Slack Web API client: typed requests for the core method families, a generic call for the rest, Block Kit, cursor pagination, rate-limit tiers and request-signature verification. No dependencies; runs on every backend.
moon add marianoguerra/slack
moon add marianoguerra/slack-http # optional: the native transportimport {
"marianoguerra/slack/api",
"marianoguerra/slack/blocks",
"marianoguerra/slack/client",
"marianoguerra/slack/model",
"marianoguerra/slack/typed",
"marianoguerra/slack-http/transport",
}// nocheck: needs a transport and a real token.
///|
let client = @client.Client::new(transport, "xoxb-...")
///|
let response = client.chat_post_message(
channel="C0123456789",
text="Deploy finished", // what shows in notifications
blocks=[
@blocks.header("Deploy finished"),
@blocks.section_text("*api* 1.4.2 -> 1.4.3"),
@blocks.divider(),
@blocks.actions([
@blocks.button("Roll back", action_id="rollback", style="danger"),
]),
],
)///|
test {
let blocks = [
@blocks.header("Report"),
@blocks.section_text("*Revenue* is up"),
@blocks.divider(),
]
let json = @blocks.blocks_to_json(blocks)
inspect(
json.stringify(),
content=(
#|[{"type":"header","text":{"type":"plain_text","text":"Report"}},{"type":"section","text":{"type":"mrkdwn","text":"*Revenue* is up"}},{"type":"divider"}]
),
)
// ...and reading them back gives the same values.
let parsed = @blocks.parse_blocks(json).unwrap()
assert_eq(parsed, blocks)
}///|
test {
let payload : Json = [{ "type": "some_block_from_2027", "id": "x" }]
let parsed = @blocks.parse_blocks(payload).unwrap()
assert_eq(parsed[0].type_name(), "some_block_from_2027")
// Nothing was dropped.
assert_eq(@blocks.blocks_to_json(parsed), payload)
}///|
test {
let payload : Json = [{ "type": "some_block_from_2027" }]
try @blocks.parse_blocks(payload, policy=Strict) |> ignore catch {
e =>
assert_eq(
e.to_string(),
"Unsupported layout block type: some_block_from_2027",
)
} noraise {
_ => fail("expected Strict to refuse an unmodelled block")
}
}///|
test {
let response = @api.ApiResponse::of_json({
"ok": true,
"user": {
"id": "U0123456789",
"name": "alice",
"tz": "Europe/Madrid",
"profile": { "display_name": "Al", "real_name": "Alice Alvarez" },
"a_field_from_next_tuesday": ["kept", "verbatim"],
},
})
guard @model.User::from_json(response.get("user").unwrap()) is Some(user) else {
fail("expected a user")
}
assert_eq(user.id, "U0123456789")
assert_eq(user.tz, Some("Europe/Madrid"))
// display_name, then real_name, then name, then the id -- the order Slack's
// own clients fall back in. `name` is the handle, which for most workspaces
// stopped being the thing anyone recognises years ago.
assert_eq(user.display(), "Al")
// Nothing is dropped: what this version does not model is in `extra`, and
// `to_json` puts it back exactly where it was.
assert_eq(
user.extra.get("a_field_from_next_tuesday"),
Some(["kept", "verbatim"]),
)
}///|
test {
guard @model.Message::from_json({
"type": "message",
"ts": "1700000000.000100",
"text": "Deploy finished",
"blocks": [
{
"type": "section",
"text": { "type": "mrkdwn", "text": "*Deploy* finished" },
},
],
"reactions": [{ "name": "tada", "count": 2, "users": ["U1", "U2"] }],
})
is Some(message) else {
fail("expected a message")
}
assert_true(message.blocks.unwrap()[0] is Section(_))
assert_eq(message.reactions.unwrap()[0].count, Some(2))
// `thread_ts` on a reply, `ts` on anything else -- including the message that
// started a thread, which carries both and whose two values are equal.
assert_eq(message.thread_root(), Some("1700000000.000100"))
}// nocheck: needs a transport and a real token.
///|
let api = @typed.Api::of(client)
///|
async fn who_said_what(channel : String) -> Unit {
// A Page[Message], with the cursor normalised: Slack ends a walk with an
// EMPTY next_cursor rather than by omitting it, and `cursor` is None there.
let page = api.conversations_history(channel~, limit=50)
for message in page.items {
if message.user is Some(id) {
let user = api.users_info(user=id)
println("\{user.display()}: \{message.text.unwrap_or("")}")
}
}
// Not modelled, so it goes through the client -- same function, same
// connection.
api.client().bookmarks_list(channel_id=channel) |> ignore
}///|
test {
let shape = @typed.ResponseShapeError(api_method="users.info", key="user")
assert_eq(shape.code(), "slack_response_shape_error")
assert_true(shape.slack() is None)
let refused = @typed.Slack(@api.RateLimitedError(retry_after=30))
assert_eq(refused.code(), "slack_webapi_rate_limited_error")
assert_true(refused.slack() is Some(RateLimitedError(retry_after=30)))
}///|
test {
let verifier = @signature.Verifier::new("8f742231b10e8888abcd99yyyzzz85a5")
let body = "token=xyzz0WbapA4vBCDEFasx0q6G&team_id=T1DC2JH3J&text=hi"
let timestamp = "1531420618"
let signature = verifier.sign(body, timestamp)
// `now` is an argument rather than a clock read, so this package builds on
// wasm and every expiry test is deterministic. In a receiver, pass the
// current epoch second.
verifier.verify_at_time(signature, body, timestamp, 1531420620L)
// A tampered body is refused.
try
verifier.verify_at_time(signature, body + "!", timestamp, 1531420620L)
catch {
@signature.WrongSignature(..) => ()
e => fail("unexpected error: \{e}")
} noraise {
_ => fail("expected a tampered body to be refused")
}
}// nocheck: needs a transport.
///|
let channels = client.conversations_list_all(
types=["public_channel"],
max_pages=5, // a Tier 2 method on a big workspace will rate-limit you
)///|
test {
let paginator = @client.Paginator::new()
inspect(@api.encode_form(paginator.next().unwrap()), content="limit=200")
// Slack sends a cursor: there is another page.
paginator.accept(
@api.ApiResponse::of_json({
"ok": true,
"response_metadata": { "next_cursor": "dXNlcjpVMDYxTkZUVDI=" },
}),
)
inspect(
@api.encode_form(paginator.next().unwrap()),
content="limit=200&cursor=dXNlcjpVMDYxTkZUVDI%3D",
)
// Slack sends an EMPTY cursor: that was the last page.
paginator.accept(
@api.ApiResponse::of_json({
"ok": true,
"response_metadata": { "next_cursor": "" },
}),
)
assert_true(paginator.is_finished())
assert_eq(paginator.next(), None)
}///|
test {
// chat.postMessage is roughly one message per second PER CHANNEL, which is
// why the bucket key includes the channel.
assert_eq(
@methods.tier_of(@methods.chat_post_message),
Some(SpecialChatPostMessage),
)
assert_eq(@methods.allowed_requests_per_minute("conversations.list"), 20)
let throttler = @ratectl.Throttler::new()
let now = 1_700_000_000_000L // epoch milliseconds; this package reads no clock
for _ in 0..<20 {
assert_eq(throttler.acquire(@methods.conversations_list, now), 0L)
}
// The 21st call in the same instant is told to wait.
assert_true(throttler.acquire(@methods.conversations_list, now) > 0L)
}///|
test {
let response = @api.ApiResponse::of_json({
"ok": false,
"error": "missing_scope",
"needed": "chat:write",
"provided": "identify",
})
let error = @api.SlackError::PlatformError(result=response)
assert_eq(error.code(), "slack_webapi_platform_error")
assert_eq(error.describe_error(), "An API error occurred: missing_scope")
// The java-slack-sdk phrasing, which is the one that shows what was missing.
assert_eq(
error.describe_error_java(),
"status: 200, error: missing_scope, needed: chat:write, provided: identify, warning: ",
)
}///|
test {
let response = @api.ApiResponse::of_json({
"ok": true,
"response_metadata": {
"messages": [
"[ERROR] unsupported type: sections [json-pointer:/blocks/0/type]",
],
},
})
let diagnostics = response.response_metadata.diagnostics()
assert_eq(diagnostics[0].0, @api.Severity::Err)
assert_eq(
diagnostics[0].1,
"unsupported type: sections [json-pointer:/blocks/0/type]",
)
}///|
test {
let fake = @testing.FakeTransport::ok(["{\"ok\":true,\"ts\":\"1.2\"}"])
let client = @client.Client::new(fake, "xoxb-test")
// ... your code calls client.chat_post_message(...) ...
ignore(client)
assert_eq(fake.sent_count(), 0)
assert_eq(fake.describe(), "fake transport")
}///|
test {
let ws = @mock.Workspace::new()
let alice = ws.add_user(name="alice", real_name="Alice Alvarez")
let general = ws.add_channel(name="general", members=[alice])
ws.install_app(token="xoxb-test", user=alice, scopes=[
"chat:write", "channels:history",
])
|> ignore
// Post, and read it back. Two calls, one workspace.
let posted = ws.invoke(
"chat.postMessage",
@mock.Form::of([("channel", general), ("text", "hello")]),
token="xoxb-test",
)
assert_eq(posted.status, 200)
assert_eq(ws.messages(general).length(), 1)
// A scope it was not granted is refused the way Slack refuses it, with the
// `needed` and `provided` that `describe_error_java` reads.
let refused = ws.invoke("users.list", @mock.Form::new(), token="xoxb-test")
let result = @api.ApiResponse::of_http(refused)
assert_eq(result.error, Some("missing_scope"))
assert_eq(result.needed, Some("users:read"))
}///|
test {
let ws = @mock.Workspace::demo()
ws.inject("chat.postMessage", RateLimited(30))
ws.inject(
"conversations.history",
Http(status=503, body="upstream", headers=[]),
)
ws.inject("auth.test", Disconnect("connection reset"))
assert_eq(ws.pending_faults(), 3)
}// nocheck: sketch.
///|
pub impl @api.Transport for MyTransport with fn send(self, request) {
// request.url, request.http_method, request.headers, request.body (Bytes)
let response = my_http_post(request.url, request.headers, request.body)
{
status: response.status,
// Header names MUST be lowercased: Slack's own casing is not stable.
headers: lowercase_keys(response.headers),
body: response.text,
}
}
///|
pub impl @api.Transport for MyTransport with fn describe(self) {
"my transport"
}| Package | What is in it | |||
|---|---|---|---|---|
| marianoguerra/slack/api | Transport, request building, form encoding, the response envelope, the error taxonomy | |||
| marianoguerra/slack/blocks | Block Kit: layout blocks, elements, rich text, composition objects, builders | |||
| marianoguerra/slack/client | Client, the typed calls, the paginator | |||
| marianoguerra/slack/crypto | SHA-256, HMAC-SHA256, hex, constant-time compare | |||
| marianoguerra/slack/signature | Request-signature verification | |||
| marianoguerra/slack/methods | 326 method names and their rate-limit tiers | |||
| marianoguerra/slack/ratectl | Leaky-bucket throttling, with an injected clock | |||
| marianoguerra/slack/model | The domain model: User, Channel, Message and nine more | |||
| marianoguerra/slack/typed | Api: the same calls, answering domain types | \n | marianoguerra/slack/testing | FakeTransport: a scripted transport |
| marianoguerra/slack/mock | An in-memory Slack workspace, and a transport over it | |||
| marianoguerra/slack-http/transport | The native transport, over moonbitlang/async. A separate module, and optional |
Slack Web API client: typed requests for the core method families, a generic call for the rest, Block Kit, cursor pagination, rate-limit tiers and request-signature verification. No dependencies; runs on every backend.