packages feed

slackblocks-3.1.0: src/Slackblocks/Helpers.hs

-- | Pure composition conveniences. These produce ordinary checked Block Kit
-- values; sending, interaction routing and pagination state remain application-owned.
module Slackblocks.Helpers
  ( accordionSection,
    accordion,
    PaginatorOptions (..),
    paginator,
    paginate,
    workflowFromURL,
    NamedColor (..),
    Color,
    namedColor,
    hexColor,
    rgbColor,
    colorText,
    BuilderPayload (..),
    blockKitBuilderURL,
  )
where

import Control.Monad (unless, when)
import Data.Aeson (ToJSON (..), Value, encode, object, (.=))
import Data.ByteString qualified as B
import Data.ByteString.Lazy qualified as L
import Data.Char (isAscii, isHexDigit, toUpper)
import Data.Map.Strict (Map)
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Encoding qualified as TE
import Numeric (showHex)
import Slackblocks.Models
import Slackblocks.Validation

-- | Construction input for a collapsible container, initially collapsed. Edit
-- ordinary container fields, then call 'buildContainerBlock'. No ID is invented.
accordionSection :: PlainText -> [Block] -> ContainerBlockFields
accordionSection title blocks =
  (containerBlock blocks)
    { containerBlockTitle = Just title,
      containerBlockWidth = Just ContainerWidthStandard,
      containerBlockIsCollapsible = Just True,
      containerBlockDefaultCollapsed = Just True,
      containerBlockHasHeaderDivider = Just False
    }

-- | Expand checked collapsible sections, preserving their order.
accordion :: [ContainerBlock] -> Result [Block]
accordion sections = do
  when (null sections) $ failure MissingRequired [Field "sections"] "accordion.sections" "expected at least one section"
  mapM_
    ( \(index, section) ->
        unless (containerBlockIsCollapsible (inspectContainerBlock section) == Just True) $
          failure TypeMismatch [Field "sections", Index index] "accordion.section" "expected a collapsible container"
    )
    (zip [0 ..] sections)
  pure (map containerBlockAsBlock sections)

-- | Page numbers start at one. Navigation adds up to two blocks; the receiving
-- message or view checks total counts after expansion. No fields encode directly.
data PaginatorOptions = PaginatorOptions
  { -- | Nonempty prefix; navigation action IDs append .previous or .next.
    paginatorActionIdPrefix :: Text,
    -- | One-based requested page; defaults to 1.
    paginatorPage :: Integer,
    -- | Positive number of content blocks per page; defaults to 5.
    paginatorPageSize :: Integer,
    -- | Previous-button label; defaults to Previous.
    paginatorPreviousText :: Text,
    -- | Next-button label; defaults to Next.
    paginatorNextText :: Text,
    -- | Include a context block with the page number; defaults to True.
    paginatorShowPageIndicator :: Bool,
    -- | Optional actions-block ID; absent by default.
    paginatorBlockId :: Maybe Text
  }
  deriving (Eq, Show)

-- | Defaults: first page, five content blocks, Previous/Next and page indicator.
paginator :: Text -> PaginatorOptions
paginator prefix = PaginatorOptions prefix 1 5 "Previous" "Next" True Nothing

-- | Select a one-based page, add navigation and optionally its indicator. Reject empty content, invalid page/size and empty action prefix.
paginate :: PaginatorOptions -> [Block] -> Result [Block]
paginate options blocks = do
  when (null blocks) $ failure MissingRequired [Field "blocks"] "paginator.blocks" "expected content"
  when (T.null prefix) $ failure MissingRequired [Field "action_id_prefix"] "paginator.prefix" "expected a nonempty prefix"
  when (page < 1) $ failure OutOfRange [Field "page"] "paginator.page" "expected positive page"
  when (size < 1) $ failure OutOfRange [Field "page_size"] "paginator.page_size" "expected positive page size"
  let total = fromIntegral (length blocks)
      pages = (total - 1) `div` size + 1
      content = take (fromInteger (min size total)) $ drop (fromInteger (min ((page - 1) * size) total)) blocks
  when (page > pages) $ failure OutOfRange [Field "page"] "paginator.page" "page exceeds page count"
  if pages == 1
    then pure content
    else do
      previous <- if page > 1 then (: []) <$> button (paginatorPreviousText options) ".previous" (page - 1) else pure []
      next <- if page < pages then (: []) <$> button (paginatorNextText options) ".next" (page + 1) else pure []
      controls <- buildActionsBlock ((actionsBlock (previous <> next)) {actionsBlockBlockId = paginatorBlockId options})
      indicator <-
        if paginatorShowPageIndicator options
          then do
            label <- buildMarkdownText (markdownText ("Page " <> decimal page <> " of " <> decimal pages))
            context <- buildContextBlock (contextBlock [markdownTextAsContextElement label])
            pure [contextBlockAsBlock context]
          else pure []
      pure (content <> indicator <> [actionsBlockAsBlock controls])
  where
    prefix = paginatorActionIdPrefix options
    page = paginatorPage options
    size = paginatorPageSize options
    decimal = T.pack . show
    button title suffix destination = do
      label <- buildPlainText (plainText title)
      action <-
        buildButtonElement
          ( (buttonElement label)
              { buttonElementActionId = Just (prefix <> suffix),
                buttonElementValue = Just (decimal destination)
              }
          )
      pure (buttonElementAsElement action)

