// Layout blocks: the top level of a message, a modal or an App Home tab.
//
// https://docs.slack.dev/reference/block-kit/blocks
///|
pub(all) struct SectionBlock {
block_id : String?
text : TextObject?
/// Up to ten short texts, rendered in two columns.
fields : Array[TextObject]?
accessory : BlockElement?
/// Show the section's text in full rather than truncating it.
expand : Bool?
extra : Map[String, Json]
} derive(Eq, Debug)
///|
pub(all) struct DividerBlock {
block_id : String?
extra : Map[String, Json]
} derive(Eq, Debug)
///|
pub(all) struct ImageBlock {
block_id : String?
/// One of `image_url` or `slack_file`.
image_url : String?
slack_file : SlackFileObject?
alt_text : String?
title : TextObject?
extra : Map[String, Json]
} derive(Eq, Debug)
///|
pub(all) struct ActionsBlock {
block_id : String?
elements : Array[BlockElement]?
extra : Map[String, Json]
} derive(Eq, Debug)
///|
/// Small context lines under a message: a mix of images and text objects, which
/// is why its elements are `BlockElement` and include the `Text` variant.
pub(all) struct ContextBlock {
block_id : String?
elements : Array[BlockElement]?
extra : Map[String, Json]
} derive(Eq, Debug)
///|
pub(all) struct InputBlock {
block_id : String?
label : TextObject?
element : BlockElement?
hint : TextObject?
optional : Bool?
/// Send an interaction as the user types, rather than only on submit.
dispatch_action : Bool?
extra : Map[String, Json]
} derive(Eq, Debug)
///|
pub(all) struct HeaderBlock {
block_id : String?
text : TextObject?
extra : Map[String, Json]
} derive(Eq, Debug)
///|
pub(all) struct FileBlock {
block_id : String?
external_id : String?
/// Always `remote` today.
source : String?
extra : Map[String, Json]
} derive(Eq, Debug)
///|
pub(all) struct VideoBlock {
block_id : String?
video_url : String?
thumbnail_url : String?
alt_text : String?
title : TextObject?
title_url : String?
author_name : String?
provider_name : String?
provider_icon_url : String?
description : TextObject?
extra : Map[String, Json]
} derive(Eq, Debug)
///|
/// Markdown as a single string, rather than as a text object.
///
/// Added for AI apps, which produce markdown and should not have to convert it
/// to Slack's `mrkdwn` dialect. java-slack-sdk's `parseMarkdownBlock` test is
/// the whole of its coverage.
pub(all) struct MarkdownBlock {
block_id : String?
text : String?
extra : Map[String, Json]
} derive(Eq, Debug)
///|
pub(all) struct RichTextBlock {
block_id : String?
elements : Array[RichTextBlockElement]?
extra : Map[String, Json]
} derive(Eq, Debug)
///|
/// A Slack call, from the Calls API.
pub(all) struct CallBlock {
block_id : String?
call_id : String?
/// The call's own payload, whose shape belongs to the Calls API rather than
/// to Block Kit.
call : Json?
api_decoration_available : Bool?
extra : Map[String, Json]
} derive(Eq, Debug)
///|
/// One block in a message, modal or App Home tab.
pub(all) enum LayoutBlock {
Section(SectionBlock)
Divider(DividerBlock)
Image(ImageBlock)
Actions(ActionsBlock)
Context(ContextBlock)
Input(InputBlock)
Header(HeaderBlock)
File(FileBlock)
Video(VideoBlock)
Markdown(MarkdownBlock)
RichText(RichTextBlock)
Call(CallBlock)
Alert(AlertBlock)
Card(CardBlock)
Carousel(CarouselBlock)
ContextActions(ContextActionsBlock)
Table(TableBlock)
TaskCard(TaskCardBlock)
ShareShortcut(ShareShortcutBlock)
/// Anything this version does not model, kept verbatim so it round-trips and
/// so `validate` can name it under `Strict`.
Unknown(type_~ : String, raw~ : Json)
} derive(Eq, Debug)
///|
pub fn LayoutBlock::type_name(self : Self) -> String {
match self {
Section(_) => "section"
Divider(_) => "divider"
Image(_) => "image"
Actions(_) => "actions"
Context(_) => "context"
Input(_) => "input"
Header(_) => "header"
File(_) => "file"
Video(_) => "video"
Markdown(_) => "markdown"
RichText(_) => "rich_text"
Call(_) => "call"
Alert(_) => "alert"
Card(_) => "card"
Carousel(_) => "carousel"
ContextActions(_) => "context_actions"
Table(_) => "table"
TaskCard(_) => "task_card"
ShareShortcut(_) => "share_shortcut"
Unknown(type_=t, ..) => t
}
}
///|
pub fn LayoutBlock::block_id(self : Self) -> String? {
match self {
Section(b) => b.block_id
Divider(b) => b.block_id
Image(b) => b.block_id
Actions(b) => b.block_id
Context(b) => b.block_id
Input(b) => b.block_id
Header(b) => b.block_id
File(b) => b.block_id
Video(b) => b.block_id
Markdown(b) => b.block_id
RichText(b) => b.block_id
Call(b) => b.block_id
Alert(b) => b.block_id
Card(b) => b.block_id
Carousel(b) => b.block_id
ContextActions(b) => b.block_id
Table(b) => b.block_id
TaskCard(b) => b.block_id
ShareShortcut(b) => b.block_id
Unknown(raw~, ..) =>
match raw {
Object(o) =>
match o.get("block_id") {
Some(String(s)) => Some(s)
_ => None
}
_ => None
}
}
}
///|
fn elements_of(rest : Map[String, Json]) -> Array[BlockElement]? {
match rest.get("elements") {
Some(Array(items)) => {
rest.remove("elements")
Some(items.map(BlockElement::from_json))
}
_ => None
}
}
///|
/// Always succeeds: an unrecognised block becomes `Unknown`.
///
/// Total on purpose. Strictness is `validate`, run afterwards, so that there is
/// one parser rather than a lenient one and a strict one that can drift.
pub fn LayoutBlock::from_json(j : Json) -> LayoutBlock {
let tag = type_tag_of(j)
guard j is Object(o) else { return Unknown(type_=tag, raw=j) }
let rest = fields_of(o)
rest.remove("type")
let block_id = take_str(rest, "block_id")
match tag {
"section" =>
Section({
block_id,
text: take_obj(rest, "text", TextObject::from_json),
fields: take_arr(rest, "fields", TextObject::from_json),
accessory: take_element(rest, "accessory"),
expand: take_bool(rest, "expand"),
extra: rest,
})
"divider" => Divider({ block_id, extra: rest })
"image" =>
Image({
block_id,
image_url: take_str(rest, "image_url"),
slack_file: take_obj(rest, "slack_file", SlackFileObject::from_json),
alt_text: take_str(rest, "alt_text"),
title: take_obj(rest, "title", TextObject::from_json),
extra: rest,
})
"actions" => Actions({ block_id, elements: elements_of(rest), extra: rest })
"context" => Context({ block_id, elements: elements_of(rest), extra: rest })
"input" =>
Input({
block_id,
label: take_obj(rest, "label", TextObject::from_json),
element: take_element(rest, "element"),
hint: take_obj(rest, "hint", TextObject::from_json),
optional: take_bool(rest, "optional"),
dispatch_action: take_bool(rest, "dispatch_action"),
extra: rest,
})
"header" =>
Header({
block_id,
text: take_obj(rest, "text", TextObject::from_json),
extra: rest,
})
"file" =>
File({
block_id,
external_id: take_str(rest, "external_id"),
source: take_str(rest, "source"),
extra: rest,
})
"video" =>
Video({
block_id,
video_url: take_str(rest, "video_url"),
thumbnail_url: take_str(rest, "thumbnail_url"),
alt_text: take_str(rest, "alt_text"),
title: take_obj(rest, "title", TextObject::from_json),
title_url: take_str(rest, "title_url"),
author_name: take_str(rest, "author_name"),
provider_name: take_str(rest, "provider_name"),
provider_icon_url: take_str(rest, "provider_icon_url"),
description: take_obj(rest, "description", TextObject::from_json),
extra: rest,
})
"markdown" =>
Markdown({ block_id, text: take_str(rest, "text"), extra: rest })
"rich_text" => {
let elements = match rest.get("elements") {
Some(Array(items)) => {
rest.remove("elements")
Some(items.map(RichTextBlockElement::from_json))
}
_ => None
}
RichText({ block_id, elements, extra: rest })
}
"call" =>
Call({
block_id,
call_id: take_str(rest, "call_id"),
call: take_json(rest, "call"),
api_decoration_available: take_bool(rest, "api_decoration_available"),
extra: rest,
})
"alert" =>
Alert({
block_id,
text: take_obj(rest, "text", TextObject::from_json),
level: take_str(rest, "level"),
extra: rest,
})
"card" =>
Card({
block_id,
hero_image: take_element(rest, "hero_image"),
icon: take_element(rest, "icon"),
slack_icon: take_json(rest, "slack_icon"),
title: take_obj(rest, "title", TextObject::from_json),
subtitle: take_obj(rest, "subtitle", TextObject::from_json),
body: take_obj(rest, "body", TextObject::from_json),
subtext: take_obj(rest, "subtext", TextObject::from_json),
actions: match rest.get("actions") {
Some(Array(items)) => {
rest.remove("actions")
Some(items.map(BlockElement::from_json))
}
_ => None
},
extra: rest,
})
"carousel" =>
Carousel({
block_id,
elements: match rest.get("elements") {
Some(Array(items)) => {
rest.remove("elements")
Some(items.map(LayoutBlock::from_json))
}
_ => None
},
extra: rest,
})
"context_actions" =>
ContextActions({ block_id, elements: elements_of(rest), extra: rest })
"table" =>
Table({
block_id,
column_settings: take_json(rest, "column_settings"),
rows: take_json(rest, "rows"),
extra: rest,
})
"task_card" =>
TaskCard({
block_id,
task_id: take_str(rest, "task_id"),
title: take_str(rest, "title"),
status: take_str(rest, "status"),
output: take_json(rest, "output"),
sources: take_json(rest, "sources"),
extra: rest,
})
"share_shortcut" =>
ShareShortcut({
block_id,
function_trigger_id: take_str(rest, "function_trigger_id"),
app_id: take_str(rest, "app_id"),
is_workflow_app: take_bool(rest, "is_workflow_app"),
sales_home_workflow_app_type: take_int(
rest, "sales_home_workflow_app_type",
),
app_collaborators: take_str_arr(rest, "app_collaborators"),
button_label: take_str(rest, "button_label"),
title: take_str(rest, "title"),
description: take_str(rest, "description"),
bot_user_id: take_str(rest, "bot_user_id"),
url: take_str(rest, "url"),
owning_team_id: take_str(rest, "owning_team_id"),
workflow_id: take_str(rest, "workflow_id"),
developer_trace_id: take_str(rest, "developer_trace_id"),
trigger_type: take_str(rest, "trigger_type"),
trigger_subtype: take_str(rest, "trigger_subtype"),
share_url: take_str(rest, "share_url"),
extra: rest,
})
_ => Unknown(type_=tag, raw=j)
}
}
///|
/// Take an element slot. Unlike `take_obj` this always succeeds when the key
/// holds anything at all, because `BlockElement::from_json` is total -- an
/// unrecognised element becomes `Unknown` rather than staying in `extra`, which
/// is what lets `validate` report it.
fn take_element(rest : Map[String, Json], key : String) -> BlockElement? {
match rest.get(key) {
Some(v) => {
rest.remove(key)
Some(BlockElement::from_json(v))
}
None => None
}
}
///|
pub fn LayoutBlock::to_json(self : Self) -> Json {
if self is Unknown(raw~, ..) {
return raw
}
let o = out_of(self.type_name())
put_str(o, "block_id", self.block_id())
match self {
Section(b) => {
put_obj(o, "text", b.text, TextObject::to_json)
put_arr(o, "fields", b.fields, TextObject::to_json)
put_obj(o, "accessory", b.accessory, BlockElement::to_json)
put_bool(o, "expand", b.expand)
merge_extra(o, b.extra)
}
Divider(b) => merge_extra(o, b.extra)
Image(b) => {
put_str(o, "image_url", b.image_url)
put_obj(o, "slack_file", b.slack_file, SlackFileObject::to_json)
put_str(o, "alt_text", b.alt_text)
put_obj(o, "title", b.title, TextObject::to_json)
merge_extra(o, b.extra)
}
Actions(b) => {
put_arr(o, "elements", b.elements, BlockElement::to_json)
merge_extra(o, b.extra)
}
Context(b) => {
put_arr(o, "elements", b.elements, BlockElement::to_json)
merge_extra(o, b.extra)
}
Input(b) => {
put_obj(o, "label", b.label, TextObject::to_json)
put_obj(o, "element", b.element, BlockElement::to_json)
put_obj(o, "hint", b.hint, TextObject::to_json)
put_bool(o, "optional", b.optional)
put_bool(o, "dispatch_action", b.dispatch_action)
merge_extra(o, b.extra)
}
Header(b) => {
put_obj(o, "text", b.text, TextObject::to_json)
merge_extra(o, b.extra)
}
File(b) => {
put_str(o, "external_id", b.external_id)
put_str(o, "source", b.source)
merge_extra(o, b.extra)
}
Video(b) => {
put_str(o, "video_url", b.video_url)
put_str(o, "thumbnail_url", b.thumbnail_url)
put_str(o, "alt_text", b.alt_text)
put_obj(o, "title", b.title, TextObject::to_json)
put_str(o, "title_url", b.title_url)
put_str(o, "author_name", b.author_name)
put_str(o, "provider_name", b.provider_name)
put_str(o, "provider_icon_url", b.provider_icon_url)
put_obj(o, "description", b.description, TextObject::to_json)
merge_extra(o, b.extra)
}
Markdown(b) => {
put_str(o, "text", b.text)
merge_extra(o, b.extra)
}
RichText(b) => {
put_arr(o, "elements", b.elements, RichTextBlockElement::to_json)
merge_extra(o, b.extra)
}
Call(b) => {
put_str(o, "call_id", b.call_id)
put_json(o, "call", b.call)
put_bool(o, "api_decoration_available", b.api_decoration_available)
merge_extra(o, b.extra)
}
Alert(b) => {
put_obj(o, "text", b.text, TextObject::to_json)
put_str(o, "level", b.level)
merge_extra(o, b.extra)
}
Card(b) => {
put_obj(o, "hero_image", b.hero_image, BlockElement::to_json)
put_obj(o, "icon", b.icon, BlockElement::to_json)
put_json(o, "slack_icon", b.slack_icon)
put_obj(o, "title", b.title, TextObject::to_json)
put_obj(o, "subtitle", b.subtitle, TextObject::to_json)
put_obj(o, "body", b.body, TextObject::to_json)
put_obj(o, "subtext", b.subtext, TextObject::to_json)
put_arr(o, "actions", b.actions, BlockElement::to_json)
merge_extra(o, b.extra)
}
Carousel(b) => {
put_arr(o, "elements", b.elements, LayoutBlock::to_json)
merge_extra(o, b.extra)
}
ContextActions(b) => {
put_arr(o, "elements", b.elements, BlockElement::to_json)
merge_extra(o, b.extra)
}
Table(b) => {
put_json(o, "column_settings", b.column_settings)
put_json(o, "rows", b.rows)
merge_extra(o, b.extra)
}
TaskCard(b) => {
put_str(o, "task_id", b.task_id)
put_str(o, "title", b.title)
put_str(o, "status", b.status)
put_json(o, "output", b.output)
put_json(o, "sources", b.sources)
merge_extra(o, b.extra)
}
ShareShortcut(b) => {
put_str(o, "function_trigger_id", b.function_trigger_id)
put_str(o, "app_id", b.app_id)
put_bool(o, "is_workflow_app", b.is_workflow_app)
put_int(o, "sales_home_workflow_app_type", b.sales_home_workflow_app_type)
put_str_arr(o, "app_collaborators", b.app_collaborators)
put_str(o, "button_label", b.button_label)
put_str(o, "title", b.title)
put_str(o, "description", b.description)
put_str(o, "bot_user_id", b.bot_user_id)
put_str(o, "url", b.url)
put_str(o, "owning_team_id", b.owning_team_id)
put_str(o, "workflow_id", b.workflow_id)
put_str(o, "developer_trace_id", b.developer_trace_id)
put_str(o, "trigger_type", b.trigger_type)
put_str(o, "trigger_subtype", b.trigger_subtype)
put_str(o, "share_url", b.share_url)
merge_extra(o, b.extra)
}
Unknown(raw~, ..) => raw
}
}
///|
/// Raise on the first unmodelled thing anywhere inside this block.
///
/// Outside in, so an unknown block reports as an unknown block even when it
/// also contains an unknown element -- which is what java-slack-sdk does, and
/// what its `parse_fail_on_unknown_blocks` test asserts.
pub fn LayoutBlock::validate(self : Self) -> Unit raise BlockParseError {
match self {
Unknown(type_=t, ..) => raise UnsupportedLayoutBlock(t)
Section(b) => {
validate_text_opt(b.text)
if b.fields is Some(fields) {
for field in fields {
field.validate()
}
}
if b.accessory is Some(e) {
e.validate()
}
}
Divider(_) => ()
Image(b) => validate_text_opt(b.title)
Actions(b) => validate_elements(b.elements, in_context=false)
Context(b) => validate_elements(b.elements, in_context=true)
Input(b) => {
validate_text_opt(b.label)
validate_text_opt(b.hint)
if b.element is Some(e) {
e.validate()
}
}
Header(b) => validate_text_opt(b.text)
File(_) => ()
Video(b) => {
validate_text_opt(b.title)
validate_text_opt(b.description)
}
Markdown(_) => ()
RichText(b) =>
if b.elements is Some(elements) {
for element in elements {
element.validate()
}
}
Call(_) => ()
Alert(b) => validate_text_opt(b.text)
Card(b) => {
for t in [b.title, b.subtitle, b.body, b.subtext] {
validate_text_opt(t)
}
for e in [b.hero_image, b.icon] {
if e is Some(element) {
element.validate()
}
}
validate_elements(b.actions, in_context=false)
}
Carousel(b) =>
if b.elements is Some(cards) {
for card in cards {
card.validate()
}
}
ContextActions(b) => validate_elements(b.elements, in_context=true)
Table(_) => ()
TaskCard(_) => ()
ShareShortcut(_) => ()
}
}
///|
fn validate_elements(
elements : Array[BlockElement]?,
in_context~ : Bool,
) -> Unit raise BlockParseError {
if elements is Some(list) {
for element in list {
element.validate(in_context~)
}
}
}
///|
/// An inline alert. Modals only.
///
/// https://docs.slack.dev/reference/block-kit/blocks/alert-block
pub(all) struct AlertBlock {
block_id : String?
text : TextObject?
/// `default`, `info`, `warning`, `error` or `success`.
level : String?
extra : Map[String, Json]
} derive(Eq, Debug)
///|
/// A compact structured summary, on its own or inside a carousel.
///
/// https://docs.slack.dev/reference/block-kit/blocks/card-block
pub(all) struct CardBlock {
block_id : String?
hero_image : BlockElement?
icon : BlockElement?
/// A built-in Slack icon, mutually exclusive with `icon`. Raw JSON: its
/// shape is a small enumeration Slack has not finished publishing.
slack_icon : Json?
title : TextObject?
subtitle : TextObject?
body : TextObject?
subtext : TextObject?
actions : Array[BlockElement]?
extra : Map[String, Json]
} derive(Eq, Debug)
///|
/// A horizontally scrollable row of cards.
pub(all) struct CarouselBlock {
block_id : String?
/// Cards. Typed as `LayoutBlock` rather than `CardBlock` so that a carousel
/// carrying something newer still round-trips as `Unknown` instead of
/// failing to parse.
elements : Array[LayoutBlock]?
extra : Map[String, Json]
} derive(Eq, Debug)
///|
/// Actions attached to the context of a message rather than to its body.
pub(all) struct ContextActionsBlock {
block_id : String?
elements : Array[BlockElement]?
extra : Map[String, Json]
} derive(Eq, Debug)
///|
/// A table.
///
/// `rows` and `column_settings` stay raw: a cell is either a `raw_text` or a
/// `rich_text`, the shape is not in the Block Kit reference, and inventing a
/// model from one fixture would be guessing. They round-trip exactly.
pub(all) struct TableBlock {
block_id : String?
column_settings : Json?
rows : Json?
extra : Map[String, Json]
} derive(Eq, Debug)
///|
/// Progress on a long-running task, for AI apps.
pub(all) struct TaskCardBlock {
block_id : String?
task_id : String?
/// A bare string here, not a text object -- unlike every other title in
/// Block Kit.
title : String?
status : String?
/// A `rich_text` block. Raw, so the task card and the rich-text model cannot
/// drift apart.
output : Json?
sources : Json?
extra : Map[String, Json]
} derive(Eq, Debug)
///|
/// The block Slack uses to offer a workflow for sharing.
///
/// Not in the Block Kit reference -- it is internal -- but it is 88 of the
/// blocks in the vendored corpus, so it is modelled from java-slack-sdk's
/// `ShareShortcutBlock` rather than left to `Unknown`.
pub(all) struct ShareShortcutBlock {
block_id : String?
function_trigger_id : String?
app_id : String?
is_workflow_app : Bool?
sales_home_workflow_app_type : Int?
app_collaborators : Array[String]?
button_label : String?
title : String?
description : String?
bot_user_id : String?
url : String?
owning_team_id : String?
workflow_id : String?
developer_trace_id : String?
trigger_type : String?
trigger_subtype : String?
share_url : String?
extra : Map[String, Json]
} derive(Eq, Debug)