// Gateway runtime: webhook server, long-pollers and the event loop.
//
// Mirrors Hermes' gateway loop: inbound events from every configured platform
// are normalized, folded into a per-chat `Session`, answered by the agent
// callback, and delivered back through the platform adapter (chunked, with
// typing indicators and silence-token suppression).

///|
/// The agent callback. Receives the inbound event and returns the reply text;
/// an empty string (or a silence token) suppresses delivery.
pub(all) struct Gateway {
  cfg : GatewayConfig
  store : SessionStore
  handler : async (MessageEvent, Session) -> String
} derive(Debug)

///|
/// Build a gateway over ``cfg`` with ``handler`` as the agent.
pub fn Gateway::new(
  cfg : GatewayConfig,
  handler : async (MessageEvent, Session) -> String,
  max_history~ : Int32 = 24,
) -> Gateway {
  { cfg, store: SessionStore::new(max_history~), handler, }
}

///|
/// Whether a sender may talk to the agent. `allow_all_users` admits everyone;
/// otherwise the user id (falling back to the chat id when the platform has no
/// per-user id) must appear in the platform or gateway allowlist โ€” or match
/// the `*` wildcard. With no allowlist configured and no opt-in the gate is
/// closed (Hermes' `GATEWAY_ALLOW_ALL_USERS` posture).
pub fn user_allowed(
  cfg : GatewayConfig,
  pcfg : PlatformConfig,
  user_id : String?,
  chat_id : String,
) -> Bool {
  if cfg.allow_all_users {
    return true
  }
  if pcfg.allowed_users.length() == 0 && cfg.allowed_users.length() == 0 {
    return false
  }
  let who = match user_id {
    Some(u) => if u.is_empty() { chat_id } else { u }
    None => chat_id
  }
  for entry in pcfg.allowed_users {
    if entry == "*" || entry == who {
      return true
    }
  }
  for entry in cfg.allowed_users {
    if entry == "*" || entry == who {
      return true
    }
  }
  false
}

///|
/// Handle one inbound event: record the turn, answer slash commands locally,
/// otherwise call the agent and deliver the reply.
pub async fn Gateway::handle_event(self : Gateway, ev : MessageEvent) -> Unit {
  match self.cfg.get(ev.platform) {
    None => ()
    Some(pcfg) => {
      if !pcfg.enabled {
        return
      }
      if !user_allowed(self.cfg, pcfg, ev.user_id, ev.chat_id) {
        log_line(
          "[imrelay] ignored \{ev.platform.value()} message from unauthorized sender \{ev.user_id.unwrap_or(ev.chat_id)} (chat \{ev.chat_id}); configure allowed_users or allow_all_users",
        )
        return
      }
      let session = self.store.session(ev.platform, ev.chat_id)
      match ev.message_id {
        Some(id) => session.set_last_message_id(id)
        None => ()
      }
      match ev.reply_to_message_id {
        Some(t) => session.set_thread_id(t)
        None => ()
      }
      match parse_command(ev.text) {
        Some(cmd) =>
          match command_reply(cmd, self.cfg, session) {
            Some(reply) => deliver_reply(pcfg, session, ev, reply)
            None => {
              session.push("user", ev.text)
              self.answer(pcfg, session, ev)
            }
          }
        None => {
          session.push("user", ev.text)
          self.answer(pcfg, session, ev)
        }
      }
    }
  }
}

///|
/// Invoke the agent with a typing indicator, then deliver the reply.
async fn Gateway::answer(
  self : Gateway,
  pcfg : PlatformConfig,
  session : Session,
  ev : MessageEvent,
) -> Unit {
  send_typing(pcfg, ev.chat_id, thread_id?=session.thread_id())
  let reply = (self.handler)(ev, session)
  session.push("assistant", reply)
  deliver_reply(pcfg, session, ev, reply)
}

///|
/// Deliver a reply, honouring silence tokens and recording the new message id.
async fn deliver_reply(
  pcfg : PlatformConfig,
  session : Session,
  ev : MessageEvent,
  reply : String,
) -> Unit {
  if is_silent_reply(reply) {
    return
  }
  let r = send_message(
    pcfg,
    ev.chat_id,
    reply,
    thread_id?=session.thread_id(),
    context_token?=session.context_token(),
  )
  match r.message_id {
    Some(id) => session.set_last_message_id(id)
    None => ()
  }
  if !r.success {
    let err = r.error.unwrap_or("unknown")
    log_line(
      "[imrelay] send to \{ev.platform.value()}:\{ev.chat_id} failed: \{err}",
    )
  }
}

