Skip to content

Commit 557ff5f

Browse files
authored
Merge pull request #72 from unisoncomputing/cp/slack-webhooks
Slack/Discord webhook support
2 parents 5852b12 + 09440b1 commit 557ff5f

File tree

24 files changed

+630
-318
lines changed

24 files changed

+630
-318
lines changed

share-api.cabal

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@ library
183183
Share.Web.UCM.SyncV2.Impl
184184
Share.Web.UCM.SyncV2.Queries
185185
Share.Web.UCM.SyncV2.Types
186+
Share.Web.UI.Links
186187
Unison.Server.NameSearch.Postgres
187188
Unison.Server.Share.Definitions
188189
Unison.Server.Share.DefinitionSummary

sql/2025-05-16_webhook-name.sql

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
-- Add a name to the webhooks table
2+
ALTER TABLE notification_webhooks
3+
ADD COLUMN name TEXT NULL CONSTRAINT notification_webhooks_name_not_empty CHECK (name <> ''::text)
4+
;
5+
6+
UPDATE notification_webhooks
7+
SET name = 'Webhook'
8+
WHERE name IS NULL;
9+
10+
ALTER TABLE notification_webhooks
11+
ALTER COLUMN name SET NOT NULL
12+
;

src/Share/BackgroundJobs/Webhooks/Worker.hs

