///|
/// Text and encoding helpers shared by the platform presets: JSON escaping,
/// percent-encoding, request signing and the two body layouts every message is
/// rendered from.
///|
const HEX_DIGITS : String = "0123456789ABCDEF"
///|
fn hex_pair(value : Int) -> String {
let high = value >> 4
let low = value & 15
HEX_DIGITS[high:high + 1].to_owned() + HEX_DIGITS[low:low + 1].to_owned()
}
///|
fn json_escape(text : String) -> String {
let buf = StringBuilder()
for c in text {
match c {
'"' => buf.write_string("\\\"")
'\\' => buf.write_string("\\\\")
'\n' => buf.write_string("\\n")
'\r' => buf.write_string("\\r")
'\t' => buf.write_string("\\t")
_ =>
if c.to_int() < 0x20 {
buf.write_string("\\u00" + hex_pair(c.to_int()))
} else {
buf.write_char(c)
}
}
}
buf.to_string()
}
///|
fn jstr(text : String) -> String {
"\"" + json_escape(text) + "\""
}
///|
/// Build a JSON object from already-encoded value fragments, so an optional key
/// simply does not appear instead of turning into a null the platform may
/// reject.
fn jobj(fields : Array[(String, String)]) -> String {
let buf = StringBuilder()
buf.write_char('{')
let mut first = true
for pair in fields {
let (key, value) = pair
if !first {
buf.write_char(',')
}
first = false
buf.write_string(jstr(key))
buf.write_char(':')
buf.write_string(value)
}
buf.write_char('}')
buf.to_string()
}
///|
fn jarr(items : Array[String]) -> String {
let buf = StringBuilder()
buf.write_char('[')
let mut first = true
for item in items {
if !first {
buf.write_char(',')
}
first = false
buf.write_string(item)
}
buf.write_char(']')
buf.to_string()
}
///|
fn is_unreserved(value : Int) -> Bool {
(value >= 48 && value <= 57) ||
(value >= 65 && value <= 90) ||
(value >= 97 && value <= 122) ||
value == 45 ||
value == 46 ||
value == 95 ||
value == 126
}
///|
/// Percent-encode a value for a URL query string or a form body.
fn url_encode(text : String) -> String {
let buf = StringBuilder()
for byte in @utf8.encode(text) {
let value = byte.to_int()
if is_unreserved(value) {
// Unreserved bytes are ASCII, so decoding one byte back gives the char.
buf.write_string(@utf8.decode_lossy(Bytes::from_array([byte])))
} else {
buf.write_char('%')
buf.write_string(hex_pair(value))
}
}
buf.to_string()
}
///|
/// HMAC-SHA256, base64-encoded: the shared half of the two Chinese chat
/// platforms' request signing.
fn hmac_sha256_base64(key : String, message : String) -> String {
let mac = @crypto.hmac(
@crypto.SHA256::new(),
@utf8.encode(key)[:],
@utf8.encode(message)[:],
)
@base64.encode(mac.unsafe_reinterpret_as_bytes())
}
///|
/// DingTalk signs `timestamp + "\n" + secret` with the secret as the key, and
/// carries the timestamp in milliseconds.
fn dingtalk_signature(secret : String, timestamp_ms : UInt64) -> String {
url_encode(
hmac_sha256_base64(secret, timestamp_ms.to_string() + "\n" + secret),
)
}
///|
/// Feishu signs the *empty* message with `timestamp + "\n" + secret` as the key
/// (the inverse of DingTalk) and carries the timestamp in seconds.
fn feishu_signature(secret : String, timestamp_s : UInt64) -> String {
hmac_sha256_base64(timestamp_s.to_string() + "\n" + secret, "")
}
///|
fn field_lines(msg : Message, bullet : String) -> String {
let buf = StringBuilder()
for pair in msg.fields {
let (key, value) = pair
buf.write_string("\n" + bullet + key + ": " + value)
}
buf.to_string()
}
///|
fn plain_body(msg : Message, ctx : Context) -> String {
msg.title +
"\n" +
msg.body +
field_lines(msg, "") +
"\n\n--\n" +
ctx.product +
" @ " +
ctx.host
}
///|
fn markdown_body(msg : Message, ctx : Context) -> String {
"**" +
msg.title +
"**\n\n" +
msg.body +
field_lines(msg, "- ") +
"\n\n> " +
ctx.product +
" @ " +
ctx.host
}
///|
/// Two decimal digits at a known offset of an ASCII string. Used only on the
/// ISO timestamp produced by `iso8601`, so byte and char offsets coincide.
fn two_digits(text : String, start : Int) -> Int {
let mut value = 0
let mut index = 0
for c in text {
if index >= start && index < start + 2 {
value = value * 10 + c.to_int() - 48
}
index += 1
}
value
}
///|
/// Format epoch milliseconds as an ISO 8601 UTC timestamp
/// (civil-from-days algorithm, Howard Hinnant, public domain).
fn iso8601(epoch_ms : UInt64) -> String {
let ms = epoch_ms % 1000UL
let secs = epoch_ms / 1000UL
let days = secs / 86400UL
let rem = secs % 86400UL
let z = days + 719468UL
let era = z / 146097UL
let doe = z - era * 146097UL
let yoe = (doe - doe / 1460UL + doe / 36524UL - doe / 146096UL) / 365UL
let y0 = yoe + era * 400UL
let doy = doe - (365UL * yoe + yoe / 4UL - yoe / 100UL)
let mp = (5UL * doy + 2UL) / 153UL
let day = doy - (153UL * mp + 2UL) / 5UL + 1UL
let month = if mp < 10UL { mp + 3UL } else { mp - 9UL }
let year = if month <= 2UL { y0 + 1UL } else { y0 }
pad4(year) +
"-" +
pad2(month) +
"-" +
pad2(day) +
"T" +
pad2(rem / 3600UL) +
":" +
pad2(rem % 3600UL / 60UL) +
":" +
pad2(rem % 60UL) +
"." +
pad3(ms) +
"Z"
}
///|
fn pad2(n : UInt64) -> String {
if n < 10UL {
"0" + n.to_string()
} else {
n.to_string()
}
}
///|
fn pad3(n : UInt64) -> String {
if n < 10UL {
"00" + n.to_string()
} else if n < 100UL {
"0" + n.to_string()
} else {
n.to_string()
}
}
///|
fn pad4(n : UInt64) -> String {
if n < 1000UL {
"0" + pad3(n)
} else {
n.to_string()
}
}
///|
/// `Tue, 29 Sep 2026 10:00:00 +0000` — email needs the weekday and month names
/// spelled out, which the ISO timestamp used everywhere else does not carry.
fn rfc5322_date(epoch_ms : UInt64) -> String {
let weekdays = ["Thu", "Fri", "Sat", "Sun", "Mon", "Tue", "Wed"]
let months = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov",
"Dec",
]
let iso = iso8601(epoch_ms)
let days = epoch_ms / 1000UL / 86400UL
weekdays[(days % 7UL).to_int()] +
", " +
iso[8:10].to_owned() +
" " +
months[two_digits(iso, 5) - 1] +
" " +
iso[:4].to_owned() +
" " +
iso[11:19].to_owned() +
" +0000"
}