///|
/// Start the gateway: an HTTP webhook server plus a long-poll loop for every
/// platform configured for polling. Runs until the task group is cancelled.
pub async fn Gateway::run(self : Gateway) -> Unit {
  let addr = @socket.Addr::parse("\{self.cfg.host}:\{self.cfg.port}") catch {
    e => {
      log_line(
        "[imrelay] invalid listen address \{self.cfg.host}:\{self.cfg.port}: \{e}",
      )
      return
    }
  }
  let server = @http.Server(addr, reuse_addr=true)
  log_line(
    "[imrelay] gateway listening on \{self.cfg.host}:\{self.cfg.port} (platforms: \{self.cfg.enabled_platforms().length()})",
  )
  if !self.cfg.allow_all_users && !self.cfg.has_any_allowed_users() {
    log_line(
      "[imrelay] warning: no allowed_users configured and allow_all_users is false โ€” inbound messages will be ignored (set [gateway] allowed_users, per-platform allowed_users, or allow_all_users = true)",
    )
  }
  @async.with_task_group(group => {
    for platform in self.cfg.enabled_platforms() {
      match self.cfg.get(platform) {
        Some(pcfg) => {
          if platform == Telegram {
            match pcfg.token_of() {
              Some(token) =>
                if pcfg.extra_bool("polling", true) {
                  let gw = self
                  group.spawn_bg(no_wait=true, () => {
                    poll_telegram(gw, pcfg, token)
                  })
                }
              None => ()
            }
          }
          if platform == Weixin {
            match pcfg.token_of() {
              Some(token) =>
                if pcfg.extra_bool("polling", true) {
                  let gw = self
                  group.spawn_bg(no_wait=true, () => {
                    poll_weixin(gw, pcfg, token)
                  })
                }
              None => ()
            }
          }
          if platform == Dingtalk {
            // Stream Mode: no public callback URL needed. Needs the app
            // key/secret; `[dingtalk.extra] stream = false` opts out.
            if dingtalk_stream_enabled(pcfg) {
              let gw = self
              group.spawn_bg(no_wait=true, () => poll_dingtalk_stream(gw, pcfg))
            }
          }
          if platform == Matrix {
            if pcfg.extra_bool("polling", true) &&
              !matrix_homeserver(pcfg).is_empty() {
              let gw = self
              group.spawn_bg(no_wait=true, () => {
                match matrix_resolve_token(pcfg) {
                  Some(token) => {
                    let self_user = matrix_self_user(pcfg, token)
                    if self_user.is_empty() {
                      log_line(
                        "[imrelay] matrix: could not resolve the bot user id; own messages may echo back (set [matrix.extra] user_id)",
                      )
                    }
                    poll_matrix(gw, pcfg, token, self_user)
                  }
                  None =>
                    log_line(
                      "[imrelay] matrix: no access token (set token / MATRIX_ACCESS_TOKEN, or user_id + password)",
                    )
                }
              })
            }
          }
        }
        None => ()
      }
    }
    server.run_forever((request, body, conn) => {
      self.handle_webhook(request, body, conn)
    })
  })
}

