Services

Slack

Messages to Slack through an incoming webhook or chat.postMessage and chat.update, with Block Kit built from a short list (headers, text, fields, buttons, context), mrkdwn escaped and Slack's limits checked.

Version 1.0.0 · 5 functions · 2 crates

#chat#notifications#webhooks#api

Functions

What use slack.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use slack_escape, slack_blocks from slack (Quillo 0.6).

slack_escape(text: str) → str

text with &, < and > escaped, as Slack's mrkdwn wants: user text cannot then ping @channel or make links.

slack_blocks(items: list) → listor an error

Block Kit blocks from a short list, each a map with one key saying what it is: { header: "..." }, { text: "*mrkdwn*" } (with image_url for a thumbnail beside it), { fields: { Label: "value" } }, { image: url, alt }, { buttons: [{ text, url, style, value, action_id }] }, { context: "small print" }, { divider: true }. Texts too long for Slack are cut with "…".

slack_webhook(webhook_url: str, text: str, blocks: list) → anyor an error

Posts to an incoming webhook (https://hooks.slack.com/services/...; empty: SLACK_WEBHOOK_URL): text is the notification's text and the fallback, blocks as slack_blocks makes them (or []). Gives { ok, status, error }.

slack_post(token: str, channel: str, text: str, blocks: list, options: map) → anyor an error

Posts with the Web API's chat.postMessage as a bot (token: empty for SLACK_BOT_TOKEN, xoxb-...) to channel (an ID like C0123ABC, or #name), options adding thread_ts (reply in a thread), unfurl_links, username, icon_emoji... SLACK_API_URL changes the server. Gives { ok, ts, channel, error }; keep ts to update the message or reply under it.

slack_update(token: str, channel: str, ts: str, text: str, blocks: list) → anyor an error

Changes a message posted before (ts from slack_post): an order's status as it moves, say.

Get it

$ quillo install slack               # slack.qwer into this folder$ quillo install slack --example     # and its example, slack.qwe

Or with curl:

$ curl -O https://quillolang.org/plugins/files/slack.qwer$ curl -O https://quillolang.org/plugins/files/slack.qwe

Then, in your program, all of it or only what you call:

main.qwe
use slack.qweruse slack_escape, slack_blocks from slack      // Quillo 0.6: only these

It uses these crates, which cargo fetches the first time the plugin is built:

[dependencies]
ureq = "2"serde_json = { version = "1", features = ["preserve_order"] }

Example

slack.qwe
// New orders announced in the team's Slack channel with Block Kit: who ordered, what, and a// button to the order.////   SLACK_WEBHOOK_URL=https://hooks.slack.com/services/... quillo run slack.qwe      then open http://localhost:8080/notify//   (or SLACK_BOT_TOKEN=xoxb-... and http://localhost:8080/post)use "slack.qwer"let WEBHOOK = env("SLACK_WEBHOOK_URL", null) ?? `${env("SINK_URL", "")}/services/T000/B000/XXXX`let ORDER = { id: "A-1042", customer: "Ada <script>", total: "€39.90", items: 3 }fn order_blocks(o) = slack_blocks([    { header: `New order ${o.id}` },    { text: `*${slack_escape(o.customer)}* ordered ${o.items} items.` },    { fields: { Total: o.total, Pickup: "Today, 17:00" } },    { buttons: [{ text: "Open order", url: `https://shop.example.com/admin/orders/${o.id}`, style: "primary" }] },    { divider: true },    { context: "Sent by the shop" },])route GET /notify = slack_webhook(WEBHOOK, `New order ${ORDER.id} (${ORDER.total})`, order_blocks(ORDER)?)?route GET /post = slack_post("", env("SLACK_CHANNEL", "#orders"), `New order ${ORDER.id}`, order_blocks(ORDER)?, { unfurl_links: false })?route GET /blocks = order_blocks(ORDER)?
$ quillo run slack.qwe$ curl 'localhost:8080/notify'$ curl 'localhost:8080/blocks'

It talks to the Redis that REDIS_URL names, else Redis on this machine. The site's test runs it against a stand-in.

Source

slack.qwer
//! Messages to Slack: through an incoming webhook or the Web API's chat.postMessage (and chat.update), with Block Kit built from a short list (headers, text, fields, images, buttons, context, dividers), mrkdwn escaped, and Slack's limits checked before it refuses.//! version: 1.0.0//!//! [dependencies]//! ureq = "2"//! serde_json = { version = "1", features = ["preserve_order"] }use serde_json::{json, Map, Value};fn env(k: &str) -> Option<String> {    std::env::var(k).ok().filter(|v| !v.trim().is_empty())}/// `text` with `&`, `<` and `>` escaped, as Slack's mrkdwn wants: user text cannot then ping @channel or make links.pub fn slack_escape(text: &str) -> String {    text.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;")}fn txt(s: &str, kind: &str) -> Value {    json!({ "type": kind, "text": s })}fn cut(s: &str, n: usize) -> String {    if s.chars().count() <= n { s.to_string() } else { format!("{}…", s.chars().take(n - 1).collect::<String>()) }}/// Block Kit blocks from a short list, each a map with one key saying what it is:/// `{ header: "..." }`, `{ text: "*mrkdwn*" }` (with `image_url` for a thumbnail beside it), `{ fields: { Label: "value" } }`,/// `{ image: url, alt }`, `{ buttons: [{ text, url, style, value, action_id }] }`, `{ context: "small print" }`, `{ divider: true }`./// Texts too long for Slack are cut with "…".pub fn slack_blocks(items: Vec<serde_json::Value>) -> Result<Vec<Value>, String> {    let mut out = vec![];    for (i, it) in items.iter().enumerate() {        let s = |k: &str| it[k].as_str();        let b = if let Some(h) = s("header") {            json!({ "type": "header", "text": txt(&cut(h, 150), "plain_text") })        } else if let Some(t) = s("text") {            let mut b = json!({ "type": "section", "text": txt(&cut(t, 3000), "mrkdwn") });            if let Some(img) = s("image_url") {                b["accessory"] = json!({ "type": "image", "image_url": img, "alt_text": s("alt").unwrap_or("image") });            }            b        } else if let Some(f) = it["fields"].as_object() {            if f.len() > 10 {                return Err(format!("item {}: a section has at most 10 fields", i + 1));            }            let fields: Vec<Value> = f.iter().map(|(k, v)| txt(&cut(&format!("*{}*\n{}", k, v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string())), 2000), "mrkdwn")).collect();            json!({ "type": "section", "fields": fields })        } else if let Some(u) = s("image") {            json!({ "type": "image", "image_url": u, "alt_text": s("alt").unwrap_or("image") })        } else if let Some(bs) = it["buttons"].as_array() {            let els: Vec<Value> = bs.iter().take(25).enumerate().map(|(j, x)| {                let mut e = json!({ "type": "button", "text": txt(&cut(x["text"].as_str().unwrap_or("Open"), 75), "plain_text"), "action_id": x["action_id"].as_str().map(str::to_string).unwrap_or_else(|| format!("button_{}", j + 1)) });                if let Some(u) = x["url"].as_str() { e["url"] = json!(u); }                if let Some(v) = x["value"].as_str() { e["value"] = json!(v); }                if let Some(st) = x["style"].as_str().filter(|s| *s == "primary" || *s == "danger") { e["style"] = json!(st); }                e            }).collect();            json!({ "type": "actions", "elements": els })        } else if let Some(c) = s("context") {            json!({ "type": "context", "elements": [txt(&cut(c, 3000), "mrkdwn")] })        } else if it["divider"].as_bool() == Some(true) {            json!({ "type": "divider" })        } else {            return Err(format!("item {}: header, text, fields, image, buttons, context or divider", i + 1));        };        out.push(b);    }    if out.len() > 50 {        return Err("a message has at most 50 blocks".into());    }    Ok(out)}fn message(text: &str, blocks: Vec<Value>, extra: &Map<String, Value>) -> Value {    let mut m = Map::new();    m.insert("text".into(), json!(cut(text, 40000)));    if !blocks.is_empty() {        m.insert("blocks".into(), json!(blocks));    }    for (k, v) in extra {        m.insert(k.clone(), v.clone());    }    Value::Object(m)}fn post(url: &str, token: Option<&str>, body: &Value) -> Result<(u16, String), String> {    let mut r = ureq::post(url).timeout(std::time::Duration::from_secs(15)).set("Content-Type", "application/json; charset=utf-8");    if let Some(t) = token {        r = r.set("Authorization", &format!("Bearer {}", t));    }    match r.send_string(&body.to_string()) {        Ok(res) => Ok((res.status(), res.into_string().unwrap_or_default())),        Err(ureq::Error::Status(c, res)) => Ok((c, res.into_string().unwrap_or_default())),        Err(e) => Err(e.to_string()),    }}/// Posts to an incoming webhook (`https://hooks.slack.com/services/...`; empty: SLACK_WEBHOOK_URL): `text` is the notification's/// text and the fallback, `blocks` as `slack_blocks` makes them (or `[]`). Gives `{ ok, status, error }`.pub fn slack_webhook(webhook_url: &str, text: &str, blocks: Vec<serde_json::Value>) -> Result<Value, String> {    let url = if webhook_url.is_empty() { env("SLACK_WEBHOOK_URL").ok_or("no webhook URL, and SLACK_WEBHOOK_URL is not set")? } else { webhook_url.to_string() };    let (status, body) = post(&url, None, &message(text, blocks, &Map::new()))?;    let ok = (200..300).contains(&status);    Ok(json!({ "ok": ok, "status": status, "error": if ok { Value::Null } else { json!(body) } }))}/// Posts with the Web API's chat.postMessage as a bot (`token`: empty for SLACK_BOT_TOKEN, `xoxb-...`) to `channel` (an ID like/// `C0123ABC`, or `#name`), `options` adding `thread_ts` (reply in a thread), `unfurl_links`, `username`, `icon_emoji`... SLACK_API_URL/// changes the server. Gives `{ ok, ts, channel, error }`; keep `ts` to update the message or reply under it.pub fn slack_post(token: &str, channel: &str, text: &str, blocks: Vec<serde_json::Value>, options: std::collections::HashMap<String, serde_json::Value>) -> Result<Value, String> {    api("chat.postMessage", token, channel, text, blocks, options)}/// Changes a message posted before (`ts` from `slack_post`): an order's status as it moves, say.pub fn slack_update(token: &str, channel: &str, ts: &str, text: &str, blocks: Vec<serde_json::Value>) -> Result<Value, String> {    let mut o = std::collections::HashMap::new();    o.insert("ts".to_string(), json!(ts));    api("chat.update", token, channel, text, blocks, o)}fn api(method: &str, token: &str, channel: &str, text: &str, blocks: Vec<Value>, options: std::collections::HashMap<String, Value>) -> Result<Value, String> {    let token = if token.is_empty() { env("SLACK_BOT_TOKEN").ok_or("no token, and SLACK_BOT_TOKEN is not set")? } else { token.to_string() };    let base = env("SLACK_API_URL").unwrap_or_else(|| "https://slack.com/api".into());    let mut extra: Map<String, Value> = options.into_iter().collect();    extra.insert("channel".into(), json!(channel));    let (status, body) = post(&format!("{}/{}", base.trim_end_matches('/'), method), Some(&token), &message(text, blocks, &extra))?;    let v: Value = serde_json::from_str(&body).unwrap_or(Value::Null);    // The Web API answers 200 with ok: false for its errors; a stand-in may answer only { ok: true }.    let ok = (200..300).contains(&status) && v["ok"].as_bool().unwrap_or(false);    Ok(json!({ "ok": ok, "status": status, "ts": v["ts"], "channel": v["channel"], "error": v["error"] }))}
Discord discord.qwer · 1.0.0Messages to Discord channels through webhooks: embeds with color, fields, images and timestamps built and checked against Discord's limits, mentions off unless asked for, threads, and the rate limit's wait reported.chatnotificationswebhooksdevops7 views · 8 downloadsMicrosoft Teams teams.qwer · 1.0.0Post to Microsoft Teams channels through incoming webhooks: Adaptive Cards built from headings, facts, columns, images and link buttons and checked before they go, sent to Workflows or classic connector URLs, with the failures Teams reports as a 200 read as errors and 429s waited out.chatnotificationswebhookscards3 views · 6 downloadsEmail APIs mail_api.qwer · 1.0.0Email sent through Resend, Postmark, SendGrid or Mailgun's HTTP APIs with one call and one message shape, attachments included, the provider picked by a word; and the request shown before it is sent.emailapinotificationsintegration7 views · 7 downloadsSMS sms.qwer · 1.0.0Text messages sent through Twilio or Vonage with one call, the encoding and segment count worked out first so an emoji does not triple the bill, and Twilio's webhook signatures checked.smsnotificationsphoneapi6 views · 8 downloadsStripe stripe.qwer · 1.0.0Stripe's API without an SDK: Checkout sessions and PaymentIntents, any endpoint called with nested form encoding and idempotency keys, amounts in each currency's minor units, and webhook events verified and read.paymentscheckoutapiwebhooks7 views · 6 downloadsTelegram bots telegram.qwer · 1.0.0A Telegram bot through the Bot API: messages split at 4096 characters, photos by URL or upload, inline keyboards checked before they are sent, updates by webhook or long polling read into one shape, the webhook's secret token compared, and the Login Widget's hash verified.chatbotswebhookssigning4 views · 8 downloads
Thread
78Useful? Like it, or add to its thread below.

Thread 0 posts

On Slack: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.