commit f0b13cc7907aa4956f3ddb6bc9d1d0582ba9f24a from: Murilo Ijanc date: Sat Apr 18 19:30:10 2026 UTC Auto-detect Block Kit payloads and post as blocks. If the message parses as a non-empty JSON array whose first element is an object with a string "type" field, send it as a "blocks" payload with a static fallback "text" for push and legacy notifications. Any other input, including messages that happen to be valid JSON but do not match that shape, is sent verbatim as text. 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, @@ -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 { + 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 { + 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(()); }