///|
/// Route one inbound webhook request.
async fn Gateway::handle_webhook(
  self : Gateway,
  request : @http.Request,
  body : &@io.Reader,
  conn : @http.ServerConnection,
) -> Unit {
  let route = match request.path.split_once("?") {
    Some((p, _)) => p.to_owned()
    None => request.path
  }
  let seg = if route.has_prefix("/") { route[1:].to_owned() } else { route }
  let raw = body.read_all().text() catch { _ => "" }
  if seg.is_empty() {
    respond_html(conn, 200, self.landing_html())
    return
  }
  if seg == "health" {
    respond_text(
      conn,
      200,
      "OK",
      "{\"ok\":true,\"platforms\":\{self.cfg.enabled_platforms().length()}}",
    )
    return
  }
  match Platform::of_value(seg) {
    None =>
      respond_text(
        conn,
        404,
        "Not Found",
        "{\"error\":\"unknown platform\",\"path\":\"\{seg}\"}",
      )
    Some(platform) => {
      let pcfg = match self.cfg.get(platform) {
        Some(p) => p
        None => {
          respond_text(
            conn,
            404,
            "Not Found",
            "{\"error\":\"platform not configured\",\"platform\":\"\{platform.value()}\"}",
          )
          return
        }
      }
      if !pcfg.enabled {
        respond_text(
          conn,
          404,
          "Not Found",
          "{\"error\":\"platform disabled\",\"platform\":\"\{platform.value()}\"}",
        )
        return
      }
      if platform == Whatsapp {
        // The subscription handshake (`hub.*` GET) authenticates with
        // `verify_token`, not with a body signature.
        match whatsapp_hub_params(request.path) {
          Some(_) => {
            ignore(self.handle_verification(platform, request, raw, conn))
            return
          }
          None => ()
        }
      }
      if platform == Wecom {
        // The URL-verification handshake carries `msg_signature` in its query
        // and must answer the decrypted `echostr`.
        match wecom_callback_params(request.path).echostr {
          Some(_) => {
            ignore(self.handle_verification(platform, request, raw, conn))
            return
          }
          None => ()
        }
      }
      let (status, reason) = webhook_auth_status(platform, pcfg, request, raw)
      if status != 0 {
        respond_text(
          conn,
          status,
          http_reason(status),
          "{\"error\":\"webhook rejected\",\"reason\":\"\{reason}\"}",
        )
        return
      }
      let body = match platform {
        Wecom =>
          match wecom_unwrap_callback(pcfg, raw) {
            Ok(b) => b
            Err(reason) => {
              respond_text(
                conn,
                400,
                "Bad Request",
                "{\"error\":\"wecom callback rejected\",\"reason\":\"\{reason}\"}",
              )
              return
            }
          }
        Feishu =>
          match feishu_unwrap_callback(pcfg, raw) {
            Ok(b) => b
            Err(reason) => {
              respond_text(
                conn,
                400,
                "Bad Request",
                "{\"error\":\"feishu callback rejected\",\"reason\":\"\{reason}\"}",
              )
              return
            }
          }
        _ => raw
      }
      if platform == Feishu {
        // The verification token lives inside the (possibly encrypted) body,
        // so it is checked after decryption. Fails closed while configured.
        let (code, reason) = feishu_token_status(pcfg, body)
        if code != 0 {
          respond_text(
            conn,
            code,
            http_reason(code),
            "{\"error\":\"feishu callback rejected\",\"reason\":\"\{reason}\"}",
          )
          return
        }
      }
      let handled = self.handle_verification(platform, request, body, conn)
      if handled {
        return
      }
      let events = if platform == Dingtalk {
        // Robot callbacks may carry media; resolve their download codes first.
        dingtalk_parse_callback_with_media(pcfg, body)
      } else {
        parse_inbound(platform, body, cfg=pcfg)
      }
      if platform == Wecom {
        // WeCom expects the literal `success` text (or an empty body).
        respond_text(conn, 200, "OK", "success")
      } else {
        respond_text(
          conn,
          200,
          "OK",
          "{\"ok\":true,\"accepted\":\{events.length()}}",
        )
      }
      for ev in events {
        self.handle_event(ev)
      }
    }
  }
}

