Commit Diff


commit - a9801d55e62952ed925510ec98eda85d55316cc8
commit + f0b13cc7907aa4956f3ddb6bc9d1d0582ba9f24a
blob - 713e36615cccf83123c9e9e350e9b59df47de6dd
blob + 6357a0b8e6fbfb2e36bb921a6ad493a5ffced8d8
--- README.md
+++ README.md
@@ -46,6 +46,23 @@ list:
     https://api.slack.com/reference/surfaces/formatting
 
 
+Block Kit
+---------
+If the message parses as JSON and is a non-empty array whose
+first element is an object with a string "type" field, sm posts
+it as a Block Kit "blocks" payload instead of plain text.  A
+static fallback "text" is sent alongside the blocks so push and
+legacy notifications have something to display.  Anything else,
+including messages that happen to be valid JSON but do not match
+that shape, is sent verbatim as text.
+
+    cat blocks.json | sm C03DEET2M18 -
+
+See the Block Kit reference:
+
+    https://api.slack.com/reference/block-kit/blocks
+
+
 Configuration
 -------------
 sm reads a single environment variable:
blob - f796de77ad1b133e4e8f4c2773d8712cb784b8b8
blob + ead27c957c1e2973af69301537062a409b83c8fd
--- sm.1
+++ sm.1
@@ -71,6 +71,26 @@ identical to CommonMark
 See
 .Lk https://api.slack.com/reference/surfaces/formatting
 for the full reference.
+.Sh BLOCK KIT
+If
+.Ar message
+parses as JSON and is a non-empty array whose first element is an
+object with a string
+.Dq type
+field,
+.Nm
+posts it as a Block Kit
+.Dq blocks
+payload instead of plain text.
+A static fallback
+.Dq text
+is sent alongside the blocks so push and legacy notifications have
+something to display.
+Any other input, including messages that happen to be valid JSON
+but do not match that shape, is sent verbatim as text.
+See
+.Lk https://api.slack.com/reference/block-kit/blocks
+for the block reference.
 .Sh ENVIRONMENT
 .Bl -tag -width SLACK_USER_TOKEN
 .It Ev SLACK_USER_TOKEN
@@ -104,6 +124,11 @@ Pipe a message from another command:
 .Bd -literal -offset indent
 $ uname -a | sm C03DEET2M18 \-
 .Ed
+.Pp
+Post a Block Kit payload from stdin:
+.Bd -literal -offset indent
+$ cat blocks.json | sm C03DEET2M18 \-
+.Ed
 .Sh SEE ALSO
 .Xr tls_init 3
 .Sh AUTHORS
blob - daa5961a41a66a88aebb2ff37c335b3473a9e282
blob + 5af5cf5f9d004d463d7b4becf2bf5a64b68d2c81
--- sm.rs
+++ sm.rs
@@ -26,16 +26,9 @@ use std::{
     time::Duration,
 };
 
-use jackson::{FromJson, ToJson, json_struct};
+use jackson::{FromJson, Value, json_struct};
 
 json_struct! {
-    struct PostMessage {
-        channel: String,
-        text: String,
-    }
-}
-
-json_struct! {
     struct PostResponse {
         ok: bool,
         error: Option<String>,
@@ -49,6 +42,7 @@ const PORT: u16 = 443;
 const PATH: &str = "/api/chat.postMessage";
 const MAX_MSG_BYTES: usize = 40_000;
 const TIMEOUT_SECS: u64 = 30;
+const BLOCKS_FALLBACK_TEXT: &str = "(message contains blocks)";
 
 fn main() {
     process::exit(run());
@@ -102,11 +96,7 @@ fn run() -> i32 {
         }
     };
 
-    let req = PostMessage {
-        channel: channel.clone(),
-        text: message,
-    };
-    let body = match req.to_json().stringify() {
+    let body = match build_body(channel, &message) {
         Ok(s) => s,
         Err(e) => {
             eprintln!("sm: encode body: {e}");
@@ -170,6 +160,48 @@ fn validate_message(s: &str) -> Result<(), String> {
 }
 
 //////////////////////////////////////////////////////////////////////////////
+// Payload
+//////////////////////////////////////////////////////////////////////////////
+
+// If the message parses as JSON and looks like a Block Kit payload — a
+// non-empty array whose first element is an object with a string "type"
+// field — send it as "blocks" with a static fallback "text" for
+// notifications.  Otherwise the message is sent verbatim as "text".
+fn build_body(channel: &str, msg: &str) -> Result<String, String> {
+    let mut fields: Vec<(String, Value)> =
+        vec![("channel".to_string(), Value::String(channel.to_string()))];
+
+    match detect_blocks(msg) {
+        Some(blocks) => {
+            fields.push(("blocks".to_string(), blocks));
+            fields.push((
+                "text".to_string(),
+                Value::String(BLOCKS_FALLBACK_TEXT.to_string()),
+            ));
+        }
+        None => {
+            fields.push(("text".to_string(), Value::String(msg.to_string())));
+        }
+    }
+
+    Value::Object(fields).stringify().map_err(|e| e.to_string())
+}
+
+fn detect_blocks(msg: &str) -> Option<Value> {
+    let v: Value = msg.parse().ok()?;
+    let looks_like_blocks = v
+        .as_array()
+        .and_then(|a| a.first())
+        .and_then(Value::as_object)
+        .map(|o| {
+            o.iter()
+                .any(|(k, val)| k == "type" && val.as_str().is_some())
+        })
+        .unwrap_or(false);
+    if looks_like_blocks { Some(v) } else { None }
+}
+
+//////////////////////////////////////////////////////////////////////////////
 // libtls FFI
 //////////////////////////////////////////////////////////////////////////////
 
@@ -402,10 +434,11 @@ fn parse_ok(resp: &[u8]) -> Result<(), String> {
         find(resp, b"\r\n\r\n").ok_or("no header terminator in response")?;
     let body = std::str::from_utf8(&resp[sep + 4..])
         .map_err(|e| format!("non-utf8 body: {e}"))?;
-    let v: jackson::Value =
-        body.parse().map_err(|e: jackson::Error| format!("json: {e}"))?;
-    let r = PostResponse::from_json(&v)
-        .map_err(|e| format!("decode body: {e}"))?;
+    let v: jackson::Value = body
+        .parse()
+        .map_err(|e: jackson::Error| format!("json: {e}"))?;
+    let r =
+        PostResponse::from_json(&v).map_err(|e| format!("decode body: {e}"))?;
     if r.ok {
         return Ok(());
     }