// Slack Web API adapter (`slack.com/api`).
//
// Mirrors Hermes `plugins/platforms/slack/adapter.py`: `chat.postMessage` for
// sends (39000-char chunks), `chat.update` for streaming edits, and the
// Events API for inbound traffic. Request signing uses the standard
// `v0=HMAC_SHA256(signing_secret, "v0:{ts}:{body}")` scheme.
///|
/// API root, overridable with `[slack.extra] api_base`.
pub fn slack_api_base(cfg : PlatformConfig) -> String {
let base = cfg.extra_str("api_base", "https://slack.com/api")
if base.has_suffix("/") {
base[:base.length() - 1].to_owned()
} else {
base
}
}
///|
/// `chat.postMessage` payload.
pub fn slack_send_payload(
channel : String,
text : String,
thread_ts : String?,
) -> Json {
let payload = json_obj([
("channel", Json::string(channel)),
("text", Json::string(text)),
])
obj_with_opt(payload, "thread_ts", thread_ts)
}
///|
/// `chat.update` payload (used for streaming/draft edits).
pub fn slack_update_payload(
channel : String,
ts : String,
text : String,
) -> Json {
json_obj([
("channel", Json::string(channel)),
("ts", Json::string(ts)),
("text", Json::string(text)),
])
}
///|
/// Slack headers (`Authorization: Bearer`, JSON content type).
pub fn slack_headers(token : String) -> Map[String, String] {
let m = json_headers()
m.set("Content-Type", "application/json; charset=utf-8")
m.set("Authorization", "Bearer \{token}")
m
}
///|
/// Slack answers HTTP 200 with `{"ok":false,"error":"…"}` on failure.
fn slack_result(
prefix : String,
res : Result[Json, ApiError],
id_key : String,
) -> SendResult {
match res {
Err(e) => send_failure(prefix, e)
Ok(j) =>
if json_field_bool(j, "ok").unwrap_or(false) {
send_ok(json_field_string(j, id_key), j)
} else {
let err = json_field_string(j, "error").unwrap_or(j.stringify())
SendResult::failed(
"\{prefix}: \{err}",
retryable=err == "ratelimited" ||
classify_send_error(err) == RateLimited,
kind=classify_send_error(err),
raw=j.stringify(),
)
}
}
}
///|
/// Call a Slack Web API method.
async fn slack_call(
cfg : PlatformConfig,
token : String,
api_method : String,
payload : Json,
id_key : String,
) -> SendResult {
let url = "\{slack_api_base(cfg)}/\{api_method}"
slack_result(
"slack \{api_method}",
post_json(url, payload, slack_headers(token)),
id_key,
)
}
///|
/// Send ``content``, chunked at Slack's 39000-char limit, threaded on
/// ``thread_ts`` (defaults to ``reply_to``).
pub async fn slack_send(
cfg : PlatformConfig,
chat_id : String,
content : String,
reply_to? : String,
thread_ts? : String,
) -> SendResult {
match cfg.token_of() {
None => missing_config(Slack, "SLACK_BOT_TOKEN")
Some(token) =>
if content.is_empty() {
return SendResult::ok()
} else {
let thread = match thread_ts {
Some(t) => Some(t)
None => reply_to
}
let mut last = SendResult::ok()
let mut first_id : String? = None
for chunk in chunk_text(content, Platform::max_message_length(Slack)) {
let r = send_with_retry(() => {
slack_call(
cfg,
token,
"chat.postMessage",
slack_send_payload(chat_id, chunk, thread),
"ts",
)
})
if !r.success {
return r
}
if first_id is None {
first_id = r.message_id
}
last = r
}
{ ..last, message_id: first_id, }
}
}
}
///|
/// Slack has no typing indicator; we surface the assistant thread status
/// (`assistant.threads.setStatus`) and ignore failures.
pub async fn slack_typing(
cfg : PlatformConfig,
chat_id : String,
thread_ts? : String,
) -> Unit {
match cfg.token_of() {
None => ()
Some(token) =>
match thread_ts {
Some(ts) => {
let payload = json_obj([
("channel_id", Json::string(chat_id)),
("thread_ts", Json::string(ts)),
("status", Json::string("is thinking…")),
])
ignore(
slack_call(cfg, token, "assistant.threads.setStatus", payload, "ok"),
)
}
None => ()
}
}
}
///|
/// Update a previously sent message (`chat.update`).
pub async fn slack_edit(
cfg : PlatformConfig,
chat_id : String,
message_id : String,
content : String,
) -> SendResult {
match cfg.token_of() {
None => missing_config(Slack, "SLACK_BOT_TOKEN")
Some(token) =>
slack_call(
cfg,
token,
"chat.update",
slack_update_payload(chat_id, message_id, content),
"ts",
)
}
}
///|
/// Delete a message (`chat.delete`).
pub async fn slack_delete(
cfg : PlatformConfig,
chat_id : String,
message_id : String,
) -> SendResult {
match cfg.token_of() {
None => missing_config(Slack, "SLACK_BOT_TOKEN")
Some(token) => {
let payload = json_obj([
("channel", Json::string(chat_id)),
("ts", Json::string(message_id)),
])
slack_call(cfg, token, "chat.delete", payload, "ts")
}
}
}
///|
/// Add a reaction (`reactions.add`).
pub async fn slack_react(
cfg : PlatformConfig,
chat_id : String,
message_id : String,
emoji : String,
) -> SendResult {
match cfg.token_of() {
None => missing_config(Slack, "SLACK_BOT_TOKEN")
Some(token) => {
let name = emoji.replace_all(old=":", new="")
let payload = json_obj([
("channel", Json::string(chat_id)),
("timestamp", Json::string(message_id)),
("name", Json::string(name)),
])
slack_call(cfg, token, "reactions.add", payload, "ok")
}
}
}
///|
/// Channel metadata (`conversations.info`).
pub async fn slack_get_chat(
cfg : PlatformConfig,
chat_id : String,
) -> ChatInfo? {
match cfg.token_of() {
None => None
Some(token) => {
let url = "\{slack_api_base(cfg)}/conversations.info?channel=\{url_quote(chat_id)}"
match get_json(url, slack_headers(token)) {
Err(_) => None
Ok(j) =>
if !json_field_bool(j, "ok").unwrap_or(false) {
None
} else {
match json_field_obj(j, "channel") {
Some(c) =>
Some({
name: or_opt(
json_field_string(c, "name"),
json_field_string(c, "name_normalized"),
).unwrap_or(chat_id),
chat_type: if json_field_bool(c, "is_im").unwrap_or(false) {
"im"
} else if json_field_bool(c, "is_private").unwrap_or(false) {
"group"
} else {
"channel"
},
chat_id,
})
None => None
}
}
}
}
}
}
///|
/// Lowercase hex encoding of ``b``.
pub fn hex_lower(b : ArrayView[Byte]) -> String {
let digits = "0123456789abcdef"
let sb = StringBuilder()
for x in b {
let v = x.to_int()
sb.write_string(digits[v / 16:v / 16 + 1].to_owned())
sb.write_string(digits[v % 16:v % 16 + 1].to_owned())
}
sb.to_string()
}
///|
/// Slack request signature (`v0=`).
pub fn slack_signature(
secret : String,
timestamp : String,
body : String,
) -> String {
"v0=\{hmac_sha256_hex(secret, "v0:\{timestamp}:\{body}")}"
}
///|
/// Verify a Slack `X-Slack-Signature` header (constant-time comparison).
pub fn slack_verify_signature(
secret : String,
timestamp : String,
body : String,
signature : String,
) -> Bool {
constant_time_eq(slack_signature(secret, timestamp, body), signature)
}
///|
/// Verify a whole Slack webhook request: the `X-Slack-Signature` must match
/// and `X-Slack-Request-Timestamp` must be within ``window_s`` seconds of
/// ``now_s`` (Slack's recommended replay window is five minutes).
pub fn slack_request_verified(
secret : String,
timestamp : String,
body : String,
signature : String,
now_s : Int64,
window_s~ : Int64 = 300L,
) -> Bool {
let ts = @string.parse_int64(timestamp) catch { _ => return false }
let age = if now_s >= ts { now_s - ts } else { ts - now_s }
age <= window_s && slack_verify_signature(secret, timestamp, body, signature)
}
///|
/// The `url_verification` challenge, if this body is a Slack handshake.
pub fn slack_challenge(body : String) -> String? {
let j = @json.parse(body) catch { _ => return None }
match json_field_string(j, "type") {
Some("url_verification") => json_field_string(j, "challenge")
_ => None
}
}
///|
/// Parse a Slack Events API body (`event_callback`) into message events.
/// Bot/echo messages and message subtypes (edits, joins) are skipped.
pub fn slack_parse_event(
body : String,
bot_user_id? : String,
) -> Array[MessageEvent] {
let j = @json.parse(body) catch { _ => return [] }
let out : Array[MessageEvent] = []
let ev = match json_field_obj(j, "event") {
Some(e) => e
None => return out
}
let ev_type = json_field_string(ev, "type").unwrap_or("")
if ev_type != "message" && ev_type != "app_mention" {
return out
}
if json_field_string(ev, "subtype") is Some(_) {
return out
}
let user_id = json_field_string(ev, "user")
match bot_user_id {
Some(bot) =>
match user_id {
Some(u) => if u == bot { return out }
None => ()
}
None => ()
}
if json_field_string(ev, "bot_id") is Some(_) {
return out
}
let text = json_field_string(ev, "text").unwrap_or("")
let channel = json_field_string(ev, "channel").unwrap_or("")
if channel.is_empty() {
return out
}
let thread = json_field_string(ev, "thread_ts")
let mtype = if text.has_prefix("/") { Command } else { Text }
out.push(
MessageEvent::new(
text,
mtype,
Slack,
channel,
user_id?,
user_name?=json_field_string(ev, "username"),
message_id?=json_field_string(ev, "ts"),
reply_to_message_id?=thread,
media=[],
timestamp=now_ms(),
),
)
out
}