// Internal helpers shared by every adapter: JSON construction and accessors,
// message chunking (Hermes `truncate_message`), plain-text rendering, and the
// send-failure classifier (Hermes `classify_send_error`).
///|
/// Raised when a required field is missing or a send was refused.
pub suberror UtilsError {
SendRefused(String)
MissingConfig(String)
InvalidArgument(String)
}
///|
/// Build a JSON object from explicit fields.
pub fn json_obj(fields : Array[(String, Json)]) -> Json {
let m : Map[String, Json] = Map([])
for f in fields {
m.set(f.0, f.1)
}
Json::object(m)
}
///|
/// Add ``key`` to ``obj`` only when ``value`` is `Some` (absent keys stay
/// absent — platforms reject `null` where a field must not exist).
pub fn obj_with_opt(obj : Json, key : String, value : String?) -> Json {
match value {
Some(s) => if obj is Object(m) { m.set(key, Json::string(s)) }
None => ()
}
obj
}
///|
/// Read a string field from a JSON object.
pub fn json_field_string(obj : Json, key : String) -> String? {
match obj {
Object(m) =>
match m.get(key) {
Some(String(s)) => Some(s)
_ => None
}
_ => None
}
}
///|
/// Read a numeric field from a JSON object as `Int`.
pub fn json_field_int(obj : Json, key : String) -> Int32? {
match obj {
Object(m) =>
match m.get(key) {
Some(Number(n, ..)) => Some(n.to_int())
Some(String(s)) => Some(@string.parse_int(s)) catch { _ => None }
_ => None
}
_ => None
}
}
///|
/// Read a boolean field from a JSON object.
pub fn json_field_bool(obj : Json, key : String) -> Bool? {
match obj {
Object(m) =>
match m.get(key) {
Some(True) => Some(true)
Some(False) => Some(false)
_ => None
}
_ => None
}
}
///|
/// Read a nested object field.
pub fn json_field_obj(obj : Json, key : String) -> Json? {
match obj {
Object(m) =>
match m.get(key) {
Some(Object(_) as o) => Some(o)
_ => None
}
_ => None
}
}
///|
/// Read an array field.
pub fn json_field_array(obj : Json, key : String) -> Array[Json]? {
match obj {
Object(m) =>
match m.get(key) {
Some(Array(a)) => Some(a)
_ => None
}
_ => None
}
}
///|
/// Read a string-or-number field as a string id (platform payloads often mix
/// the two, e.g. Telegram `chat.id`).
pub fn json_id(obj : Json, key : String) -> String? {
match obj {
Object(m) =>
match m.get(key) {
Some(String(s)) => Some(s)
Some(Number(n, ..)) =>
if n == n.to_int().to_double() {
Some(n.to_int().to_string())
} else {
Some(n.to_string())
}
_ => None
}
_ => None
}
}
///|
/// Split ``text`` into chunks no longer than ``max_len`` characters, breaking
/// on paragraph (blank-line) boundaries first, then on character boundaries.
/// Returns at least one chunk even when the input is empty.
pub fn chunk_text(text : String, max_len : Int32) -> Array[String] {
if max_len <= 0 {
return [text]
}
if text.length() <= max_len {
return [text]
}
let out : Array[String] = []
for para in text.split("\n\n") {
let p = para.to_owned()
if p.is_empty() {
continue
}
if p.length() <= max_len {
out.push(p)
continue
}
let sb = StringBuilder()
let mut n = 0
for ch in p {
if n >= max_len {
out.push(sb.to_string())
sb.reset()
n = 0
}
sb.write_char(ch)
n = n + 1
}
if n > 0 {
out.push(sb.to_string())
}
}
if out.is_empty() {
out.push("")
}
out
}
///|
/// Truncate ``text`` to at most ``max`` characters, appending ``suffix`` when
/// it was cut (Hermes `_truncate_preview`).
pub fn truncate(text : String, max : Int32, suffix~ : String = "...") -> String {
if text.length() <= max {
text
} else {
"\{text[:max].to_owned()}\{suffix}"
}
}
///|
/// Strip common markdown to a platform-safe plain-text rendering. Used by
/// text-only adapters (DingTalk fallback, email, WeCom, WhatsApp body, ...).
pub fn strip_markdown(text : String) -> String {
let s = text
.replace_all(old="```", new="")
.replace_all(old="**", new="")
.replace_all(old="__", new="")
.replace_all(old="`", new="")
.replace_all(old="*", new="")
.replace_all(old="#", new="")
s
}
///|
/// Hermes' `SEND_ERROR_KINDS` classifier: map an error string onto a
/// platform-neutral kind. Substring table mirrors `gateway/platforms/base.py`
/// (`_SEND_ERROR_CLASSIFIERS`) so classified failures read identically.
pub fn classify_send_error(msg : String) -> SendErrorKind {
let blob = msg.to_lower()
if any_substring(blob, ["message_too_long", "too long", "message is too long"]) {
return TooLong
}
if any_substring(blob, [
"can't parse entities", "cant parse entities", "can't find end", "unsupported start tag",
]) ||
(blob.contains("entity") && blob.contains("parse")) ||
(blob.contains("bad request") && blob.contains("entit")) {
return BadFormat
}
if any_substring(blob, [
"forbidden", "bot was blocked", "blocked by the user", "user is deactivated",
"not enough rights", "have no rights", "not a member",
]) {
return Forbidden
}
if any_substring(blob, chat_level_not_found_needles()) ||
any_substring(blob, subchat_not_found_needles()) {
return NotFound
}
if any_substring(blob, [
"flood", "too many requests", "retry after", "rate limit",
]) {
return RateLimited
}
if any_substring(blob, [
"connecterror", "connectionerror", "connectionreset", "connectionrefused",
"connecttimeout", "network", "broken pipe", "remotedisconnected", "eoferror",
]) ||
blob.contains("connecttimeout") {
return Transient
}
Unknown
}
///|
/// `true` when at least one of ``needles`` occurs in ``blob``.
fn any_substring(blob : String, needles : Array[String]) -> Bool {
for n in needles {
if blob.contains(n) {
return true
}
}
false
}
///|
/// `not_found` markers that mean the whole chat is gone.
fn chat_level_not_found_needles() -> Array[String] {
["chat not found"]
}
///|
/// `not_found` markers that leave the parent chat reachable.
fn subchat_not_found_needles() -> Array[String] {
[
"message to edit not found", "message to reply not found", "thread not found",
"topic_deleted", "message_id_invalid",
]
}
///|
/// Hermes' `is_chat_level_not_found`: whether a `not_found` failure means the
/// *whole chat* is gone (only that marks a target dead; a deleted topic or an
/// edited-away message leaves the parent chat reachable). When both kinds of
/// marker are present the sub-chat reading wins.
pub fn is_chat_level_not_found(msg : String) -> Bool {
let blob = msg.to_lower()
!any_substring(blob, subchat_not_found_needles()) &&
any_substring(blob, chat_level_not_found_needles())
}
///|
/// Random lowercase-hex string of ``len`` digits (transaction ids, uuids).
pub fn random_hex(len : Int32) -> String {
let hexchars = "0123456789abcdef"
let rand = @random.Rand()
let sb = StringBuilder()
for _ in 0.. String {
@percent.encode(s)
}
///|
/// Current epoch time in milliseconds.
pub fn now_ms() -> Int64 {
@env.now().reinterpret_as_int64()
}
///|
/// First present of two optional strings (`a` when it is `Some`).
pub fn or_opt(a : String?, b : String?) -> String? {
match a {
Some(_) => a
None => b
}
}
///|
/// Byte from an int (0..255) — MoonBit has no `Byte::from_int` and int-literal
/// method chains parse as floats.
pub fn byte_of_int(n : Int32) -> Byte {
n.to_int64().to_byte()
}
///|
/// Lowercase hex encoding of raw bytes.
pub fn hex_encode(data : BytesView) -> String {
let hex = "0123456789abcdef"
let sb = StringBuilder()
for i in 0..> 4
let b = v & 15
sb.write_string(hex[a:a + 1].to_owned())
sb.write_string(hex[b:b + 1].to_owned())
}
sb.to_string()
}
///|
/// Decode a lowercase/uppercase hex string into bytes; `None` on malformed
/// input (odd length or non-hex digit).
pub fn hex_decode(s : String) -> Bytes? {
if s.length() % 2 != 0 {
return None
}
let out : Array[Byte] = []
let mut i = 0
while i < s.length() {
let hi = Some(@string.parse_int(s[i:i + 1].to_owned(), base=16)) catch {
_ => None
}
let lo = Some(@string.parse_int(s[i + 1:i + 2].to_owned(), base=16)) catch {
_ => None
}
match (hi, lo) {
(Some(h), Some(l)) => out.push(byte_of_int(h * 16 + l))
_ => return None
}
i += 2
}
Some(Bytes::from_array(out[:]))
}
///|
/// Cryptographically-ish random bytes via `random_hex` (transaction ids).
pub fn random_bytes(len : Int32) -> Bytes {
match hex_decode(random_hex(len * 2)) {
Some(b) => b
None => Bytes::new(len)
}
}
///|
/// Lowercase hex MD5 digest (WeChat/WeCom media fingerprints).
pub fn md5_hex(data : BytesView) -> String {
hex_encode(Bytes::from_array(@crypto.md5(data)[:]))
}
///|
/// Lowercase hex HMAC-SHA256 of ``data`` under ``secret`` (Slack/WhatsApp
/// webhook signatures).
pub fn hmac_sha256_hex(secret : String, data : String) -> String {
let mac = @crypto.hmac(
@crypto.SHA256::new(),
@utf8.encode(secret)[:],
@utf8.encode(data)[:],
)
hex_lower(mac[:])
}
///|
/// Write one log line to stderr, flushed immediately. Logs must not share
/// stdout with protocol output (the `moon test` driver parses test results
/// from stdout), and stderr is unbuffered even when stdout is a pipe.
pub async fn log_line(line : String) -> Unit {
@stdio.stderr.write("\{line}\n") catch {
_ => ()
}
}
///|
/// Constant-time string comparison (no early exit; length differences are
/// absorbed into the accumulated difference).
pub fn constant_time_eq(a : String, b : String) -> Bool {
let n = if a.length() > b.length() { a.length() } else { b.length() }
let mut diff = if a.length() == b.length() { 0 } else { 1 }
for i in 0.. String {
"data:\{mime};base64,\{@base64.encode(data)}"
}