// Feishu / Lark open-platform adapter.
//
// Mirrors Hermes `plugins/platforms/feishu/adapter.py`: a tenant access token
// is fetched from `/auth/v3/tenant_access_token/internal`, messages are posted
// to `/im/v1/messages?receive_id_type=chat_id` (4000-char text chunks), and
// inbound traffic arrives as an event callback (`im.message.receive_v1`).
///|
/// Open-platform root, overridable with `[feishu.extra] api_base`
/// (`https://open.larksuite.com/open-apis` for Lark).
pub fn feishu_api_base(cfg : PlatformConfig) -> String {
let base = cfg.extra_str("api_base", "https://open.feishu.cn/open-apis")
if base.has_suffix("/") {
base[:base.length() - 1].to_owned()
} else {
base
}
}
///|
/// App id (`FEISHU_APP_ID`).
pub fn feishu_app_id(cfg : PlatformConfig) -> String {
cfg.extra_str("app_id", "")
}
///|
/// App secret (`FEISHU_APP_SECRET`), also accepted as `token`.
pub fn feishu_app_secret(cfg : PlatformConfig) -> String? {
match cfg.extra_first(["app_secret"]) {
Some(s) => Some(s)
None => cfg.token_of()
}
}
///|
/// Cached tenant access tokens keyed by app id.
let feishu_token_cache : Map[String, String] = Map([])
///|
/// Fetch (and cache) a tenant access token.
pub async fn feishu_tenant_token(cfg : PlatformConfig) -> String? {
let app_id = feishu_app_id(cfg)
let secret = match feishu_app_secret(cfg) {
Some(s) => s
None => return None
}
if app_id.is_empty() {
return None
}
match feishu_token_cache.get(app_id) {
Some(t) => return Some(t)
None => ()
}
let url = "\{feishu_api_base(cfg)}/auth/v3/tenant_access_token/internal"
let body = json_obj([
("app_id", Json::string(app_id)),
("app_secret", Json::string(secret)),
])
match post_json(url, body, json_headers()) {
Err(_) => None
Ok(j) =>
match json_field_string(j, "tenant_access_token") {
Some(t) => {
feishu_token_cache.set(app_id, t)
Some(t)
}
None => None
}
}
}
///|
/// Map a Feishu API response (`{code, msg, data}`) onto a `SendResult`.
fn feishu_result(j : Json) -> SendResult {
match json_field_int(j, "code") {
Some(0) => {
let mid = match json_field_obj(j, "data") {
Some(d) => json_field_string(d, "message_id")
None => None
}
send_ok(mid, j)
}
Some(code) => {
let msg = json_field_string(j, "msg").unwrap_or(j.stringify())
SendResult::failed(
"feishu: \{msg} (code \{code})",
kind=classify_send_error(msg),
raw=j.stringify(),
)
}
None => send_body_failure("feishu", j.stringify())
}
}
///|
/// Post to `/im/v1/messages`. ``id_type`` is `chat_id` or `open_id`.
pub async fn feishu_send_to(
cfg : PlatformConfig,
receive_id : String,
id_type : String,
content : String,
) -> SendResult {
match feishu_tenant_token(cfg) {
None => missing_config(Feishu, "FEISHU_APP_ID / FEISHU_APP_SECRET")
Some(token) => {
if content.is_empty() {
return SendResult::ok()
}
let url = "\{feishu_api_base(cfg)}/im/v1/messages?receive_id_type=\{id_type}"
let mut last = SendResult::ok()
let mut first_id : String? = None
for chunk in chunk_text(content, Platform::max_message_length(Feishu)) {
let body = json_obj([
("receive_id", Json::string(receive_id)),
("msg_type", Json::string("text")),
(
"content",
Json::string(json_obj([("text", Json::string(chunk))]).stringify()),
),
])
let r = send_with_retry(() => {
match post_json(url, body, bearer_headers(token)) {
Err(e) => send_failure("feishu", e)
Ok(j) => feishu_result(j)
}
})
if !r.success {
return r
}
if first_id is None {
first_id = r.message_id
}
last = r
}
{ ..last, message_id: first_id, }
}
}
}
///|
/// Send to a chat (`receive_id_type=chat_id`).
pub async fn feishu_send(
cfg : PlatformConfig,
chat_id : String,
content : String,
reply_to? : String,
) -> SendResult {
ignore(reply_to)
feishu_send_to(cfg, chat_id, "chat_id", content)
}
///|
/// Reply in-thread to a specific message.
pub async fn feishu_reply(
cfg : PlatformConfig,
message_id : String,
content : String,
) -> SendResult {
match feishu_tenant_token(cfg) {
None => missing_config(Feishu, "FEISHU_APP_ID / FEISHU_APP_SECRET")
Some(token) => {
let url = "\{feishu_api_base(cfg)}/im/v1/messages/\{message_id}/reply"
let body = json_obj([
("msg_type", Json::string("text")),
(
"content",
Json::string(json_obj([("text", Json::string(content))]).stringify()),
),
])
match post_json(url, body, bearer_headers(token)) {
Err(e) => send_failure("feishu reply", e)
Ok(j) => feishu_result(j)
}
}
}
}
///|
/// Chat metadata (`GET /im/v1/chats/{chat_id}`).
pub async fn feishu_get_chat(
cfg : PlatformConfig,
chat_id : String,
) -> ChatInfo? {
match feishu_tenant_token(cfg) {
None => None
Some(token) => {
let url = "\{feishu_api_base(cfg)}/im/v1/chats/\{chat_id}"
match get_json(url, bearer_headers(token)) {
Err(_) => None
Ok(j) =>
match json_field_obj(j, "data") {
Some(d) =>
Some({
name: json_field_string(d, "name").unwrap_or(chat_id),
chat_type: json_field_string(d, "chat_mode").unwrap_or("group"),
chat_id,
})
None => None
}
}
}
}
}
///|
/// Event-callback URL verification challenge.
pub fn feishu_challenge(body : String) -> String? {
let j = @json.parse(body) catch { _ => return None }
json_field_string(j, "challenge")
}
///|
/// Parse a Feishu event callback (`im.message.receive_v1`) into events.
/// Messages sent by the bot itself and plain text-less messages are skipped.
pub fn feishu_parse_event(body : String) -> Array[MessageEvent] {
let out : Array[MessageEvent] = []
let j = @json.parse(body) catch { _ => return out }
let event = match json_field_obj(j, "event") {
Some(e) => e
None => return out
}
let message = match json_field_obj(event, "message") {
Some(m) => m
None => return out
}
let sender = json_field_obj(event, "sender").unwrap_or(Json::null())
let sender_type = json_field_string(sender, "sender_type").unwrap_or("")
if sender_type == "bot" || sender_type == "app" {
return out
}
let sender_id = json_field_obj(sender, "sender_id").unwrap_or(Json::null())
let chat_id = json_field_string(message, "chat_id").unwrap_or("")
if chat_id.is_empty() {
return out
}
let text = feishu_message_text(message)
let msg_type = json_field_string(message, "message_type").unwrap_or("text")
let mtype = if text.has_prefix("/") {
Command
} else {
match msg_type {
"image" => Photo
"audio" => Audio
"media" => Video
"file" => Document
_ => Text
}
}
out.push(
MessageEvent::new(
text,
mtype,
Feishu,
chat_id,
user_id?=or_opt(
json_field_string(sender_id, "open_id"),
json_field_string(sender_id, "user_id"),
),
message_id?=json_field_string(message, "message_id"),
reply_to_message_id?=json_field_string(message, "parent_id"),
media=[],
timestamp=parse_ms(json_field_string(message, "create_time")),
),
)
out
}
///|
/// Extract the text body from a Feishu message (JSON-encoded `content`).
pub fn feishu_message_text(message : Json) -> String {
let raw = json_field_string(message, "content").unwrap_or("")
let content = @json.parse(raw) catch { _ => return raw }
json_field_string(content, "text").unwrap_or(raw)
}