|
| 1 | +{-# LANGUAGE DataKinds #-} |
| 2 | +{-# LANGUAGE KindSignatures #-} |
| 3 | +{-# LANGUAGE MultiWayIf #-} |
1 | 4 | {-# LANGUAGE StandaloneDeriving #-}
|
2 | 5 |
|
3 | 6 | -- | This module provides the background worker for sending notification webhooks.
|
4 | 7 | module Share.BackgroundJobs.Webhooks.Worker (worker) where
|
5 | 8 |
|
6 |
| -import Control.Lens |
7 |
| -import Control.Monad.Except (runExceptT) |
| 9 | +import Control.Lens hiding ((.=)) |
| 10 | +import Control.Monad.Except (ExceptT (..), runExceptT) |
8 | 11 | import Crypto.JWT (JWTError)
|
9 | 12 | import Data.Aeson (FromJSON (..), ToJSON (..))
|
10 | 13 | import Data.Aeson qualified as Aeson
|
| 14 | +import Data.Aeson.Types ((.=)) |
11 | 15 | import Data.ByteString.Lazy.Char8 qualified as BL
|
| 16 | +import Data.List.Extra qualified as List |
12 | 17 | import Data.Text qualified as Text
|
13 | 18 | import Data.Text.Encoding qualified as Text
|
14 | 19 | import Data.Time (UTCTime)
|
| 20 | +import Data.Time qualified as Time |
| 21 | +import Data.Time.Clock.POSIX qualified as POSIX |
15 | 22 | import Ki.Unlifted qualified as Ki
|
16 | 23 | import Network.HTTP.Client qualified as HTTPClient
|
17 | 24 | import Network.HTTP.Types qualified as HTTP
|
| 25 | +import Network.URI (URI) |
| 26 | +import Network.URI qualified as URI |
18 | 27 | import Share.BackgroundJobs.Errors (reportError)
|
19 | 28 | import Share.BackgroundJobs.Monad (Background)
|
20 | 29 | import Share.BackgroundJobs.Webhooks.Queries qualified as WQ
|
21 | 30 | import Share.BackgroundJobs.Workers (newWorker)
|
22 | 31 | import Share.Env qualified as Env
|
23 |
| -import Share.IDs (NotificationEventId, NotificationWebhookId) |
| 32 | +import Share.IDs |
| 33 | +import Share.IDs qualified as IDs |
24 | 34 | import Share.JWT (JWTParam (..))
|
25 | 35 | import Share.JWT qualified as JWT
|
26 | 36 | import Share.Metrics qualified as Metrics
|
27 | 37 | import Share.Notifications.Queries qualified as NQ
|
28 |
| -import Share.Notifications.Types (HydratedEventPayload, NotificationEvent (..), NotificationTopic, eventData_, eventUserInfo_, hydratedEventTopic) |
| 38 | +import Share.Notifications.Types |
29 | 39 | import Share.Notifications.Webhooks.Secrets (WebhookConfig (..), WebhookSecretError)
|
30 | 40 | import Share.Notifications.Webhooks.Secrets qualified as Webhooks
|
31 | 41 | import Share.Postgres qualified as PG
|
32 | 42 | import Share.Postgres.Notifications qualified as Notif
|
33 | 43 | import Share.Prelude
|
34 | 44 | import Share.Utils.Logging qualified as Logging
|
35 |
| -import Share.Utils.URI (URIParam (..)) |
| 45 | +import Share.Utils.URI (URIParam (..), uriToText) |
36 | 46 | import Share.Web.Authorization qualified as AuthZ
|
37 | 47 | import Share.Web.Share.DisplayInfo.Queries qualified as DisplayInfoQ
|
38 | 48 | 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 |
39 | 51 | import UnliftIO qualified
|
40 | 52 |
|
41 | 53 | data WebhookSendFailure
|
@@ -188,23 +200,168 @@ tryWebhook event webhookId = UnliftIO.handleAny (\someException -> pure $ Just $
|
188 | 200 | Left jwtErr -> throwError $ JWTError event.eventId webhookId jwtErr
|
189 | 201 | Right jwt -> pure jwt
|
190 | 202 | 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 |
202 | 204 | resp <- liftIO $ HTTPClient.httpLbs req proxiedHTTPManager
|
203 | 205 | case HTTPClient.responseStatus resp of
|
204 | 206 | httpStatus@(HTTP.Status status _)
|
205 | 207 | | status >= 400 -> throwError $ ReceiverError event.eventId webhookId httpStatus $ HTTPClient.responseBody resp
|
206 | 208 | | otherwise -> pure ()
|
207 | 209 |
|
| 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 | + |
208 | 365 | attemptWebhookSend ::
|
209 | 366 | AuthZ.AuthZReceipt ->
|
210 | 367 | (NotificationEvent NotificationEventId UnifiedDisplayInfo UTCTime HydratedEventPayload -> NotificationWebhookId -> IO (Maybe WebhookSendFailure)) ->
|
|
0 commit comments