///|
/// Authenticate one inbound webhook request: `(0, "")` when the request may be
/// processed, or an HTTP status and reason when it must be rejected. Webhook
/// payloads that carry a platform signature (`slack`, `whatsapp`, `telegram`)
/// fail closed while no secret is configured; platforms with no signature
/// scheme only check once a shared secret is configured. WeCom checks
/// `msg_signature` once callback crypto (`encoding_aes_key`) is configured,
/// Feishu checks `X-Lark-Signature` once `encrypt_key` is configured.
pub fn webhook_auth_status(
  platform : Platform,
  pcfg : PlatformConfig,
  request : @http.Request,
  raw : String,
) -> (Int32, String) {
  let query = match request.path.split_once("?") {
    Some((_, q)) => q.to_owned()
    None => ""
  }
  match platform {
    Telegram => {
      let secret = pcfg.extra_str("webhook_secret", "")
      if secret.is_empty() {
        (
          503, "telegram webhook_secret not configured (set [telegram.extra] webhook_secret / TELEGRAM_WEBHOOK_SECRET)",
        )
      } else if telegram_webhook_authorized(
          secret,
          header_of(request, "X-Telegram-Bot-Api-Secret-Token"),
        ) {
        (0, "")
      } else {
        (401, "invalid X-Telegram-Bot-Api-Secret-Token")
      }
    }
    Slack => {
      let secret = pcfg.extra_str("signing_secret", "")
      if secret.is_empty() {
        (
          503, "slack signing_secret not configured (set [slack.extra] signing_secret / SLACK_SIGNING_SECRET)",
        )
      } else if slack_request_verified(
          secret,
          header_of(request, "X-Slack-Request-Timestamp").unwrap_or(""),
          raw,
          header_of(request, "X-Slack-Signature").unwrap_or(""),
          now_ms() / 1000L,
        ) {
        (0, "")
      } else {
        (401, "invalid X-Slack-Signature")
      }
    }
    Whatsapp => {
      let secret = pcfg.extra_str("app_secret", "")
      if secret.is_empty() {
        (
          503, "whatsapp app_secret not configured (set [whatsapp.extra] app_secret / WHATSAPP_CLOUD_APP_SECRET)",
        )
      } else if whatsapp_verify_signature(
          secret,
          raw,
          header_of(request, "X-Hub-Signature-256").unwrap_or(""),
        ) {
        (0, "")
      } else {
        (401, "invalid X-Hub-Signature-256")
      }
    }
    Bluebubbles => {
      let password = pcfg.extra_str("password", "")
      if password.is_empty() {
        (0, "")
      } else {
        match
          bluebubbles_webhook_token(query, header_of(request, "x-password")) {
          Some(t) =>
            if constant_time_eq(password, t) {
              (0, "")
            } else {
              (401, "invalid BlueBubbles webhook password")
            }
          None => (401, "missing BlueBubbles webhook password")
        }
      }
    }
    Mattermost => {
      let expected = pcfg.extra_str("webhook_token", "")
      if expected.is_empty() {
        (0, "")
      } else {
        match mattermost_webhook_token(raw) {
          Some(t) =>
            if constant_time_eq(expected, t) {
              (0, "")
            } else {
              (401, "invalid Mattermost webhook token")
            }
          None => (401, "missing Mattermost webhook token")
        }
      }
    }
    Wecom => {
      // Callback crypto is opt-in: when `encoding_aes_key` is set, every
      // request must carry a valid `msg_signature` over its ``;
      // otherwise bodies pass through (relay mode).
      let aes_key = wecom_encoding_aes_key(pcfg)
      if aes_key.is_empty() {
        (0, "")
      } else {
        let token = wecom_callback_token(pcfg)
        if token.is_empty() {
          (
            503, "wecom callback_token not configured (set [wecom.extra] callback_token / WECOM_CALLBACK_TOKEN)",
          )
        } else {
          let params = wecom_callback_params(request.path)
          match xml_text(raw, "Encrypt") {
            None => (400, "missing  in wecom callback body")
            Some(encrypt) =>
              if wecom_verify_msg_signature(
                  token,
                  params.timestamp,
                  params.nonce,
                  encrypt,
                  params.msg_signature,
                ) {
                (0, "")
              } else {
                (401, "invalid WeCom msg_signature")
              }
          }
        }
      }
    }
    Feishu => {
      // With `encrypt_key` configured every callback must carry a valid
      // `X-Lark-Signature` = `sha256(timestamp + nonce + encrypt_key + body)`
      // (raw body, before decryption); the verification token is checked
      // later against the decrypted body. Without the key the body is
      // plaintext and `feishu_token_status` alone gates it.
      let encrypt_key = feishu_encrypt_key(pcfg)
      if encrypt_key.is_empty() {
        (0, "")
      } else {
        let signature = header_of(request, "X-Lark-Signature").unwrap_or("")
        if signature.is_empty() {
          (401, "missing Feishu X-Lark-Signature")
        } else if feishu_verify_signature(
            encrypt_key,
            header_of(request, "X-Lark-Request-Timestamp").unwrap_or(""),
            header_of(request, "X-Lark-Request-Nonce").unwrap_or(""),
            raw,
            signature,
          ) {
          (0, "")
        } else {
          (401, "invalid Feishu X-Lark-Signature")
        }
      }
    }
    _ => (0, "")
  }
}