-- | Build a workflow's trigger and optional customizable parameters. This does
-- not call Slack or add restrictions beyond the shared trigger contract.
workflowFromURL :: Text -> [InputParameter] -> Result Workflow
workflowFromURL url parameters = do
  link <- buildTrigger ((trigger url) {triggerCustomizableInputParameters = if null parameters then Nothing else Just parameters})
  buildWorkflow (workflow link)

-- | Named attachment colors, including Slack semantic good, warning and danger values.
data NamedColor
  = -- | Slack attachment color @good@.
    Good
  | -- | Slack attachment color @warning@.
    Warning
  | -- | Slack attachment color @danger@.
    Danger
  | -- | Slack attachment color @#ff0000@.
    Red
  | -- | Slack attachment color @#0000ff@.
    Blue
  | -- | Slack attachment color @#ffff00@.
    Yellow
  | -- | Slack attachment color @#00ff00@.
    Green
  | -- | Slack attachment color @#ff8800@.
    Orange
  | -- | Slack attachment color @#8800ff@.
    Purple
  | -- | Slack attachment color @#000000@.
    Black
  deriving (Eq, Ord, Show, Enum, Bounded)

-- | Valid attachment color. Use 'colorText' with an attachment's color field.
newtype Color = Color Text deriving (Eq, Ord, Show)

-- | Return the exact Slack color string for a checked color.
colorText :: Color -> Text
colorText (Color value) = value

-- | Resolve a named attachment color without network access.
namedColor :: NamedColor -> Color
namedColor color = Color $ case color of
  Good -> "good"
  Warning -> "warning"
  Danger -> "danger"
  Red -> "#ff0000"
  Blue -> "#0000ff"
  Yellow -> "#ffff00"
  Green -> "#00ff00"
  Orange -> "#ff8800"
  Purple -> "#8800ff"
  Black -> "#000000"

-- | Six ASCII hexadecimal digits, with optional #. Letter case is preserved.
hexColor :: Text -> Result Color
hexColor input =
  let value = fromMaybe input (T.stripPrefix "#" input)
   in if T.length value == 6 && T.all (\c -> isAscii c && isHexDigit c) value
        then Right (Color ("#" <> value))
        else failure TypeMismatch [Field "color"] "color.hex" "expected six hexadecimal digits"

-- | Construct a lowercase hexadecimal color; each exact integer channel must be in 0..255.
rgbColor :: Integer -> Integer -> Integer -> Result Color
rgbColor red green blue = do
  mapM_
    ( \(index, value) ->
        when (value < 0 || value > 255) $
          failure OutOfRange [Field "color", Index index] "color.rgb" "channel must be in 0..255"
    )
    (zip [0 ..] channels)
  pure (Color ("#" <> T.concat [T.justifyRight 2 '0' (T.pack (showHex channel "")) | channel <- channels]))
  where
    channels = [red, green, blue]

-- | Explicit Builder input shapes. 'BuilderJSON' is a deliberate opaque-object
-- escape hatch; it is not validation or typed support for a Block Kit feature.
data BuilderPayload
  = -- | A block list wrapped in a blocks object.
    BuilderBlocks [Block]
  | -- | A complete checked Web API message.
    BuilderMessage MessagePayload
  | -- | A checked incoming-webhook payload.
    BuilderWebhook WebhookMessage
  | -- | A checked interaction response.
    BuilderResponse MessageResponse
  | -- | A checked modal view.
    BuilderModal ModalView
  | -- | A checked App Home view.
    BuilderHome HomeTabView
  | -- | An explicit unchecked application object; modeled support does not rely on this escape hatch.
    BuilderJSON (Map Text Value)
  deriving (Eq, Show)

instance ToJSON BuilderPayload where
  toJSON (BuilderBlocks blocks) = object ["blocks" .= blocks]
  toJSON (BuilderMessage value) = toJSON value
  toJSON (BuilderWebhook value) = toJSON value
  toJSON (BuilderResponse value) = toJSON value
  toJSON (BuilderModal value) = toJSON value
  toJSON (BuilderHome value) = toJSON value
  toJSON (BuilderJSON value) = toJSON value

-- | Percent-encode UTF-8 JSON and an optional team as a single path segment.
-- Makes no network request; rendering in Slack is a separate acceptance step.
blockKitBuilderURL :: BuilderPayload -> Maybe Text -> Text
blockKitBuilderURL payload team =
  "https://app.slack.com/block-kit-builder/"
    <> maybe "" (escape . TE.encodeUtf8) team
    <> "#"
    <> escape (L.toStrict (encode payload))
  where
    escape = T.concat . map byte . B.unpack
    byte value
      | value >= 65 && value <= 90 || value >= 97 && value <= 122 || value >= 48 && value <= 57 || value `elem` [45, 46, 95, 126] = T.singleton (toEnum (fromIntegral value))
      | otherwise = "%" <> T.pack (map toUpper (pad (showHex value "")))
    pad [one] = ['0', one]
    pad pair = pair