Our Slack integration now supports unfurling
What is Slack unfurling?
"Unfurling" is what Slack calls adding a small preview to a link that is posted in your workspace. For example, a preview with the content of a Tweet, or the social image & description of a web page.
This all works well until you try to share links that are private, and thus result in a 404, 403 or 401 error code for the Slack bot that is processing your link.
Luckily, Slack provides a way for your app to hook into links that are posted with your domain and lets you generate that preview yourself.
What it looks like in our integration
With our Flare Slack app, you can now unfurl urls to:
Projects
Errors & Error Occurrences
Getting started
The first thing you'll need is a Slack app with the correct link:read
& link:write
permissions.
Once you have that set up, you can head to "Event subscriptions" and enable events. This will require a request URL that needs to be verified.
The code to handle this verification is pretty straightforward, Slack sends you a request with a type of url_verification
and you reply back with the challenge value after verifying that the signature is correct.
Here's a bit of example code:
class SlackWebhookController
{
public function __invoke(SlackRequest $request)
{
if ($request->get('type') === 'url_verification') {
return response()->json([
'challenge' => $request->get('challenge'),
]);
}
// Other event types
}
}
Inside our SlackRequest
class we'll verify the signature, you can find the signing secret in your Slack app's credentials page:
class SlackRequest extends FormRequest
{
public function authorize()
{
$timestamp = $this->header('X-Slack-Request-Timestamp');
$slackSignature = $this->header('X-Slack-Signature', '');
$body = $this->getContent();
$requestSignature = "v0:{$timestamp}:{$body}";
$requestSignature = 'v0='.hash_hmac('sha256', $requestSignature, config('services.slack.signing_secret'));
return hash_equals($requestSignature, $slackSignature);
}
}
Authenticating users
Before your Slack app is able to handle unfurling, you'll need your users to be able to install the app into their workspace. Slack provides an oauth flow for this. Luckily Laravel has a first party package called Socialite that is able to handle most of the difficult work for this.
Check out the Socialite docs on how to get set up.
We'll create a SlackAuthorizationController
that will handle the installation of our Slack app into the workspace of our users.
The first step is to create a redirect to Slack's oauth url:
class SlackAuthorizationController
{
public function authorize(): RedirectResponse
{
return Socialite::driver('slack')
->asBotUser()
->setScopes(['links:read', 'links:write'])
->redirectUrl(action([self::class, 'callback']))
->redirect();
}
}
As you can see, Socialite makes this very straightforward, the important part here is the ->asBotUser()
call which will need to be repeated when fetching the token in the callback part.
The ->redirectUrl()
method calls for a callback()
method, let's create that one to fetch the bot token and save the necessary information to facilitate our unfurling.
I've added comments in the code to clarify what is happening:
class SlackAuthorizationController
{
public function callback(): RedirectResponse
{
/**
Here we fetch the user from the code that is returned by Slack in the url.
As before, Socialite makes this easy for us. We do have to make sure
that we set up the driver with the same scopes and redirectUrl.
**/
$user = Socialite::driver('slack')
->asBotUser()
->setScopes(['links:read', 'links:write'])
->redirectUrl(action([self::class, 'callback']))
->user();
if (! $user->token) {
/**
You can handle the error case here, this usually happens
when the user cancels the authentication flow.
**/
}
/**
Next, we'll use the auth.test endpoint from Slack to make sure
the token works, from this endpoint we also receive more
information about which team & user installed the app.
**/
$response = Http::withToken($user->token)->post('https://slack.com/api/auth.test')->json();
/**
We'll create a link between the logged in user, its Slack user_id
and the team_id from the team the app was installed in.
**/
SlackAuthentication::firstOrCreate([
'user_id' => Auth::user()->id,
'slack_id' => $response['user_id'],
'team_id' => $response['team_id'],
]);
/**
We also want to keep track of active Slack app installations
and the token that is assigned to them. Keep in mind that
the token needs to be stored encrypted in the database.
**/
SlackInstallation::updateOrCreate([
'authed_user_id' => $response['user_id'],
'team_id' => $response['team_id'],
], [
'access_token' => $user->token,
]);
return redirect()->action([SlackSettingsController::class, 'index']);
}
}
We keep separate SlackAuthentication
and SlackInstallation
records, because once a Slack app is installed in the workspace, other users in the workspace need to be able to link their Slack user ID to the team_id as well.
Once the Slack app is installed into a team's workspace, we can start handling the link_shared
events.
Handling the link_shared
event
After all this set up, we're finally ready to start handling the link_shared event.
When this event comes in, you'll need to check if there is an installed app for the team, and we can find the user in our Slack authentications.
A simplified example from our webhook controller:
class SlackWebhookController
{
public function __invoke(SlackRequest $request)
{
if ($request->get('event')['type'] === 'link_shared') {
$slackInstallation = SlackInstallation::where('team_id', $request->get('team_id'))->first();
// If we can't find a Slack installation, just respond with a 200
if (! $slackInstallation) {
return response();
}
$data = $request->all();
$slackUserId = $data['authorizations'][0]['user_id'];
$user = User::whereHas('slackAuthentications', function (Builder $query) use ($slackUserId) {
$query->where('slack_id', $slackUserId);
})->first();
/**
If we don't find any authenticated users with this Slack id
send an authentication message instead. We'll cover this
below.
**/
if (! $user) {
return $this->handleUserAuthentication($slackId, $data, $slackInstallation);
}
/**
Create an array of the urls in the message
**/
$urls = array_map(fn ($link) => $link['url'], $data['event']['links']);
/**
Dispatch a job that handles the unfurling of the URL
**/
dispatch(function () use ($slackUserId, $data) {
app(UnfurlUrlsAction::class)->execute([
'teamId' => $data['team_id'],
'userId' => $slackUserId,
'urls' => $urls,
'unfurlId' => $data['event']['unfurl_id'],
'source' => $data['event']['source'],
'messageTs' => $data['event']['message_ts'],
]);
});
return response();
}
}
Handling user authentication
Slack provides a way for your app to let the user know that unfurling is available inside their workspace, but they haven't authenticated yet. This is what we're calling in the $this->handleUserAuthentication()
method above:
protected function handleUserAuthentication(string $slackId, array $data, SlackInstallation $slackInstallation): Response
{
Http::withToken($slackInstallation->access_token)
->post('https://slack.com/api/chat.unfurl', [
'unfurl_id' => $data['event']['unfurl_id'],
'source' => $data['event']['source'],
'ts' => $data['event']['message_ts'],
'user_auth_url' => action([SlackAuthorizationController::class, 'linkUserToTeam'], [
'slackId' => $slackId,
'teamId' => $slackInstallation->team_id,
]),
])->throw();
return ok();
}
We use the SlackInstallation's token to post to the chat.unfurl
endpoint, with a user_auth_url
parameter. This displays the following message to the user:
Once they click the link, we link the user to the team by creating a new SlackAuthentication:
public function linkUserToTeam(string $slackId, string $teamId)
{
Auth::user()->slackAuthentications()->firstOrCreate([
'slack_id' => $slackId,
'team_id' => $teamId,
]);
return redirect()->action([SlackSettingsController::class, 'index']);
}
In our full integration we go a small step further and then unfurl the URL they just tried to send, but this is beyond the scope of this blogpost.
Unfurling the URL
The final step is actually unfurling the URL that the user has posted. This can be done by calling the chat.unfurl
API method with the following data:
Http::withToken($slackInstallation->access_token)
->contentType('application/json; charset=utf-8')
->post('https://slack.com/api/chat.unfurl', [
'unfurl_id' => $unfurlData->unfurlId,
'source' => $unfurlData->source,
'ts' => $unfurlData->messageTs,
'unfurls' => [
'https://example.com' => [
'blocks' => [...]
]
],
])->throw();
The important part here is the unfurls
parameter, this is an array of data keyed by the URLs that you were able to unfurl. For more information about the blocks
I suggest Slack's great documentation on them.
The parsing from a URL to which preview you want to generate is very app-specific, so I won't go into detail about this.