///|
/// Case-insensitive request-header lookup.
fn header_of(request : @http.Request, name : String) -> String? {
  request.headers.get(@http.CaseInsensitiveString(name))
}

///|
/// Reason phrase for the webhook-rejection status codes.
fn http_reason(code : Int32) -> String {
  match code {
    401 => "Unauthorized"
    403 => "Forbidden"
    503 => "Service Unavailable"
    _ => "Error"
  }
}

///|
/// Answer platform verification handshakes (Slack/Feishu challenge, WhatsApp
/// `hub.challenge`, WeCom `echostr`). Returns `true` when the request was
/// fully handled.
async fn Gateway::handle_verification(
  self : Gateway,
  platform : Platform,
  request : @http.Request,
  raw : String,
  conn : @http.ServerConnection,
) -> Bool {
  match platform {
    Slack =>
      match slack_challenge(raw) {
        Some(challenge) => {
          respond_text(conn, 200, "OK", challenge)
          true
        }
        None => false
      }
    Feishu =>
      match feishu_challenge(raw) {
        Some(challenge) => {
          respond_text(conn, 200, "OK", "{\"challenge\":\"\{challenge}\"}")
          true
        }
        None => false
      }
    Whatsapp =>
      match whatsapp_hub_params(request.path) {
        Some(hub) => {
          let expected = match self.cfg.get(Whatsapp) {
            Some(p) => p.extra_str("verify_token", "")
            None => ""
          }
          match whatsapp_verify(hub.mode, hub.token, hub.challenge, expected) {
            Some(ch) => {
              respond_text(conn, 200, "OK", ch)
              true
            }
            None => {
              respond_text(conn, 403, "Forbidden", "verification failed")
              true
            }
          }
        }
        None => false
      }
    Wecom =>
      match self.cfg.get(Wecom) {
        None => false
        Some(pcfg) => {
          let params = wecom_callback_params(request.path)
          match params.echostr {
            None => false
            Some(echostr) => {
              let token = wecom_callback_token(pcfg)
              let aes_key = wecom_encoding_aes_key(pcfg)
              if token.is_empty() || aes_key.is_empty() {
                respond_text(
                  conn, 403, "Forbidden", "wecom callback crypto not configured (set callback_token / encoding_aes_key)",
                )
              } else {
                match
                  wecom_decrypt_message(
                    token,
                    aes_key,
                    wecom_corp_id(pcfg),
                    params.msg_signature,
                    params.timestamp,
                    params.nonce,
                    echostr,
                  ) {
                  Some(plain) => respond_text(conn, 200, "OK", plain)
                  None =>
                    respond_text(
                      conn, 403, "Forbidden", "wecom URL verification failed (msg_signature mismatch)",
                    )
                }
              }
              true
            }
          }
        }
      }
    _ => false
  }
}

///|
/// WhatsApp webhook verification parameters (`hub.*` query keys).
pub(all) struct HubParams {
  mode : String
  token : String
  challenge : String
} derive(Debug)

///|
/// Extract `hub.mode` / `hub.verify_token` / `hub.challenge` from a path.
pub fn whatsapp_hub_params(path : String) -> HubParams? {
  let query = match path.split_once("?") {
    Some((_, q)) => q.to_owned()
    None => return None
  }
  let mode = query_param(query, "hub.mode").unwrap_or("")
  if mode.is_empty() {
    return None
  }
  Some({
    mode,
    token: query_param(query, "hub.verify_token").unwrap_or(""),
    challenge: query_param(query, "hub.challenge").unwrap_or(""),
  })
}

///|
/// Read a `name=value` pair from a URL query string (percent-decoded).
pub fn query_param(query : String, name : String) -> String? {
  for pair in query.split("&") {
    match pair.split_once("=") {
      Some((k, v)) =>
        if k.to_owned() == name {
          return Some(percent_decode_value(v))
        }
      None => ()
    }
  }
  None
}

