// DingTalk adapter.
//
// Mirrors Hermes `plugins/platforms/dingtalk/adapter.py`. Two transports are
// supported: a group robot webhook (`oapi.dingtalk.com/robot/send`) and the
// newer app-robot API (`api.dingtalk.com/v1.0/robot/…`) addressed by
// `robotCode`. Inbound traffic arrives as an outgoing robot callback carrying
// a `sessionWebhook` that can be used for replies.
///|
/// Robot webhook access token (`DINGTALK_ACCESS_TOKEN` / `[dingtalk.extra]`).
pub fn dingtalk_robot_token(cfg : PlatformConfig) -> String? {
match cfg.extra_first(["access_token", "robot_token"]) {
Some(t) => Some(t)
None => cfg.token_of()
}
}
///|
/// App credentials (`DINGTALK_CLIENT_ID` + `DINGTALK_CLIENT_SECRET`).
pub fn dingtalk_app_key(cfg : PlatformConfig) -> String? {
cfg.extra_first(["client_id", "app_key"])
}
///|
/// App secret, also accepted as `api_key`.
pub fn dingtalk_app_secret(cfg : PlatformConfig) -> String? {
match cfg.extra_first(["client_secret", "app_secret"]) {
Some(s) => Some(s)
None => cfg.api_key_of()
}
}
///|
/// `robot/send` payload (robot webhook uses markdown, app uses text).
pub fn dingtalk_send_payload(
content : String,
title : String,
use_markdown : Bool,
) -> Json {
if use_markdown {
json_obj([
("msgtype", Json::string("markdown")),
(
"markdown",
json_obj([
("title", Json::string(title)),
("text", Json::string(content)),
]),
),
])
} else {
json_obj([
("msgtype", Json::string("text")),
("text", json_obj([("content", Json::string(content))])),
])
}
}
///|
/// Cached app access tokens keyed by app key.
let dingtalk_token_cache : Map[String, String] = Map([])
///|
/// Fetch (and cache) an app-robot access token.
pub async fn dingtalk_access_token(cfg : PlatformConfig) -> String? {
let key = match dingtalk_app_key(cfg) {
Some(k) => k
None => return None
}
let secret = match dingtalk_app_secret(cfg) {
Some(s) => s
None => return None
}
match dingtalk_token_cache.get(key) {
Some(t) => return Some(t)
None => ()
}
let api_base = cfg.extra_str("api_base", "https://api.dingtalk.com")
let url = "\{api_base}/v1.0/oauth2/accessToken"
let body = json_obj([
("appKey", Json::string(key)),
("appSecret", Json::string(secret)),
])
match post_json(url, body, json_headers()) {
Err(_) => None
Ok(j) =>
match json_field_string(j, "accessToken") {
Some(t) => {
dingtalk_token_cache.set(key, t)
Some(t)
}
None => None
}
}
}
///|
/// Map a DingTalk `{errcode, errmsg}` response onto a `SendResult`.
fn dingtalk_robot_result(j : Json) -> SendResult {
match json_field_int(j, "errcode") {
Some(0) => send_ok(None, j)
Some(code) => {
let msg = json_field_string(j, "errmsg").unwrap_or(j.stringify())
SendResult::failed(
"dingtalk: \{msg} (code \{code})",
kind=classify_send_error(msg),
raw=j.stringify(),
)
}
None => send_ok(None, j)
}
}
///|
/// Send ``content``. Uses the app-robot API when app credentials are present,
/// otherwise falls back to the group robot webhook.
pub async fn dingtalk_send(
cfg : PlatformConfig,
chat_id : String,
content : String,
reply_to? : String,
) -> SendResult {
ignore(reply_to)
let title = cfg.extra_str("title", "Message")
if content.is_empty() {
return SendResult::ok()
}
if cfg.extra_bool("use_webhook", false) || dingtalk_app_key(cfg) is None {
return dingtalk_send_webhook(cfg, content, title)
}
match dingtalk_access_token(cfg) {
None =>
missing_config(Dingtalk, "DINGTALK_CLIENT_ID / DINGTALK_CLIENT_SECRET")
Some(token) => {
let api_base = cfg.extra_str("api_base", "https://api.dingtalk.com")
let url = "\{api_base}/v1.0/robot/oToMessages/batchSend"
let headers = json_headers()
headers.set("x-acs-dingtalk-access-token", token)
let mut last = SendResult::ok()
for chunk in chunk_text(content, Platform::max_message_length(Dingtalk)) {
let msg_param = json_obj([
("title", Json::string(title)),
("text", Json::string(chunk)),
])
let body = json_obj([
("robotCode", Json::string(dingtalk_app_key(cfg).unwrap_or(""))),
("userIds", Json::array([Json::string(chat_id)])),
("msgKey", Json::string("sampleMarkdown")),
("msgParam", Json::string(msg_param.stringify())),
])
let r = send_with_retry(() => {
match post_json(url, body, headers) {
Err(e) => send_failure("dingtalk", e)
Ok(j) =>
match json_field_string(j, "message") {
Some(m) =>
if json_field_string(j, "processQueryKey") is None {
SendResult::failed(
"dingtalk: \{m}",
kind=classify_send_error(m),
)
} else {
send_ok(json_field_string(j, "processQueryKey"), j)
}
None => send_ok(json_field_string(j, "processQueryKey"), j)
}
}
})
if !r.success {
return r
}
last = r
}
last
}
}
}
///|
/// Send through the group robot webhook.
pub async fn dingtalk_send_webhook(
cfg : PlatformConfig,
content : String,
title : String,
) -> SendResult {
match dingtalk_robot_token(cfg) {
None => missing_config(Dingtalk, "DINGTALK_ACCESS_TOKEN")
Some(token) => {
let base = cfg.extra_str("webhook_base", "https://oapi.dingtalk.com")
let url = "\{base}/robot/send?access_token=\{url_quote(token)}"
let markdown = cfg.extra_bool("markdown", true)
let mut last = SendResult::ok()
for chunk in chunk_text(content, Platform::max_message_length(Dingtalk)) {
let r = send_with_retry(() => {
match
post_json(
url,
dingtalk_send_payload(chunk, title, markdown),
json_headers(),
) {
Err(e) => send_failure("dingtalk webhook", e)
Ok(j) => dingtalk_robot_result(j)
}
})
if !r.success {
return r
}
last = r
}
last
}
}
}
///|
/// Post to a fresh `sessionWebhook` (used to reply to an inbound callback).
pub async fn dingtalk_session_reply(
session_webhook : String,
content : String,
title? : String,
) -> SendResult {
let payload = dingtalk_send_payload(content, title.unwrap_or("Message"), true)
match post_json(session_webhook, payload, json_headers()) {
Err(e) => send_failure("dingtalk session", e)
Ok(j) => dingtalk_robot_result(j)
}
}
///|
/// Parse a DingTalk robot callback body into a `MessageEvent`. The
/// `sessionWebhook` is preserved in `reply_to_text` so the caller can answer
/// without a separate token.
pub fn dingtalk_parse_callback(body : String) -> Array[MessageEvent] {
let out : Array[MessageEvent] = []
let j = @json.parse(body) catch { _ => return out }
let chat_id = or_opt(
json_field_string(j, "conversationId"),
json_field_string(j, "senderId"),
).unwrap_or("")
if chat_id.is_empty() {
return out
}
let text = match json_field_string(j, "msgtype") {
Some("markdown") =>
match json_field_obj(j, "markdown") {
Some(m) => json_field_string(m, "text").unwrap_or("")
None => ""
}
_ =>
match json_field_obj(j, "text") {
Some(t) => json_field_string(t, "content").unwrap_or("")
None => ""
}
}
let mtype = if text.has_prefix("/") { Command } else { Text }
out.push(
MessageEvent::new(
text,
mtype,
Dingtalk,
chat_id,
user_id?=json_field_string(j, "senderId"),
user_name?=json_field_string(j, "senderNick"),
message_id?=json_field_string(j, "msgId"),
reply_to_text?=json_field_string(j, "sessionWebhook"),
media=[],
timestamp=now_ms(),
),
)
out
}