Lines changed: 173 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,53 @@
1+
{-# LANGUAGE DataKinds #-}
2+
{-# LANGUAGE KindSignatures #-}
3+
{-# LANGUAGE MultiWayIf #-}
14
{-# LANGUAGE StandaloneDeriving #-}
25

36
-- | This module provides the background worker for sending notification webhooks.
47
module Share.BackgroundJobs.Webhooks.Worker (worker) where
58

6-
import Control.Lens
7-
import Control.Monad.Except (runExceptT)
9+
import Control.Lens hiding ((.=))
10+
import Control.Monad.Except (ExceptT (..), runExceptT)
811
import Crypto.JWT (JWTError)
912
import Data.Aeson (FromJSON (..), ToJSON (..))
1013
import Data.Aeson qualified as Aeson
14+
import Data.Aeson.Types ((.=))
1115
import Data.ByteString.Lazy.Char8 qualified as BL
16+
import Data.List.Extra qualified as List
1217
import Data.Text qualified as Text
1318
import Data.Text.Encoding qualified as Text
1419
import Data.Time (UTCTime)
20+
import Data.Time qualified as Time
21+
import Data.Time.Clock.POSIX qualified as POSIX
1522
import Ki.Unlifted qualified as Ki
1623
import Network.HTTP.Client qualified as HTTPClient
1724
import Network.HTTP.Types qualified as HTTP
25+
import Network.URI (URI)
26+
import Network.URI qualified as URI
1827
import Share.BackgroundJobs.Errors (reportError)
1928
import Share.BackgroundJobs.Monad (Background)
2029
import Share.BackgroundJobs.Webhooks.Queries qualified as WQ
2130
import Share.BackgroundJobs.Workers (newWorker)
2231
import Share.Env qualified as Env
23-
import Share.IDs (NotificationEventId, NotificationWebhookId)
32+
import Share.IDs
33+
import Share.IDs qualified as IDs
2434
import Share.JWT (JWTParam (..))
2535
import Share.JWT qualified as JWT
2636
import Share.Metrics qualified as Metrics
2737
import Share.Notifications.Queries qualified as NQ
28-
import Share.Notifications.Types (HydratedEventPayload, NotificationEvent (..), NotificationTopic, eventData_, eventUserInfo_, hydratedEventTopic)
38+
import Share.Notifications.Types
2939
import Share.Notifications.Webhooks.Secrets (WebhookConfig (..), WebhookSecretError)
3040
import Share.Notifications.Webhooks.Secrets qualified as Webhooks
3141
import Share.Postgres qualified as PG
3242
import Share.Postgres.Notifications qualified as Notif
3343
import Share.Prelude
3444
import Share.Utils.Logging qualified as Logging
35-
import Share.Utils.URI (URIParam (..))
45+
import Share.Utils.URI (URIParam (..), uriToText)
3646
import Share.Web.Authorization qualified as AuthZ
3747
import Share.Web.Share.DisplayInfo.Queries qualified as DisplayInfoQ
3848
import Share.Web.Share.DisplayInfo.Types (UnifiedDisplayInfo)
49+
import Share.Web.Share.DisplayInfo.Types qualified as DisplayInfo
50+
import Share.Web.UI.Links qualified as Links
3951
import UnliftIO qualified
4052

4153
data WebhookSendFailure
@@ -188,23 +200,168 @@ tryWebhook event webhookId = UnliftIO.handleAny (\someException -> pure $ Just $
188200
Left jwtErr -> throwError $ JWTError event.eventId webhookId jwtErr
189201
Right jwt -> pure jwt
190202
let payloadWithJWT = payload {jwt = JWTParam payloadJWT}
191-
let reqResult =
192-
HTTPClient.requestFromURI uri <&> \req ->
193-
req
194-
{ HTTPClient.method = "POST",
195-
HTTPClient.responseTimeout = webhookTimeout,
196-
HTTPClient.requestHeaders = [(HTTP.hContentType, "application/json")],
197-
HTTPClient.requestBody = HTTPClient.RequestBodyLBS $ Aeson.encode $ payloadWithJWT
198-
}
199-
req <- case reqResult of
200-
Left parseErr -> throwError $ InvalidRequest event.eventId webhookId parseErr
201-
Right req -> pure req
203+
req <- ExceptT $ buildWebhookRequest webhookId uri event payloadWithJWT
202204
resp <- liftIO $ HTTPClient.httpLbs req proxiedHTTPManager
203205
case HTTPClient.responseStatus resp of
204206
httpStatus@(HTTP.Status status _)
205207
| status >= 400 -> throwError $ ReceiverError event.eventId webhookId httpStatus $ HTTPClient.responseBody resp
206208
| otherwise -> pure ()
207209

210+
data ChatProvider
211+
= Slack
212+
| Discord
213+
deriving stock (Show, Eq)
214+
215+
-- A type to unify slack and discord message types
216+
data MessageContent (provider :: ChatProvider) = MessageContent
217+
{ -- Text of the bot message
218+
preText :: Text,
219+
-- Title of the attachment
220+
title :: Text,
221+
-- Text of the attachment
222+
content :: Text,
223+
-- Title link
224+
mainLink :: URI,
225+
authorName :: Text,
226+
authorLink :: URI,
227+
authorAvatarUrl :: Maybe URI,
228+
thumbnailUrl :: Maybe URI,
229+
timestamp :: UTCTime
230+
}
231+
deriving stock (Show, Eq)
232+
233+
instance ToJSON (MessageContent 'Slack) where
234+
toJSON MessageContent {preText, content, title, mainLink, authorName, authorLink, authorAvatarUrl, thumbnailUrl, timestamp} =
235+
Aeson.object
236+
[ "text" .= preText,
237+
"attachments"
238+
.= [ Aeson.object
239+
[ "title" .= cutOffText 250 title,
240+
"title_link" .= uriToText mainLink,
241+
"text" .= content,
242+
"author_name" .= authorName,
243+
"author_link" .= uriToText authorLink,
244+
"author_icon" .= fmap uriToText authorAvatarUrl,
245+
"thumb_url" .= fmap uriToText thumbnailUrl,
246+
"ts" .= (round (POSIX.utcTimeToPOSIXSeconds timestamp) :: Int64),
247+
"color" .= ("#36a64f" :: Text)
248+
]
249+
]
250+
]
251+
252+
instance ToJSON (MessageContent 'Discord) where
253+
toJSON MessageContent {preText, content, title, mainLink, authorName, authorLink, authorAvatarUrl, thumbnailUrl, timestamp} =
254+
Aeson.object
255+
[ "username" .= ("Share Notifications" :: Text),
256+
"avatar_url" .= Links.unisonLogoImage,
257+
"content" .= cutOffText 1950 preText,
258+
"embeds"
259+
.= [ Aeson.object
260+
[ "title" .= cutOffText 250 title,
261+
"url" .= uriToText mainLink,
262+
"description" .= cutOffText 4000 content,
263+
"author" .= Aeson.object ["name" .= cutOffText 250 authorName, "url" .= uriToText authorLink, "icon_url" .= fmap uriToText authorAvatarUrl],
264+
"timestamp" .= (Just $ Text.pack $ Time.formatTime Time.defaultTimeLocale "%FT%T%QZ" timestamp),
265+
"thumbnail" .= fmap (\url -> Aeson.object ["url" .= uriToText url]) thumbnailUrl
266+
]
267+
]
268+
]
269+
270+
buildWebhookRequest :: NotificationWebhookId -> URI -> NotificationEvent NotificationEventId UnifiedDisplayInfo UTCTime HydratedEventPayload -> WebhookEventPayload JWTParam -> Background (Either WebhookSendFailure HTTPClient.Request)
271+
buildWebhookRequest webhookId uri event defaultPayload = do
272+
if
273+
| isSlackWebhook uri -> buildChatAppPayload (Proxy @Slack) uri
274+
| isDiscordWebhook uri -> buildChatAppPayload (Proxy @Discord) uri
275+
| otherwise -> pure $ buildDefaultPayload
276+
where
277+
isSlackWebhook :: URI -> Bool
278+
isSlackWebhook uri =
279+
case URI.uriRegName <$> URI.uriAuthority uri of
280+
Nothing -> False
281+
Just regName -> List.isPrefixOf "hooks.slack.com" regName
282+
283+
isDiscordWebhook :: URI -> Bool
284+
isDiscordWebhook uri =
285+
case (URI.uriRegName <$> URI.uriAuthority uri) of
286+
Just regName ->
287+
Text.isPrefixOf "discord.com" (Text.pack regName)
288+
&& Text.isPrefixOf "/api/webhooks" (Text.pack $ URI.uriPath uri)
289+
_ -> False
290+
291+
buildDefaultPayload :: Either WebhookSendFailure HTTPClient.Request
292+
buildDefaultPayload =
293+
HTTPClient.requestFromURI uri
294+
& mapLeft (\e -> InvalidRequest event.eventId webhookId e)
295+
<&> \req ->
296+
req
297+
{ HTTPClient.method = "POST",
298+
HTTPClient.responseTimeout = webhookTimeout,
299+
HTTPClient.requestHeaders = [(HTTP.hContentType, "application/json")],
300+
HTTPClient.requestBody = HTTPClient.RequestBodyLBS $ Aeson.encode defaultPayload
301+
}
302+
303+
buildChatAppPayload :: forall provider. (ToJSON (MessageContent provider)) => Proxy provider -> URI -> Background (Either WebhookSendFailure HTTPClient.Request)
304+
buildChatAppPayload _ uri = do
305+
let actorName = event.eventActor ^. DisplayInfo.name_
306+
actorHandle = "(" <> IDs.toText (PrefixedID @"@" $ event.eventActor ^. DisplayInfo.handle_) <> ")"
307+
actorAuthor = maybe "" (<> " ") actorName <> actorHandle
308+
actorAvatarUrl = event.eventActor ^. DisplayInfo.avatarUrl_
309+
actorLink <- Links.userProfilePage (event.eventActor ^. DisplayInfo.handle_)
310+
messageContent :: MessageContent provider <- case event.eventData of
311+
HydratedProjectBranchUpdatedPayload payload -> do
312+
let pbShorthand = (projectBranchShortHandFromParts payload.projectInfo.projectShortHand payload.branchInfo.branchShortHand)
313+
title = "Branch " <> IDs.toText pbShorthand <> " was just updated."
314+
preText = title
315+
link <- Links.notificationLink event.eventData
316+
pure $
317+
MessageContent
318+
{ preText = preText,
319+
content = "Branch updated",
320+
title = title,
321+
mainLink = link,
322+
authorName = actorAuthor,
323+
authorLink = actorLink,
324+
authorAvatarUrl = actorAvatarUrl,
325+
thumbnailUrl = Nothing,
326+
timestamp = event.eventOccurredAt
327+
}
328+
HydratedProjectContributionCreatedPayload payload -> do
329+
let pbShorthand = (projectBranchShortHandFromParts payload.projectInfo.projectShortHand payload.contributionInfo.contributionSourceBranch.branchShortHand)
330+
title = payload.contributionInfo.contributionTitle
331+
description = fromMaybe "" $ payload.contributionInfo.contributionDescription
332+
preText = "New Contribution in " <> IDs.toText pbShorthand
333+
link <- Links.notificationLink event.eventData
334+
pure $
335+
MessageContent
336+
{ preText = preText,
337+
content = description,
338+
title = title,
339+
mainLink = link,
340+
authorName = actorAuthor,
341+
authorLink = actorLink,
342+
authorAvatarUrl = actorAvatarUrl,
343+
thumbnailUrl = Nothing,
344+
timestamp = event.eventOccurredAt
345+
}
346+
pure $
347+
HTTPClient.requestFromURI uri
348+
& mapLeft (\e -> InvalidRequest event.eventId webhookId e)
349+
<&> ( \req ->
350+
req
351+
{ HTTPClient.method = "POST",
352+
HTTPClient.responseTimeout = webhookTimeout,
353+
HTTPClient.requestHeaders = [(HTTP.hContentType, "application/json")],
354+
HTTPClient.requestBody = HTTPClient.RequestBodyLBS $ Aeson.encode messageContent
355+
}
356+
)
357+
358+
-- | Nicely cut off text so that it doesn't exceed the max length
359+
cutOffText :: Int -> Text -> Text
360+
cutOffText maxLength text =
361+
if Text.length text > maxLength
362+
then Text.take (maxLength - 3) text <> "..."
363+
else text
364+
208365
attemptWebhookSend ::
209366
AuthZ.AuthZReceipt ->
210367
(NotificationEvent NotificationEventId UnifiedDisplayInfo UTCTime HydratedEventPayload -> NotificationWebhookId -> IO (Maybe WebhookSendFailure)) ->

src/Share/Github.hs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import Share.Utils.Show
2222
import Share.Utils.URI (URIParam (URIParam))
2323
import Share.Web.App
2424
import Share.Web.Errors
25+
import Share.Web.UI.Links qualified as Links
2526

2627
data GithubError
2728
= GithubClientError ClientError
@@ -210,7 +211,7 @@ githubOauthApi = Proxy
210211
githubAuthenticationURI :: OAuth2State -> WebApp URI
211212
githubAuthenticationURI oauth2State = do
212213
oauthClientId <- asks Env.githubClientID
213-
redirectUri <- sharePath ["oauth", "redirect"]
214+
redirectUri <- Links.oauthRedirect
214215
let ghAuth = Just (URIAuth "" "github.com" "")
215216
ghLink = linkURI (uri oauthClientId redirectUri)
216217
in return $

src/Share/IDs.hs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ module Share.IDs
4646
NotificationSubscriptionId (..),
4747
Email (..),
4848
projectBranchShortHandToBranchShortHand,
49+
projectBranchShortHandFromParts,
4950
JTI (..),
5051
CategoryName (..),
5152
CategoryID (..),
@@ -473,6 +474,10 @@ projectBranchShortHandToBranchShortHand :: ProjectBranchShortHand -> BranchShort
473474
projectBranchShortHandToBranchShortHand ProjectBranchShortHand {contributorHandle, branchName} =
474475
BranchShortHand {contributorHandle, branchName}
475476

477+
projectBranchShortHandFromParts :: ProjectShortHand -> BranchShortHand -> ProjectBranchShortHand
478+
projectBranchShortHandFromParts ProjectShortHand {userHandle, projectSlug} BranchShortHand {contributorHandle, branchName} =
479+
ProjectBranchShortHand {userHandle, projectSlug, contributorHandle, branchName}
480+
476481
-- | A fully specified branch identifier of the form '@user/project/@contributor/branch' or
477482
--
478483
-- With contributor

src/Share/Notifications/API.hs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -289,13 +289,15 @@ type CreateWebhookEndpoint =
289289

290290
data CreateWebhookRequest
291291
= CreateWebhookRequest
292-
{ url :: URIParam
292+
{ url :: URIParam,
293+
name :: Text
293294
}
294295

295296
instance FromJSON CreateWebhookRequest where
296297
parseJSON = withObject "CreateWebhookRequest" $ \o -> do
297298
url <- o .: "url"
298-
pure CreateWebhookRequest {url}
299+
name <- o .: "name"
300+
pure CreateWebhookRequest {url, name}
299301

300302
data CreateWebhookResponse
301303
= CreateWebhookResponse

src/Share/Notifications/Impl.hs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,10 +140,10 @@ removeSubscriptionDeliveryMethodsEndpoint userHandle subscriptionId callerUserId
140140
pure ()
141141

142142
createWebhookEndpoint :: UserHandle -> UserId -> API.CreateWebhookRequest -> WebApp API.CreateWebhookResponse
143-
createWebhookEndpoint userHandle callerUserId API.CreateWebhookRequest {url} = do
143+
createWebhookEndpoint userHandle callerUserId API.CreateWebhookRequest {url, name = webhookName} = do
144144
User {user_id = notificationUserId} <- UserQ.expectUserByHandle userHandle
145145
_authZReceipt <- AuthZ.permissionGuard $ AuthZ.checkDeliveryMethodsManage callerUserId notificationUserId
146-
webhookId <- NotifOps.createWebhookDeliveryMethod notificationUserId url
146+
webhookId <- NotifOps.createWebhookDeliveryMethod notificationUserId url webhookName
147147
pure $ API.CreateWebhookResponse {webhookId}
148148

149149
deleteWebhookEndpoint :: UserHandle -> UserId -> NotificationWebhookId -> WebApp ()

src/Share/Notifications/Ops.hs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,11 @@ listNotificationDeliveryMethods userId maySubscriptionId = do
3131

3232
pure $ (EmailDeliveryMethod <$> emailDeliveryMethods) <> (WebhookDeliveryMethod <$> webhookDeliveryMethods)
3333

34-
createWebhookDeliveryMethod :: UserId -> URIParam -> WebApp NotificationWebhookId
35-
createWebhookDeliveryMethod userId uriParam = do
34+
createWebhookDeliveryMethod :: UserId -> URIParam -> Text -> WebApp NotificationWebhookId
35+
createWebhookDeliveryMethod userId uriParam webhookName = do
3636
-- Note that we can't be completely transactional between postgres and vault here.
3737
webhookId <- PG.runTransaction do
38-
NotifQ.createWebhookDeliveryMethod userId
38+
NotifQ.createWebhookDeliveryMethod userId webhookName
3939
let webhookConfig = WebhookConfig {uri = uriParam}
4040
WebhookSecrets.putWebhookConfig webhookId webhookConfig
4141
pure webhookId

0 commit comments

Comments
 (0)