///|
/// Percent-decode one URL query component (raw text when not valid UTF-8).
fn percent_decode_value(v : StringView) -> String {
  let bytes = @percent.decode(v)
  @utf8.decode(bytes[:]) catch {
    _ => v.to_owned()
  }
}

///|
/// Long-poll Telegram `getUpdates` and feed events into the gateway.
async fn poll_telegram(
  gw : Gateway,
  pcfg : PlatformConfig,
  token : String,
) -> Unit {
  ignore(telegram_delete_webhook(pcfg, token))
  let mut offset = 0
  while true {
    let started = now_ms()
    match telegram_get_updates_result(pcfg, token, offset, 25) {
      Ok(updates) => {
        for u in updates {
          offset = json_field_int(u, "update_id").unwrap_or(offset) + 1
          for ev in telegram_parse_update(u) {
            gw.handle_event(ev)
          }
        }
        if updates.length() == 0 && now_ms() - started < 1000L {
          @async.sleep(1000)
        }
      }
      Err(e) => {
        log_line(
          "[imrelay] telegram getUpdates failed: \{api_error_message(e)}",
        )
        @async.sleep(2000)
      }
    }
  }
}

///|
/// Long-poll the WeChat iLink `getupdates` endpoint.
async fn poll_weixin(
  gw : Gateway,
  pcfg : PlatformConfig,
  token : String,
) -> Unit {
  let mut buf = ""
  while true {
    let started = now_ms()
    match weixin_get_updates_result(pcfg, token, buf) {
      Ok((msgs, new_buf)) => {
        buf = new_buf
        for m in msgs {
          match weixin_parse_message(m) {
            Some(ev) => {
              let session = gw.store.session(Weixin, ev.chat_id)
              match json_field_string(m, "context_token") {
                Some(t) => session.set_context_token(t)
                None => ()
              }
              // Download + decrypt any iLink media so the agent sees attachments.
              let ev = weixin_with_media(ev, weixin_media_attachments(pcfg, m))
              gw.handle_event(ev)
            }
            None => ()
          }
        }
        if msgs.length() == 0 && now_ms() - started < 1000L {
          @async.sleep(1000)
        }
      }
      Err(e) => {
        log_line("[imrelay] weixin getupdates failed: \{api_error_message(e)}")
        @async.sleep(2000)
      }
    }
  }
}

///|
/// Long-poll Matrix `/sync` and feed events into the gateway. The bot's own
/// events are filtered out via ``self_user`` (Prevents reply echo loops.)
async fn poll_matrix(
  gw : Gateway,
  pcfg : PlatformConfig,
  token : String,
  self_user : String,
) -> Unit {
  // Prime `next_batch` without replaying history: the first sync's timeline is
  // consumed for the token only (Hermes skips the initial sync the same way).
  let mut since = ""
  match matrix_sync(pcfg, token, "", 0) {
    Ok(j) =>
      match json_field_string(j, "next_batch") {
        Some(b) => if !b.is_empty() { since = b }
        None => ()
      }
    Err(e) =>
      log_line("[imrelay] matrix initial sync failed: \{api_error_message(e)}")
  }
  while true {
    let started = now_ms()
    match matrix_sync(pcfg, token, since, 30000) {
      Ok(j) => {
        match json_field_string(j, "next_batch") {
          Some(b) => if !b.is_empty() { since = b }
          None => ()
        }
        for ev in matrix_parse_sync(j.stringify(), self_user~) {
          gw.handle_event(ev)
        }
        if now_ms() - started < 1000L {
          @async.sleep(1000)
        }
      }
      Err(e) => {
        log_line("[imrelay] matrix sync failed: \{api_error_message(e)}")
        @async.sleep(2000)
      }
    }
  }
}

///|
/// DingTalk Stream Mode: open a WebSocket long-connection, ack `CALLBACK`
/// frames, echo `SYSTEM` pings and reconnect with exponential backoff.
async fn poll_dingtalk_stream(gw : Gateway, pcfg : PlatformConfig) -> Unit {
  let mut delay : Int64 = 1000L
  let backoff = (d : Int64) => if d >= 60000L { 60000L } else { d * 2L }
  while true {
    match dingtalk_stream_open(pcfg) {
      Err(e) => {
        log_line(
          "[imrelay] dingtalk: stream open failed (\{api_error_message(e)}); retrying in \{delay} ms",
        )
        @async.sleep(delay.to_int())
        delay = backoff(delay)
      }
      Ok((endpoint, ticket)) => {
        let url = "\{endpoint}?ticket=\{url_quote(ticket)}"
        let opened = Some(@websocket.connect(url)) catch {
          e => {
            log_line(
              "[imrelay] dingtalk: stream connect failed: \{e}; retrying in \{delay} ms",
            )
            None
          }
        }
        match opened {
          None => {
            @async.sleep(delay.to_int())
            delay = backoff(delay)
          }
          Some(conn) => {
            log_line("[imrelay] dingtalk: stream connected")
            delay = 1000L
            try {
              for ;; {
                let message = conn.recv()
                let raw = message.read_all().text()
                match dingtalk_stream_frame(raw) {
                  None =>
                    log_line(
                      "[imrelay] dingtalk: ignoring a malformed stream frame",
                    )
                  Some(frame) =>
                    if frame.kind == "SYSTEM" {
                      if frame.topic == "ping" || frame.topic == "disconnect" {
                        conn.send_text(
                          dingtalk_stream_echo_reply(frame.headers, frame.data).stringify(),
                        )
                        if frame.topic == "disconnect" {
                          break
                        }
                      }
                    } else if frame.kind == "CALLBACK" {
                      // Ack first (the server retries after 60 s), then
                      // dispatch; redeliveries are dropped by frame id.
                      if !frame.message_id.is_empty() {
                        conn.send_text(
                          dingtalk_stream_callback_ack(frame.message_id).stringify(),
                        )
                      }
                      if !dingtalk_stream_duplicate(frame.message_id) {
                        let data = match frame.data {
                          String(s) => s
                          other => other.stringify()
                        }
                        for ev in dingtalk_parse_callback_with_media(pcfg, data) {
                          gw.handle_event(ev) catch {
                            e =>
                              log_line(
                                "[imrelay] dingtalk: handling a stream message failed: \{e}",
                              )
                          }
                        }
                      }
                    } else if frame.kind != "" && !frame.message_id.is_empty() {
                      // Unknown callback/event type: ack so it is not retried.
                      conn.send_text(
                        dingtalk_stream_callback_ack(frame.message_id).stringify(),
                      )
                    }
                }
              }
            } catch {
              e => log_line("[imrelay] dingtalk: stream closed: \{e}")
            }
            conn.close()
            @async.sleep(1000)
          }
        }
      }
    }
  }
}

///|
/// Minimal landing page describing the running gateway.
fn Gateway::landing_html(self : Gateway) -> String {
  let sb = StringBuilder()
  sb.write_string(
    "imrelay gateway",
  )
  sb.write_string(
    "",
  )
  sb.write_string("

imrelay

") sb.write_string( "

A MoonBit chat gateway for Telegram, Discord, Slack, WhatsApp, Signal, Email, Home Assistant, Mattermost, Matrix, DingTalk, Feishu/Lark, WeChat, WeCom and BlueBubbles.

", ) sb.write_string("

Webhook endpoints

    ") for p in self.cfg.enabled_platforms() { sb.write_string("
  • POST /\{p.value()} โ€” \{p.name()}
  • ") } sb.write_string("
") sb.write_string( "

Health: /health ยท Sessions live in memory, keyed by platform:chat_id.

", ) sb.write_string("") sb.to_string() } ///| /// Send an HTML HTTP response. async fn respond_html( conn : @http.ServerConnection, code : Int32, html : String, ) -> Unit { conn.send_response(code, "OK", extra_headers={ "Content-Type": "text/html; charset=utf-8", }) conn.write_string(html) conn.end_response() } ///| /// Send a plain-text HTTP response. async fn respond_text( conn : @http.ServerConnection, code : Int32, reason : String, text : String, ) -> Unit { conn.send_response(code, reason, extra_headers={ "Content-Type": "text/plain; charset=utf-8", }) conn.write_string(text) conn.end_response() }