1use async_trait::async_trait;
2use base64::Engine as _;
3use futures_util::{SinkExt, StreamExt};
4use prost::Message as ProstMessage;
5use std::collections::HashMap;
6use std::sync::{Arc, RwLock as StdRwLock};
7use std::time::{Duration, Instant};
8use tokio::sync::RwLock;
9use tokio_tungstenite::tungstenite::Message as WsMsg;
10use uuid::Uuid;
11use zeroclaw_api::channel::{Channel, ChannelMessage, SendMessage};
12
13const FEISHU_BASE_URL: &str = "https://open.feishu.cn/open-apis";
14const FEISHU_WS_BASE_URL: &str = "https://open.feishu.cn";
15const LARK_BASE_URL: &str = "https://open.larksuite.com/open-apis";
16const LARK_WS_BASE_URL: &str = "https://open.larksuite.com";
17
18const LARK_ACK_REACTIONS_ZH_CN: &[&str] = &[
19 "OK", "JIAYI", "APPLAUSE", "THUMBSUP", "MUSCLE", "SMILE", "DONE",
20];
21const LARK_ACK_REACTIONS_ZH_TW: &[&str] = &[
22 "OK",
23 "JIAYI",
24 "APPLAUSE",
25 "THUMBSUP",
26 "FINGERHEART",
27 "SMILE",
28 "DONE",
29];
30const LARK_ACK_REACTIONS_EN: &[&str] = &[
31 "OK",
32 "THUMBSUP",
33 "THANKS",
34 "MUSCLE",
35 "FINGERHEART",
36 "APPLAUSE",
37 "SMILE",
38 "DONE",
39];
40const LARK_ACK_REACTIONS_JA: &[&str] = &[
41 "OK",
42 "THUMBSUP",
43 "THANKS",
44 "MUSCLE",
45 "FINGERHEART",
46 "APPLAUSE",
47 "SMILE",
48 "DONE",
49];
50
51const MAX_LARK_AUDIO_BYTES: u64 = 25 * 1024 * 1024;
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54enum LarkAckLocale {
55 ZhCn,
56 ZhTw,
57 En,
58 Ja,
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62enum LarkPlatform {
63 Lark,
64 Feishu,
65}
66
67impl LarkPlatform {
68 fn api_base(self) -> &'static str {
69 match self {
70 Self::Lark => LARK_BASE_URL,
71 Self::Feishu => FEISHU_BASE_URL,
72 }
73 }
74
75 fn ws_base(self) -> &'static str {
76 match self {
77 Self::Lark => LARK_WS_BASE_URL,
78 Self::Feishu => FEISHU_WS_BASE_URL,
79 }
80 }
81
82 fn locale_header(self) -> &'static str {
83 match self {
84 Self::Lark => "en",
85 Self::Feishu => "zh",
86 }
87 }
88
89 fn proxy_service_key(self) -> &'static str {
90 match self {
91 Self::Lark => "channel.lark",
92 Self::Feishu => "channel.feishu",
93 }
94 }
95
96 fn channel_name(self) -> &'static str {
97 match self {
98 Self::Lark => "lark",
99 Self::Feishu => "feishu",
100 }
101 }
102}
103
104#[derive(Clone, PartialEq, prost::Message)]
109struct PbHeader {
110 #[prost(string, tag = "1")]
111 pub key: String,
112 #[prost(string, tag = "2")]
113 pub value: String,
114}
115
116#[derive(Clone, PartialEq, prost::Message)]
119struct PbFrame {
120 #[prost(uint64, tag = "1")]
121 pub seq_id: u64,
122 #[prost(uint64, tag = "2")]
123 pub log_id: u64,
124 #[prost(int32, tag = "3")]
125 pub service: i32,
126 #[prost(int32, tag = "4")]
127 pub method: i32,
128 #[prost(message, repeated, tag = "5")]
129 pub headers: Vec<PbHeader>,
130 #[prost(bytes = "vec", optional, tag = "8")]
131 pub payload: Option<Vec<u8>>,
132}
133
134impl PbFrame {
135 fn header_value<'a>(&'a self, key: &str) -> &'a str {
136 self.headers
137 .iter()
138 .find(|h| h.key == key)
139 .map(|h| h.value.as_str())
140 .unwrap_or("")
141 }
142}
143
144#[derive(Debug, serde::Deserialize, Default, Clone)]
146struct WsClientConfig {
147 #[serde(rename = "PingInterval")]
148 ping_interval: Option<u64>,
149}
150
151#[derive(Debug, serde::Deserialize)]
153struct WsEndpointResp {
154 code: i32,
155 #[serde(default)]
156 msg: Option<String>,
157 #[serde(default)]
158 data: Option<WsEndpoint>,
159}
160
161#[derive(Debug, serde::Deserialize)]
162struct WsEndpoint {
163 #[serde(rename = "URL")]
164 url: String,
165 #[serde(rename = "ClientConfig")]
166 client_config: Option<WsClientConfig>,
167}
168
169#[derive(Debug, serde::Deserialize)]
171struct LarkEvent {
172 header: LarkEventHeader,
173 event: serde_json::Value,
174}
175
176#[derive(Debug, serde::Deserialize)]
177struct LarkEventHeader {
178 event_type: String,
179 #[allow(dead_code)]
180 event_id: String,
181}
182
183#[derive(Debug, serde::Deserialize)]
184struct MsgReceivePayload {
185 sender: LarkSender,
186 message: LarkMessage,
187}
188
189#[derive(Debug, serde::Deserialize)]
190struct LarkSender {
191 sender_id: LarkSenderId,
192 #[serde(default)]
193 sender_type: String,
194}
195
196#[derive(Debug, serde::Deserialize, Default)]
197struct LarkSenderId {
198 open_id: Option<String>,
199}
200
201#[derive(Debug, serde::Deserialize)]
202struct LarkMessage {
203 message_id: String,
204 chat_id: String,
205 chat_type: String,
206 message_type: String,
207 #[serde(default)]
208 content: String,
209 #[serde(default)]
210 mentions: Vec<serde_json::Value>,
211}
212
213const WS_HEARTBEAT_TIMEOUT: Duration = Duration::from_secs(300);
216const LARK_TOKEN_REFRESH_SKEW: Duration = Duration::from_secs(120);
218const LARK_DEFAULT_TOKEN_TTL: Duration = Duration::from_secs(7200);
220const LARK_INVALID_ACCESS_TOKEN_CODE: i64 = 99_991_663;
222
223const LARK_DRAFT_RATE_LIMIT_CODE: i64 = 230_020;
228
229const LARK_CARD_MARKDOWN_MAX_BYTES: usize = 28_000;
232
233const LARK_IMAGE_MAX_BYTES: usize = 5 * 1024 * 1024;
235
236const LARK_FILE_MAX_BYTES: usize = 512 * 1024;
238
239const LARK_SUPPORTED_IMAGE_MIMES: &[&str] = &[
241 "image/png",
242 "image/jpeg",
243 "image/gif",
244 "image/webp",
245 "image/bmp",
246];
247
248fn should_refresh_last_recv(msg: &WsMsg) -> bool {
251 matches!(msg, WsMsg::Binary(_) | WsMsg::Ping(_) | WsMsg::Pong(_))
252}
253
254fn build_card_content(markdown: &str) -> String {
258 serde_json::json!({
259 "schema": "2.0",
260 "body": {
261 "elements": [{
262 "tag": "markdown",
263 "content": markdown
264 }]
265 }
266 })
267 .to_string()
268}
269
270fn build_approval_card(
281 approval_id: &str,
282 tool_name: &str,
283 arguments_summary: &str,
284) -> serde_json::Value {
285 let make_button = |label: &str, button_type: &str, decision: &str| {
286 serde_json::json!({
287 "tag": "button",
288 "text": { "tag": "plain_text", "content": label },
289 "type": button_type,
290 "behaviors": [{
291 "type": "callback",
292 "value": {
293 "approval_id": approval_id,
294 "decision": decision
295 }
296 }]
297 })
298 };
299
300 serde_json::json!({
301 "schema": "2.0",
302 "config": { "wide_screen_mode": true },
303 "header": {
304 "template": "orange",
305 "title": {
306 "tag": "plain_text",
307 "content": "🔧 Tool approval required"
308 }
309 },
310 "body": {
311 "elements": [
312 {
313 "tag": "markdown",
314 "content": format!("**Tool:** `{tool_name}`\n\n{arguments_summary}")
315 },
316 {
317 "tag": "column_set",
318 "flex_mode": "stretch",
319 "columns": [
320 { "tag": "column", "elements": [
321 make_button("✅ Approve", "primary_filled", "approve")
322 ]},
323 { "tag": "column", "elements": [
324 make_button("❌ Deny", "danger_filled", "deny")
325 ]},
326 { "tag": "column", "elements": [
327 make_button("✅✅ Always", "default", "always")
328 ]}
329 ]
330 }
331 ]
332 }
333 })
334}
335
336fn build_resolved_approval_card(
344 tool_name: &str,
345 arguments_summary: &str,
346 decision: zeroclaw_api::channel::ChannelApprovalResponse,
347) -> serde_json::Value {
348 use zeroclaw_api::channel::ChannelApprovalResponse;
349
350 let (banner_emoji, banner_text, header_template) = match decision {
351 ChannelApprovalResponse::Approve => ("✅", "Approved", "green"),
352 ChannelApprovalResponse::AlwaysApprove => ("✅✅", "Approved (always)", "green"),
353 ChannelApprovalResponse::Deny => ("❌", "Denied", "red"),
354 };
355
356 serde_json::json!({
357 "schema": "2.0",
358 "config": { "wide_screen_mode": true },
359 "header": {
360 "template": header_template,
361 "title": {
362 "tag": "plain_text",
363 "content": format!("{banner_emoji} Tool approval — {banner_text}")
364 }
365 },
366 "body": {
367 "elements": [
368 {
369 "tag": "markdown",
370 "content": format!(
371 "**Tool:** `{tool_name}`\n\n{arguments_summary}\n\n---\n\n**{banner_emoji} {banner_text}**"
372 )
373 }
374 ]
375 }
376 })
377}
378
379fn sanitize_card_action_payload(event_payload: &serde_json::Value) -> serde_json::Value {
405 use serde_json::Value;
406
407 let mut sanitized = event_payload.clone();
408
409 if let Some(token) = sanitized.get_mut("token")
411 && !token.is_null()
412 {
413 *token = Value::String("REDACTED_TOKEN".to_string());
414 }
415
416 if let Some(Value::Object(operator)) = sanitized.get_mut("operator") {
420 for (key, placeholder) in [
421 ("open_id", "REDACTED_OPERATOR_OPEN_ID"),
422 ("union_id", "REDACTED_OPERATOR_UNION_ID"),
423 ("user_id", "REDACTED_OPERATOR_USER_ID"),
424 ("tenant_key", "REDACTED_OPERATOR_TENANT_KEY"),
425 ] {
426 if operator.contains_key(key) {
427 operator.insert(key.to_string(), Value::String(placeholder.to_string()));
428 }
429 }
430 }
431
432 if let Some(Value::Object(context)) = sanitized.get_mut("context") {
434 for (key, placeholder) in [
435 ("open_chat_id", "REDACTED_OPEN_CHAT_ID"),
436 ("open_message_id", "REDACTED_OPEN_MESSAGE_ID"),
437 ] {
438 if context.contains_key(key) {
439 context.insert(key.to_string(), Value::String(placeholder.to_string()));
440 }
441 }
442 }
443
444 sanitized
445}
446
447fn build_interactive_card_body(recipient: &str, markdown: &str) -> serde_json::Value {
449 serde_json::json!({
450 "receive_id": recipient,
451 "msg_type": "interactive",
452 "content": build_card_content(markdown),
453 })
454}
455
456fn split_markdown_chunks(text: &str, max_bytes: usize) -> Vec<&str> {
459 if text.len() <= max_bytes {
460 return vec![text];
461 }
462
463 let mut chunks = Vec::new();
464 let mut start = 0;
465
466 while start < text.len() {
467 if start + max_bytes >= text.len() {
468 chunks.push(&text[start..]);
469 break;
470 }
471
472 let end = start + max_bytes;
473 let search_region = &text[start..end];
474 let split_at = search_region
475 .rfind('\n')
476 .map(|pos| start + pos + 1)
477 .unwrap_or(end);
478
479 let split_at = if text.is_char_boundary(split_at) {
480 split_at
481 } else {
482 (start..split_at)
483 .rev()
484 .find(|&i| text.is_char_boundary(i))
485 .unwrap_or(start)
486 };
487
488 if split_at <= start {
489 let forced = (end..=text.len())
490 .find(|&i| text.is_char_boundary(i))
491 .unwrap_or(text.len());
492 chunks.push(&text[start..forced]);
493 start = forced;
494 } else {
495 chunks.push(&text[start..split_at]);
496 start = split_at;
497 }
498 }
499
500 chunks
501}
502
503#[derive(Debug, Clone)]
504struct CachedTenantToken {
505 value: String,
506 refresh_after: Instant,
507}
508
509fn extract_lark_response_code(body: &serde_json::Value) -> Option<i64> {
510 body.get("code").and_then(|c| c.as_i64())
511}
512
513fn is_lark_invalid_access_token(body: &serde_json::Value) -> bool {
514 extract_lark_response_code(body) == Some(LARK_INVALID_ACCESS_TOKEN_CODE)
515}
516
517fn should_refresh_lark_tenant_token(status: reqwest::StatusCode, body: &serde_json::Value) -> bool {
518 status == reqwest::StatusCode::UNAUTHORIZED || is_lark_invalid_access_token(body)
519}
520
521fn extract_lark_token_ttl_seconds(body: &serde_json::Value) -> u64 {
522 let ttl = body
523 .get("expire")
524 .or_else(|| body.get("expires_in"))
525 .and_then(|v| v.as_u64())
526 .or_else(|| {
527 body.get("expire")
528 .or_else(|| body.get("expires_in"))
529 .and_then(|v| v.as_i64())
530 .and_then(|v| u64::try_from(v).ok())
531 })
532 .unwrap_or(LARK_DEFAULT_TOKEN_TTL.as_secs());
533 ttl.max(1)
534}
535
536fn next_token_refresh_deadline(now: Instant, ttl_seconds: u64) -> Instant {
537 let ttl = Duration::from_secs(ttl_seconds.max(1));
538 let refresh_in = ttl
539 .checked_sub(LARK_TOKEN_REFRESH_SKEW)
540 .unwrap_or(Duration::from_secs(1));
541 now + refresh_in
542}
543
544fn ensure_lark_send_success(
545 status: reqwest::StatusCode,
546 body: &serde_json::Value,
547 context: &str,
548) -> anyhow::Result<()> {
549 if !status.is_success() {
550 anyhow::bail!("send failed {context}: status={status}, body={body}");
551 }
552
553 let code = extract_lark_response_code(body).unwrap_or(0);
554 if code != 0 {
555 anyhow::bail!("send failed {context}: code={code}, body={body}");
556 }
557
558 Ok(())
559}
560
561struct PendingApproval {
566 sender: tokio::sync::oneshot::Sender<zeroclaw_api::channel::ChannelApprovalResponse>,
567 message_id: String,
571 tool_name: String,
572 arguments_summary: String,
573}
574
575#[derive(Clone)]
581pub struct LarkChannel {
582 app_id: String,
583 app_secret: String,
584 verification_token: String,
585 port: Option<u16>,
586 alias: String,
590 peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync>,
593 resolved_bot_open_id: Arc<StdRwLock<Option<String>>>,
595 mention_only: bool,
596 platform: LarkPlatform,
598 receive_mode: zeroclaw_config::schema::LarkReceiveMode,
600 tenant_token: Arc<RwLock<Option<CachedTenantToken>>>,
602 ws_seen_ids: Arc<RwLock<HashMap<String, Instant>>>,
604 proxy_url: Option<String>,
606 transcription: Option<zeroclaw_config::schema::TranscriptionConfig>,
607 transcription_manager: Option<Arc<super::transcription::TranscriptionManager>>,
608 pending_approvals: Arc<tokio::sync::Mutex<std::collections::HashMap<String, PendingApproval>>>,
611 approval_timeout_secs: u64,
615 #[cfg(test)]
616 api_base_override: Option<String>,
617}
618
619impl LarkChannel {
620 pub fn new(
621 app_id: String,
622 app_secret: String,
623 verification_token: String,
624 port: Option<u16>,
625 alias: impl Into<String>,
626 peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync>,
627 mention_only: bool,
628 ) -> Self {
629 Self::new_with_platform(
630 app_id,
631 app_secret,
632 verification_token,
633 port,
634 alias,
635 peer_resolver,
636 mention_only,
637 LarkPlatform::Lark,
638 )
639 }
640
641 pub fn alias(&self) -> &str {
644 &self.alias
645 }
646
647 fn new_with_platform(
648 app_id: String,
649 app_secret: String,
650 verification_token: String,
651 port: Option<u16>,
652 alias: impl Into<String>,
653 peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync>,
654 mention_only: bool,
655 platform: LarkPlatform,
656 ) -> Self {
657 Self {
658 app_id,
659 app_secret,
660 verification_token,
661 port,
662 alias: alias.into(),
663 peer_resolver,
664 resolved_bot_open_id: Arc::new(StdRwLock::new(None)),
665 mention_only,
666 platform,
667 receive_mode: zeroclaw_config::schema::LarkReceiveMode::default(),
668 tenant_token: Arc::new(RwLock::new(None)),
669 ws_seen_ids: Arc::new(RwLock::new(HashMap::new())),
670 proxy_url: None,
671 transcription: None,
672 transcription_manager: None,
673 pending_approvals: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
674 approval_timeout_secs: 120,
675 #[cfg(test)]
676 api_base_override: None,
677 }
678 }
679
680 pub fn from_config(
683 config: &zeroclaw_config::schema::LarkConfig,
684 alias: impl Into<String>,
685 peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync>,
686 ) -> Self {
687 let platform = if config.use_feishu {
688 LarkPlatform::Feishu
689 } else {
690 LarkPlatform::Lark
691 };
692 let mut ch = Self::new_with_platform(
693 config.app_id.clone(),
694 config.app_secret.clone(),
695 config.verification_token.clone().unwrap_or_default(),
696 config.port,
697 alias,
698 peer_resolver,
699 config.mention_only,
700 platform,
701 );
702 ch.receive_mode = config.receive_mode.clone();
703 ch.proxy_url = config.proxy_url.clone();
704 ch
705 }
706
707 pub fn with_transcription(
708 mut self,
709 config: zeroclaw_config::schema::TranscriptionConfig,
710 ) -> Self {
711 if !config.enabled {
712 return self;
713 }
714 match super::transcription::TranscriptionManager::new(&config) {
715 Ok(m) => {
716 let names = m.available_providers();
722 let m = if names.len() == 1 {
723 let only = names[0].to_string();
724 m.with_agent_transcription_provider(only)
725 } else {
726 m
727 };
728 self.transcription_manager = Some(Arc::new(m));
729 }
730 Err(e) => {
731 ::zeroclaw_log::record!(
732 WARN,
733 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
734 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
735 .with_attrs(::serde_json::json!({"e": e.to_string()})),
736 "transcription manager init failed, audio transcription disabled"
737 );
738 }
739 }
740 self.transcription = Some(config);
741 self
742 }
743
744 fn http_client(&self) -> reqwest::Client {
745 zeroclaw_config::schema::build_channel_proxy_client(
746 self.platform.proxy_service_key(),
747 self.proxy_url.as_deref(),
748 )
749 }
750
751 fn channel_name(&self) -> &'static str {
752 self.platform.channel_name()
753 }
754
755 fn api_base(&self) -> &str {
756 #[cfg(test)]
757 if let Some(ref url) = self.api_base_override {
758 return url.as_str();
759 }
760 self.platform.api_base()
761 }
762
763 fn ws_base(&self) -> &'static str {
764 self.platform.ws_base()
765 }
766
767 fn tenant_access_token_url(&self) -> String {
768 format!("{}/auth/v3/tenant_access_token/internal", self.api_base())
769 }
770
771 fn bot_info_url(&self) -> String {
772 format!("{}/bot/v3/info", self.api_base())
773 }
774
775 fn send_message_url(&self) -> String {
776 format!("{}/im/v1/messages?receive_id_type=chat_id", self.api_base())
777 }
778
779 fn patch_message_url(&self, message_id: &str) -> String {
783 format!("{}/im/v1/messages/{message_id}", self.api_base())
784 }
785
786 fn message_reaction_url(&self, message_id: &str) -> String {
787 format!("{}/im/v1/messages/{message_id}/reactions", self.api_base())
788 }
789
790 fn image_download_url(&self, image_key: &str) -> String {
791 format!("{}/im/v1/images/{image_key}", self.api_base())
792 }
793
794 fn file_download_url(&self, message_id: &str, file_key: &str) -> String {
795 format!(
796 "{}/im/v1/messages/{message_id}/resources/{file_key}?type=file",
797 self.api_base()
798 )
799 }
800
801 fn resolved_bot_open_id(&self) -> Option<String> {
802 self.resolved_bot_open_id
803 .read()
804 .ok()
805 .and_then(|guard| guard.clone())
806 }
807
808 fn set_resolved_bot_open_id(&self, open_id: Option<String>) {
809 if let Ok(mut guard) = self.resolved_bot_open_id.write() {
810 *guard = open_id;
811 }
812 }
813
814 async fn post_message_reaction_with_token(
815 &self,
816 message_id: &str,
817 token: &str,
818 emoji_type: &str,
819 ) -> anyhow::Result<reqwest::Response> {
820 let url = self.message_reaction_url(message_id);
821 let body = serde_json::json!({
822 "reaction_type": {
823 "emoji_type": emoji_type
824 }
825 });
826
827 let response = self
828 .http_client()
829 .post(&url)
830 .header("Authorization", format!("Bearer {token}"))
831 .header("Content-Type", "application/json; charset=utf-8")
832 .json(&body)
833 .send()
834 .await?;
835
836 Ok(response)
837 }
838
839 async fn try_add_ack_reaction(&self, message_id: &str, emoji_type: &str) {
842 if message_id.is_empty() {
843 return;
844 }
845
846 let mut token = match self.get_tenant_access_token().await {
847 Ok(token) => token,
848 Err(err) => {
849 ::zeroclaw_log::record!(
850 WARN,
851 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
852 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
853 .with_attrs(::serde_json::json!({"error": format!("{}", err)})),
854 "failed to fetch token for reaction"
855 );
856 return;
857 }
858 };
859
860 let mut retried = false;
861 loop {
862 let response = match self
863 .post_message_reaction_with_token(message_id, &token, emoji_type)
864 .await
865 {
866 Ok(resp) => resp,
867 Err(err) => {
868 ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"error": format!("{}", err), "message_id": message_id})), "failed to add reaction for");
869 return;
870 }
871 };
872
873 if response.status().as_u16() == 401 && !retried {
874 self.invalidate_token().await;
875 token = match self.get_tenant_access_token().await {
876 Ok(new_token) => new_token,
877 Err(err) => {
878 ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"message_id": message_id, "err": err.to_string()})), "failed to refresh token for reaction on");
879 return;
880 }
881 };
882 retried = true;
883 continue;
884 }
885
886 if !response.status().is_success() {
887 let status = response.status();
888 let err_body = response.text().await.unwrap_or_default();
889 ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"message_id": message_id, "status": status.to_string(), "err_body": err_body})), "add reaction failed for : status=, body=");
890 return;
891 }
892
893 let payload: serde_json::Value = match response.json().await {
894 Ok(v) => v,
895 Err(err) => {
896 ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"error": format!("{}", err), "message_id": message_id})), "add reaction decode failed for");
897 return;
898 }
899 };
900
901 let code = payload.get("code").and_then(|v| v.as_i64()).unwrap_or(-1);
902 if code != 0 {
903 let msg = payload
904 .get("msg")
905 .and_then(|v| v.as_str())
906 .unwrap_or("unknown error");
907 ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"code": code.to_string(), "message_id": message_id, "msg": msg.to_string()})), "add reaction returned code= for");
908 }
909 return;
910 }
911 }
912
913 async fn get_ws_endpoint(&self) -> anyhow::Result<(String, WsClientConfig)> {
915 let resp = self
916 .http_client()
917 .post(format!("{}/callback/ws/endpoint", self.ws_base()))
918 .header("locale", self.platform.locale_header())
919 .json(&serde_json::json!({
920 "AppID": self.app_id,
921 "AppSecret": self.app_secret,
922 }))
923 .send()
924 .await?
925 .json::<WsEndpointResp>()
926 .await?;
927 if resp.code != 0 {
928 anyhow::bail!(
929 "WS endpoint failed: code={} msg={}",
930 resp.code,
931 resp.msg.as_deref().unwrap_or("(none)")
932 );
933 }
934 let ep = resp.data.ok_or_else(|| {
935 ::zeroclaw_log::record!(
936 ERROR,
937 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
938 .with_outcome(::zeroclaw_log::EventOutcome::Failure),
939 "WS endpoint: empty data"
940 );
941 anyhow::Error::msg("WS endpoint: empty data")
942 })?;
943 Ok((ep.url, ep.client_config.unwrap_or_default()))
944 }
945
946 #[allow(clippy::too_many_lines)]
949 async fn listen_ws(&self, tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> anyhow::Result<()> {
950 self.ensure_bot_open_id().await;
951 let (wss_url, client_config) = self.get_ws_endpoint().await?;
952 let service_id = wss_url
953 .split('?')
954 .nth(1)
955 .and_then(|qs| {
956 qs.split('&')
957 .find(|kv| kv.starts_with("service_id="))
958 .and_then(|kv| kv.split('=').nth(1))
959 .and_then(|v| v.parse::<i32>().ok())
960 })
961 .unwrap_or(0);
962 ::zeroclaw_log::record!(
963 INFO,
964 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
965 .with_attrs(::serde_json::json!({"wss_url": wss_url})),
966 "connecting to"
967 );
968
969 let (ws_stream, _) = zeroclaw_config::schema::ws_connect_with_proxy(
970 &wss_url,
971 "channel.lark",
972 self.proxy_url.as_deref(),
973 )
974 .await?;
975 let (mut write, mut read) = ws_stream.split();
976 ::zeroclaw_log::record!(
977 INFO,
978 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
979 .with_attrs(::serde_json::json!({"service_id": service_id})),
980 "WS connected (service_id=)"
981 );
982
983 let mut ping_secs = client_config.ping_interval.unwrap_or(120).max(10);
984 let mut hb_interval = tokio::time::interval(Duration::from_secs(ping_secs));
985 let mut timeout_check = tokio::time::interval(Duration::from_secs(10));
986 hb_interval.tick().await; let mut seq: u64 = 0;
989 let mut last_recv = Instant::now();
990
991 seq = seq.wrapping_add(1);
994 let initial_ping = PbFrame {
995 seq_id: seq,
996 log_id: 0,
997 service: service_id,
998 method: 0,
999 headers: vec![PbHeader {
1000 key: "type".into(),
1001 value: "ping".into(),
1002 }],
1003 payload: None,
1004 };
1005 if write
1006 .send(WsMsg::Binary(initial_ping.encode_to_vec().into()))
1007 .await
1008 .is_err()
1009 {
1010 anyhow::bail!("initial ping failed");
1011 }
1012 type FragEntry = (Vec<Option<Vec<u8>>>, Instant);
1014 let mut frag_cache: HashMap<String, FragEntry> = HashMap::new();
1015
1016 loop {
1017 tokio::select! {
1018 biased;
1019
1020 _ = hb_interval.tick() => {
1021 seq = seq.wrapping_add(1);
1022 let ping = PbFrame {
1023 seq_id: seq, log_id: 0, service: service_id, method: 0,
1024 headers: vec![PbHeader { key: "type".into(), value: "ping".into() }],
1025 payload: None,
1026 };
1027 if write.send(WsMsg::Binary(ping.encode_to_vec().into())).await.is_err() {
1028 ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown), "ping failed, reconnecting");
1029 break;
1030 }
1031 let cutoff = Instant::now().checked_sub(Duration::from_secs(300)).unwrap_or(Instant::now());
1033 frag_cache.retain(|_, (_, ts)| *ts > cutoff);
1034 }
1035
1036 _ = timeout_check.tick() => {
1037 if last_recv.elapsed() > WS_HEARTBEAT_TIMEOUT {
1038 ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown), "heartbeat timeout, reconnecting");
1039 break;
1040 }
1041 }
1042
1043 msg = read.next() => {
1044 let raw = match msg {
1045 Some(Ok(ws_msg)) => {
1046 if should_refresh_last_recv(&ws_msg) {
1047 last_recv = Instant::now();
1048 }
1049 match ws_msg {
1050 WsMsg::Binary(b) => b,
1051 WsMsg::Ping(d) => { let _ = write.send(WsMsg::Pong(d)).await; continue; }
1052 WsMsg::Close(_) => { ::zeroclaw_log::record!(INFO, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note), "WS closed — reconnecting"); break; }
1053 _ => continue,
1054 }
1055 }
1056 None => { ::zeroclaw_log::record!(INFO, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note), "WS closed — reconnecting"); break; }
1057 Some(Err(e)) => { ::zeroclaw_log::record!(ERROR, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail).with_outcome(::zeroclaw_log::EventOutcome::Failure).with_attrs(::serde_json::json!({"error": format!("{}", e)})), "WS read error"); break; }
1058 };
1059
1060 let frame = match PbFrame::decode(&raw[..]) {
1061 Ok(f) => f,
1062 Err(e) => { ::zeroclaw_log::record!(ERROR, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail).with_outcome(::zeroclaw_log::EventOutcome::Failure).with_attrs(::serde_json::json!({"error": format!("{}", e)})), "proto decode"); continue; }
1063 };
1064
1065 if frame.method == 0 {
1067 if frame.header_value("type") == "pong"
1068 && let Some(p) = &frame.payload
1069 && let Ok(cfg) = serde_json::from_slice::<WsClientConfig>(p)
1070 && let Some(secs) = cfg.ping_interval {
1071 let secs = secs.max(10);
1072 if secs != ping_secs {
1073 ping_secs = secs;
1074 hb_interval = tokio::time::interval(Duration::from_secs(ping_secs));
1075 ::zeroclaw_log::record!(INFO, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"ping_secs": ping_secs})), "ping_interval → s");
1076 }
1077 }
1078 continue;
1079 }
1080
1081 let msg_type = frame.header_value("type").to_string();
1083 let msg_id = frame.header_value("message_id").to_string();
1084 let sum = frame.header_value("sum").parse::<usize>().unwrap_or(1);
1085 let seq_num = frame.header_value("seq").parse::<usize>().unwrap_or(0);
1086
1087 {
1089 let mut ack = frame.clone();
1090 ack.payload = Some(br#"{"code":200,"headers":{},"data":[]}"#.to_vec());
1091 ack.headers.push(PbHeader { key: "biz_rt".into(), value: "0".into() });
1092 let _ = write.send(WsMsg::Binary(ack.encode_to_vec().into())).await;
1093 }
1094
1095 let sum = if sum == 0 { 1 } else { sum };
1097 let payload: Vec<u8> = if sum == 1 || msg_id.is_empty() || seq_num >= sum {
1098 frame.payload.clone().unwrap_or_default()
1099 } else {
1100 let entry = frag_cache.entry(msg_id.clone())
1101 .or_insert_with(|| (vec![None; sum], Instant::now()));
1102 if entry.0.len() != sum { *entry = (vec![None; sum], Instant::now()); }
1103 entry.0[seq_num] = frame.payload.clone();
1104 if entry.0.iter().all(|s| s.is_some()) {
1105 let full: Vec<u8> = entry.0.iter()
1106 .flat_map(|s| s.as_deref().unwrap_or(&[]))
1107 .copied().collect();
1108 frag_cache.remove(&msg_id);
1109 full
1110 } else { continue; }
1111 };
1112
1113 if msg_type != "event" { continue; }
1114
1115 let event: LarkEvent = match serde_json::from_slice(&payload) {
1116 Ok(e) => e,
1117 Err(e) => { ::zeroclaw_log::record!(ERROR, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail).with_outcome(::zeroclaw_log::EventOutcome::Failure).with_attrs(::serde_json::json!({"error": format!("{}", e)})), "event JSON"); continue; }
1118 };
1119 match event.header.event_type.as_str() {
1120 "im.message.receive_v1" => {}
1121 "card.action.trigger" => {
1122 if let Err(e) = self.handle_card_action_event(&event.event).await {
1123 ::zeroclaw_log::record!(
1124 WARN,
1125 ::zeroclaw_log::Event::new(
1126 module_path!(),
1127 ::zeroclaw_log::Action::Dispatch
1128 )
1129 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
1130 .with_attrs(::serde_json::json!({"error": e.to_string()})),
1131 "Lark WS: card action dispatch error"
1132 );
1133 }
1134 continue;
1135 }
1136 _ => continue,
1137 }
1138
1139 let event_payload = event.event;
1140
1141 let recv: MsgReceivePayload = match serde_json::from_value(event_payload.clone()) {
1142 Ok(r) => r,
1143 Err(e) => { ::zeroclaw_log::record!(ERROR, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail).with_outcome(::zeroclaw_log::EventOutcome::Failure).with_attrs(::serde_json::json!({"error": format!("{}", e)})), "payload parse"); continue; }
1144 };
1145
1146 if recv.sender.sender_type == "app" || recv.sender.sender_type == "bot" { continue; }
1147
1148 let sender_open_id = recv.sender.sender_id.open_id.as_deref().unwrap_or("");
1149 if !self.is_user_allowed(sender_open_id) {
1150 ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"sender_open_id": sender_open_id})), "WS: ignoring (not in peer group)");
1151 continue;
1152 }
1153
1154 let lark_msg = &recv.message;
1155
1156 {
1158 let now = Instant::now();
1159 let mut seen = self.ws_seen_ids.write().await;
1160 seen.retain(|_, t| now.duration_since(*t) < Duration::from_secs(30 * 60));
1162 if seen.contains_key(&lark_msg.message_id) {
1163 ::zeroclaw_log::record!(DEBUG, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note), &format!("WS: dup {}", lark_msg.message_id));
1164 continue;
1165 }
1166 seen.insert(lark_msg.message_id.clone(), now);
1167 }
1168
1169 let (text, post_mentioned_open_ids) = match lark_msg.message_type.as_str() {
1171 "text" => {
1172 let v: serde_json::Value = match serde_json::from_str(&lark_msg.content) {
1173 Ok(v) => v,
1174 Err(_) => continue,
1175 };
1176 match v.get("text").and_then(|t| t.as_str()).filter(|s| !s.is_empty()) {
1177 Some(t) => (t.to_string(), Vec::new()),
1178 None => continue,
1179 }
1180 }
1181 "post" => match parse_post_content_details(&lark_msg.content) {
1182 Some(details) => (details.text, details.mentioned_open_ids),
1183 None => continue,
1184 },
1185 "image" => {
1186 let v: serde_json::Value = match serde_json::from_str(&lark_msg.content) {
1187 Ok(v) => v,
1188 Err(_) => continue,
1189 };
1190 let image_key = match v.get("image_key").and_then(|k| k.as_str()) {
1191 Some(k) => k.to_string(),
1192 None => { ::zeroclaw_log::record!(DEBUG, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note), "WS: image message missing image_key"); continue; }
1193 };
1194 match self.download_image_as_marker(&image_key).await {
1195 Some(marker) => (marker, Vec::new()),
1196 None => {
1197 ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"image_key": image_key})), "WS: failed to download image");
1198 (format!("[IMAGE:{image_key} | download failed]"), Vec::new())
1199 }
1200 }
1201 }
1202 "file" => {
1203 let v: serde_json::Value = match serde_json::from_str(&lark_msg.content) {
1204 Ok(v) => v,
1205 Err(_) => continue,
1206 };
1207 let file_key = match v.get("file_key").and_then(|k| k.as_str()) {
1208 Some(k) => k.to_string(),
1209 None => { ::zeroclaw_log::record!(DEBUG, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note), "WS: file message missing file_key"); continue; }
1210 };
1211 let file_name = v.get("file_name")
1212 .and_then(|n| n.as_str())
1213 .unwrap_or("unknown_file")
1214 .to_string();
1215 match self.download_file_as_content(&lark_msg.message_id, &file_key, &file_name).await {
1216 Some(content) => (content, Vec::new()),
1217 None => {
1218 ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"file_key": file_key})), "WS: failed to download file");
1219 (format!("[ATTACHMENT:{file_name} | download failed]"), Vec::new())
1220 }
1221 }
1222 }
1223 "audio" => {
1224 let Some(manager) = self.transcription_manager.as_deref() else {
1225 ::zeroclaw_log::record!(DEBUG, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note), &format!("WS: audio message in {} (transcription not configured)", lark_msg.chat_id));
1226 continue;
1227 };
1228 let transcript = self.try_transcribe_audio_message(
1229 &lark_msg.message_id,
1230 &lark_msg.content,
1231 manager,
1232 ).await;
1233 let Some(text) = transcript else { continue; };
1234 (text, Vec::new())
1235 }
1236 "list" => match parse_list_content(&lark_msg.content) {
1237 Some(t) => (t, Vec::new()),
1238 None => { ::zeroclaw_log::record!(DEBUG, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note), "WS: list message with no extractable text"); continue; }
1239 },
1240 _ => { ::zeroclaw_log::record!(DEBUG, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note), &format!("WS: skipping unsupported type '{}'", lark_msg.message_type)); continue; }
1241 };
1242
1243 let text = text.trim().to_string();
1244 if text.is_empty() { continue; }
1245
1246 let bot_open_id = self.resolved_bot_open_id();
1248 if lark_msg.chat_type == "group"
1249 && !should_respond_in_group(
1250 self.mention_only,
1251 bot_open_id.as_deref(),
1252 &lark_msg.mentions,
1253 &post_mentioned_open_ids,
1254 )
1255 {
1256 continue;
1257 }
1258
1259 let ack_emoji =
1260 random_lark_ack_reaction(Some(&event_payload), &text).to_string();
1261 let reaction_channel = self.clone();
1262 let reaction_message_id = lark_msg.message_id.clone();
1263 tokio::spawn(async move {
1264 reaction_channel
1265 .try_add_ack_reaction(&reaction_message_id, &ack_emoji)
1266 .await;
1267 });
1268
1269 let channel_msg = ChannelMessage {
1270 id: Uuid::new_v4().to_string(),
1271 sender: lark_msg.chat_id.clone(),
1272 reply_target: lark_msg.chat_id.clone(),
1273 content: text,
1274 channel: self.channel_name().to_string(),
1275 channel_alias: Some(self.alias.clone()),
1276 timestamp: std::time::SystemTime::now()
1277 .duration_since(std::time::UNIX_EPOCH)
1278 .unwrap_or_default()
1279 .as_secs(),
1280 thread_ts: None,
1281 interruption_scope_id: None,
1282 attachments: vec![],
1283 subject: None,
1284 };
1285
1286 ::zeroclaw_log::record!(DEBUG, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note), &format!("WS: message in {}", lark_msg.chat_id));
1287 if tx.send(channel_msg).await.is_err() { break; }
1288 }
1289 }
1290 }
1291 Ok(())
1292 }
1293
1294 fn is_user_allowed(&self, open_id: &str) -> bool {
1296 let peers = (self.peer_resolver)();
1297 crate::allowlist::is_user_allowed(&peers, open_id, crate::allowlist::Match::Sensitive)
1298 }
1299
1300 async fn get_tenant_access_token(&self) -> anyhow::Result<String> {
1302 {
1304 let cached = self.tenant_token.read().await;
1305 if let Some(ref token) = *cached
1306 && Instant::now() < token.refresh_after
1307 {
1308 return Ok(token.value.clone());
1309 }
1310 }
1311
1312 let url = self.tenant_access_token_url();
1313 let body = serde_json::json!({
1314 "app_id": self.app_id,
1315 "app_secret": self.app_secret,
1316 });
1317
1318 let resp = self.http_client().post(&url).json(&body).send().await?;
1319 let status = resp.status();
1320 let data: serde_json::Value = resp.json().await?;
1321
1322 if !status.is_success() {
1323 anyhow::bail!("tenant_access_token request failed: status={status}, body={data}");
1324 }
1325
1326 let code = data.get("code").and_then(|c| c.as_i64()).unwrap_or(-1);
1327 if code != 0 {
1328 let msg = data
1329 .get("msg")
1330 .and_then(|m| m.as_str())
1331 .unwrap_or("unknown error");
1332 anyhow::bail!("tenant_access_token failed: {msg}");
1333 }
1334
1335 let token = data
1336 .get("tenant_access_token")
1337 .and_then(|t| t.as_str())
1338 .ok_or_else(|| {
1339 ::zeroclaw_log::record!(
1340 WARN,
1341 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
1342 .with_outcome(::zeroclaw_log::EventOutcome::Failure),
1343 "missing tenant_access_token in response"
1344 );
1345 anyhow::Error::msg("missing tenant_access_token in response")
1346 })?
1347 .to_string();
1348
1349 let ttl_seconds = extract_lark_token_ttl_seconds(&data);
1350 let refresh_after = next_token_refresh_deadline(Instant::now(), ttl_seconds);
1351
1352 {
1354 let mut cached = self.tenant_token.write().await;
1355 *cached = Some(CachedTenantToken {
1356 value: token.clone(),
1357 refresh_after,
1358 });
1359 }
1360
1361 Ok(token)
1362 }
1363
1364 async fn invalidate_token(&self) {
1366 let mut cached = self.tenant_token.write().await;
1367 *cached = None;
1368 }
1369
1370 async fn download_image_as_marker(&self, image_key: &str) -> Option<String> {
1372 let token = match self.get_tenant_access_token().await {
1373 Ok(t) => t,
1374 Err(e) => {
1375 ::zeroclaw_log::record!(
1376 WARN,
1377 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1378 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
1379 .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
1380 "failed to get token for image download"
1381 );
1382 return None;
1383 }
1384 };
1385
1386 let url = self.image_download_url(image_key);
1387 let resp = match self
1388 .http_client()
1389 .get(&url)
1390 .header("Authorization", format!("Bearer {token}"))
1391 .send()
1392 .await
1393 {
1394 Ok(r) => r,
1395 Err(e) => {
1396 ::zeroclaw_log::record!(
1397 WARN,
1398 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1399 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
1400 .with_attrs(
1401 ::serde_json::json!({"error": format!("{}", e), "image_key": image_key})
1402 ),
1403 "image download request failed for"
1404 );
1405 return None;
1406 }
1407 };
1408
1409 if !resp.status().is_success() {
1410 ::zeroclaw_log::record!(
1411 WARN,
1412 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1413 .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
1414 &format!(
1415 "image download failed for {image_key}: status={}",
1416 resp.status()
1417 )
1418 );
1419 return None;
1420 }
1421
1422 if let Some(cl) = resp.content_length()
1423 && cl > LARK_IMAGE_MAX_BYTES as u64
1424 {
1425 ::zeroclaw_log::record!(
1426 WARN,
1427 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1428 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
1429 .with_attrs(::serde_json::json!({"image_key": image_key, "cl": cl})),
1430 "image too large for : bytes exceeds limit"
1431 );
1432 return None;
1433 }
1434
1435 let content_type = resp
1436 .headers()
1437 .get(reqwest::header::CONTENT_TYPE)
1438 .and_then(|v| v.to_str().ok())
1439 .map(str::to_string);
1440
1441 let bytes = match resp.bytes().await {
1442 Ok(b) => b,
1443 Err(e) => {
1444 ::zeroclaw_log::record!(
1445 WARN,
1446 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1447 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
1448 .with_attrs(
1449 ::serde_json::json!({"error": format!("{}", e), "image_key": image_key})
1450 ),
1451 "image body read failed for"
1452 );
1453 return None;
1454 }
1455 };
1456
1457 if bytes.is_empty() || bytes.len() > LARK_IMAGE_MAX_BYTES {
1458 ::zeroclaw_log::record!(
1459 WARN,
1460 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1461 .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
1462 &format!(
1463 "image body empty or too large for {image_key}: {} bytes",
1464 bytes.len()
1465 )
1466 );
1467 return None;
1468 }
1469
1470 let mime = lark_detect_image_mime(content_type.as_deref(), &bytes)?;
1471 if !LARK_SUPPORTED_IMAGE_MIMES.contains(&mime.as_str()) {
1472 ::zeroclaw_log::record!(
1473 WARN,
1474 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1475 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
1476 .with_attrs(::serde_json::json!({"image_key": image_key, "mime": mime})),
1477 "unsupported image MIME for"
1478 );
1479 return None;
1480 }
1481
1482 let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
1483 Some(format!("[IMAGE:data:{mime};base64,{encoded}]"))
1484 }
1485
1486 async fn download_file_as_content(
1489 &self,
1490 message_id: &str,
1491 file_key: &str,
1492 file_name: &str,
1493 ) -> Option<String> {
1494 let token = match self.get_tenant_access_token().await {
1495 Ok(t) => t,
1496 Err(e) => {
1497 ::zeroclaw_log::record!(
1498 WARN,
1499 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1500 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
1501 .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
1502 "failed to get token for file download"
1503 );
1504 return None;
1505 }
1506 };
1507
1508 let url = self.file_download_url(message_id, file_key);
1509 let resp = match self
1510 .http_client()
1511 .get(&url)
1512 .header("Authorization", format!("Bearer {token}"))
1513 .send()
1514 .await
1515 {
1516 Ok(r) => r,
1517 Err(e) => {
1518 ::zeroclaw_log::record!(
1519 WARN,
1520 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1521 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
1522 .with_attrs(
1523 ::serde_json::json!({"error": format!("{}", e), "file_key": file_key})
1524 ),
1525 "file download request failed for"
1526 );
1527 return None;
1528 }
1529 };
1530
1531 if !resp.status().is_success() {
1532 ::zeroclaw_log::record!(
1533 WARN,
1534 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1535 .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
1536 &format!(
1537 "file download failed for {file_key}: status={}",
1538 resp.status()
1539 )
1540 );
1541 return None;
1542 }
1543
1544 if let Some(cl) = resp.content_length()
1545 && cl > LARK_FILE_MAX_BYTES as u64
1546 {
1547 ::zeroclaw_log::record!(
1548 WARN,
1549 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1550 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
1551 .with_attrs(::serde_json::json!({"file_key": file_key, "cl": cl})),
1552 "file too large for : bytes exceeds limit"
1553 );
1554 return Some(format!(
1555 "[ATTACHMENT:{file_name} | size={cl} bytes | too large to inline]"
1556 ));
1557 }
1558
1559 let content_type = resp
1560 .headers()
1561 .get(reqwest::header::CONTENT_TYPE)
1562 .and_then(|v| v.to_str().ok())
1563 .unwrap_or("")
1564 .to_string();
1565
1566 let bytes = match resp.bytes().await {
1567 Ok(b) => b,
1568 Err(e) => {
1569 ::zeroclaw_log::record!(
1570 WARN,
1571 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1572 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
1573 .with_attrs(
1574 ::serde_json::json!({"error": format!("{}", e), "file_key": file_key})
1575 ),
1576 "file body read failed for"
1577 );
1578 return None;
1579 }
1580 };
1581
1582 if bytes.is_empty() {
1583 ::zeroclaw_log::record!(
1584 WARN,
1585 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1586 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
1587 .with_attrs(::serde_json::json!({"file_key": file_key})),
1588 "file body is empty for"
1589 );
1590 return None;
1591 }
1592
1593 if content_type.starts_with("image/")
1595 && bytes.len() <= LARK_IMAGE_MAX_BYTES
1596 && let Some(mime) = lark_detect_image_mime(Some(&content_type), &bytes)
1597 && LARK_SUPPORTED_IMAGE_MIMES.contains(&mime.as_str())
1598 {
1599 let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
1600 return Some(format!("[IMAGE:data:{mime};base64,{encoded}]"));
1601 }
1602
1603 if bytes.len() <= LARK_FILE_MAX_BYTES
1605 && !bytes.contains(&0)
1606 && (content_type.starts_with("text/")
1607 || content_type.contains("json")
1608 || content_type.contains("xml")
1609 || content_type.contains("yaml")
1610 || content_type.contains("javascript")
1611 || content_type.contains("csv")
1612 || lark_is_text_filename(file_name))
1613 {
1614 let text = String::from_utf8_lossy(&bytes);
1615 let truncated = if text.len() > 50_000 {
1616 format!("{}...\n[truncated]", &text[..50_000])
1617 } else {
1618 text.into_owned()
1619 };
1620 let ext = file_name.rsplit('.').next().unwrap_or("text");
1621 return Some(format!("[FILE:{file_name}]\n```{ext}\n{truncated}\n```"));
1622 }
1623
1624 Some(format!(
1625 "[ATTACHMENT:{file_name} | mime={content_type} | size={} bytes]",
1626 bytes.len()
1627 ))
1628 }
1629
1630 async fn fetch_bot_open_id_with_token(
1631 &self,
1632 token: &str,
1633 ) -> anyhow::Result<(reqwest::StatusCode, serde_json::Value)> {
1634 let resp = self
1635 .http_client()
1636 .get(self.bot_info_url())
1637 .header("Authorization", format!("Bearer {token}"))
1638 .send()
1639 .await?;
1640 let status = resp.status();
1641 let body = resp
1642 .json::<serde_json::Value>()
1643 .await
1644 .unwrap_or_else(|_| serde_json::json!({}));
1645 Ok((status, body))
1646 }
1647
1648 async fn refresh_bot_open_id(&self) -> anyhow::Result<Option<String>> {
1649 let token = self.get_tenant_access_token().await?;
1650 let (status, body) = self.fetch_bot_open_id_with_token(&token).await?;
1651
1652 let body = if should_refresh_lark_tenant_token(status, &body) {
1653 self.invalidate_token().await;
1654 let refreshed = self.get_tenant_access_token().await?;
1655 let (retry_status, retry_body) = self.fetch_bot_open_id_with_token(&refreshed).await?;
1656 if !retry_status.is_success() {
1657 anyhow::bail!(
1658 "bot info request failed after token refresh: status={retry_status}, body={retry_body}"
1659 );
1660 }
1661 retry_body
1662 } else {
1663 if !status.is_success() {
1664 anyhow::bail!("bot info request failed: status={status}, body={body}");
1665 }
1666 body
1667 };
1668
1669 let code = body.get("code").and_then(|c| c.as_i64()).unwrap_or(-1);
1670 if code != 0 {
1671 anyhow::bail!("bot info failed: code={code}, body={body}");
1672 }
1673
1674 let bot_open_id = body
1675 .pointer("/bot/open_id")
1676 .or_else(|| body.pointer("/data/bot/open_id"))
1677 .and_then(|v| v.as_str())
1678 .map(str::trim)
1679 .filter(|v| !v.is_empty())
1680 .map(str::to_owned);
1681
1682 self.set_resolved_bot_open_id(bot_open_id.clone());
1683 Ok(bot_open_id)
1684 }
1685
1686 async fn ensure_bot_open_id(&self) {
1687 if !self.mention_only || self.resolved_bot_open_id().is_some() {
1688 return;
1689 }
1690
1691 match self.refresh_bot_open_id().await {
1692 Ok(Some(open_id)) => {
1693 ::zeroclaw_log::record!(
1694 INFO,
1695 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1696 .with_attrs(::serde_json::json!({"open_id": open_id})),
1697 "resolved bot open_id"
1698 );
1699 }
1700 Ok(None) => {
1701 ::zeroclaw_log::record!(
1702 WARN,
1703 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1704 .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
1705 "bot open_id missing from /bot/v3/info response; mention_only group messages will be ignored"
1706 );
1707 }
1708 Err(err) => {
1709 ::zeroclaw_log::record!(
1710 WARN,
1711 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1712 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
1713 .with_attrs(::serde_json::json!({"err": err.to_string()})),
1714 "failed to resolve bot open_id: ; mention_only group messages will be ignored"
1715 );
1716 }
1717 }
1718 }
1719
1720 async fn stream_audio_bytes(mut resp: reqwest::Response) -> anyhow::Result<Vec<u8>> {
1721 let mut body = Vec::new();
1722 while let Some(chunk) = resp.chunk().await? {
1723 body.extend_from_slice(&chunk);
1724 if body.len() as u64 > MAX_LARK_AUDIO_BYTES {
1725 anyhow::bail!("audio download exceeds {} byte limit", MAX_LARK_AUDIO_BYTES);
1726 }
1727 }
1728 Ok(body)
1729 }
1730
1731 async fn download_audio_resource(
1732 &self,
1733 message_id: &str,
1734 file_key: &str,
1735 ) -> anyhow::Result<(Vec<u8>, String)> {
1736 let url = format!(
1737 "{}/im/v1/messages/{message_id}/resources/{file_key}?type=file",
1738 self.api_base()
1739 );
1740 let token = self.get_tenant_access_token().await?;
1741 let resp = self
1742 .http_client()
1743 .get(&url)
1744 .header("Authorization", format!("Bearer {token}"))
1745 .send()
1746 .await?;
1747
1748 let status = resp.status();
1749 if !status.is_success() {
1750 let body_text = resp.text().await.unwrap_or_default();
1751 let body: serde_json::Value =
1752 serde_json::from_str(&body_text).unwrap_or_else(|_| serde_json::json!({}));
1753
1754 if should_refresh_lark_tenant_token(status, &body) {
1755 self.invalidate_token().await;
1756 let token = self.get_tenant_access_token().await?;
1757 let resp = self
1758 .http_client()
1759 .get(&url)
1760 .header("Authorization", format!("Bearer {token}"))
1761 .send()
1762 .await?;
1763 if !resp.status().is_success() {
1764 anyhow::bail!(
1765 "audio download failed after token refresh: {}",
1766 resp.status()
1767 );
1768 }
1769 let bytes = Self::stream_audio_bytes(resp).await?;
1770 return Ok((bytes, inferred_audio_filename(file_key)));
1771 }
1772
1773 anyhow::bail!("audio download failed: {}", status);
1774 }
1775 let bytes = Self::stream_audio_bytes(resp).await?;
1776 Ok((bytes, inferred_audio_filename(file_key)))
1777 }
1778
1779 async fn try_transcribe_audio_message(
1780 &self,
1781 message_id: &str,
1782 content: &str,
1783 manager: &super::transcription::TranscriptionManager,
1784 ) -> Option<String> {
1785 let file_key = serde_json::from_str::<serde_json::Value>(content)
1786 .ok()
1787 .and_then(|v| {
1788 v.get("file_key")
1789 .and_then(|k| k.as_str())
1790 .map(str::to_owned)
1791 })?;
1792
1793 let (audio_data, filename) = match self.download_audio_resource(message_id, &file_key).await
1794 {
1795 Ok(result) => result,
1796 Err(e) => {
1797 ::zeroclaw_log::record!(
1798 WARN,
1799 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1800 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
1801 .with_attrs(
1802 ::serde_json::json!({"error": format!("{}", e), "message_id": message_id})
1803 ),
1804 "audio download failed for"
1805 );
1806 return None;
1807 }
1808 };
1809
1810 match manager.transcribe(&audio_data, &filename).await {
1811 Ok(transcript) => {
1812 ::zeroclaw_log::record!(
1813 DEBUG,
1814 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1815 .with_attrs(::serde_json::json!({"message_id": message_id})),
1816 "audio transcribed for"
1817 );
1818 Some(transcript)
1819 }
1820 Err(e) => {
1821 ::zeroclaw_log::record!(
1822 WARN,
1823 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1824 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
1825 .with_attrs(
1826 ::serde_json::json!({"error": format!("{}", e), "message_id": message_id})
1827 ),
1828 "transcription failed for"
1829 );
1830 None
1831 }
1832 }
1833 }
1834
1835 pub async fn parse_event_payload_async(
1836 &self,
1837 payload: &serde_json::Value,
1838 ) -> Vec<ChannelMessage> {
1839 let event_type = payload
1840 .pointer("/header/event_type")
1841 .and_then(|e| e.as_str())
1842 .unwrap_or("");
1843 if event_type != "im.message.receive_v1" {
1844 return vec![];
1845 }
1846
1847 let msg_type = payload
1848 .pointer("/event/message/message_type")
1849 .and_then(|t| t.as_str())
1850 .unwrap_or("");
1851
1852 if msg_type != "audio" {
1853 return self.parse_event_payload(payload).await;
1854 }
1855
1856 let Some(manager) = self.transcription_manager.as_deref() else {
1857 ::zeroclaw_log::record!(
1858 DEBUG,
1859 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
1860 "webhook: audio message (transcription not configured)"
1861 );
1862 return vec![];
1863 };
1864
1865 let open_id = payload
1866 .pointer("/event/sender/sender_id/open_id")
1867 .and_then(|v| v.as_str())
1868 .unwrap_or("");
1869 if !self.is_user_allowed(open_id) {
1870 ::zeroclaw_log::record!(
1871 WARN,
1872 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1873 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
1874 .with_attrs(::serde_json::json!({"open_id": open_id})),
1875 "ignoring audio from unauthorized user"
1876 );
1877 return vec![];
1878 }
1879
1880 let message_id = payload
1881 .pointer("/event/message/message_id")
1882 .and_then(|v| v.as_str())
1883 .unwrap_or("");
1884 let content = payload
1885 .pointer("/event/message/content")
1886 .and_then(|v| v.as_str())
1887 .unwrap_or("");
1888 let chat_id = payload
1889 .pointer("/event/message/chat_id")
1890 .and_then(|v| v.as_str())
1891 .unwrap_or(open_id);
1892
1893 let chat_type = payload
1894 .pointer("/event/message/chat_type")
1895 .and_then(|v| v.as_str())
1896 .unwrap_or("");
1897 let mentions = payload
1898 .pointer("/event/message/mentions")
1899 .and_then(|v| v.as_array())
1900 .cloned()
1901 .unwrap_or_default();
1902 let bot_open_id = self.resolved_bot_open_id();
1903 if chat_type == "group"
1904 && !should_respond_in_group(
1905 self.mention_only,
1906 bot_open_id.as_deref(),
1907 &mentions,
1908 &Vec::new(),
1909 )
1910 {
1911 return vec![];
1912 }
1913
1914 let Some(text) = self
1915 .try_transcribe_audio_message(message_id, content, manager)
1916 .await
1917 else {
1918 return vec![];
1919 };
1920
1921 let timestamp = payload
1922 .pointer("/event/message/create_time")
1923 .and_then(|t| t.as_str())
1924 .and_then(|t| t.parse::<u64>().ok())
1925 .map(|ms| ms / 1000)
1926 .unwrap_or_else(|| {
1927 std::time::SystemTime::now()
1928 .duration_since(std::time::UNIX_EPOCH)
1929 .unwrap_or_default()
1930 .as_secs()
1931 });
1932
1933 vec![ChannelMessage {
1934 id: Uuid::new_v4().to_string(),
1935 sender: chat_id.to_string(),
1936 reply_target: chat_id.to_string(),
1937 content: text,
1938 channel: self.channel_name().to_string(),
1939 channel_alias: Some(self.alias.clone()),
1940 timestamp,
1941 thread_ts: None,
1942 interruption_scope_id: None,
1943 attachments: vec![],
1944 subject: None,
1945 }]
1946 }
1947
1948 async fn send_text_once(
1949 &self,
1950 url: &str,
1951 token: &str,
1952 body: &serde_json::Value,
1953 ) -> anyhow::Result<(reqwest::StatusCode, serde_json::Value)> {
1954 let resp = self
1955 .http_client()
1956 .post(url)
1957 .header("Authorization", format!("Bearer {token}"))
1958 .header("Content-Type", "application/json; charset=utf-8")
1959 .json(body)
1960 .send()
1961 .await?;
1962 let status = resp.status();
1963 let raw = resp.text().await.unwrap_or_default();
1964 let parsed = serde_json::from_str::<serde_json::Value>(&raw)
1965 .unwrap_or_else(|_| serde_json::json!({ "raw": raw }));
1966 Ok((status, parsed))
1967 }
1968
1969 pub async fn parse_event_payload(&self, payload: &serde_json::Value) -> Vec<ChannelMessage> {
1972 let mut messages = Vec::new();
1973
1974 let event_type = payload
1977 .pointer("/header/event_type")
1978 .and_then(|e| e.as_str())
1979 .unwrap_or("");
1980
1981 if event_type != "im.message.receive_v1" {
1982 return messages;
1983 }
1984
1985 let event = match payload.get("event") {
1986 Some(e) => e,
1987 None => return messages,
1988 };
1989
1990 let open_id = event
1992 .pointer("/sender/sender_id/open_id")
1993 .and_then(|s| s.as_str())
1994 .unwrap_or("");
1995
1996 if open_id.is_empty() {
1997 return messages;
1998 }
1999
2000 if !self.is_user_allowed(open_id) {
2002 ::zeroclaw_log::record!(
2003 WARN,
2004 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2005 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
2006 .with_attrs(::serde_json::json!({"open_id": open_id})),
2007 "ignoring message from unauthorized user"
2008 );
2009 return messages;
2010 }
2011
2012 let msg_type = event
2014 .pointer("/message/message_type")
2015 .and_then(|t| t.as_str())
2016 .unwrap_or("");
2017
2018 let chat_type = event
2019 .pointer("/message/chat_type")
2020 .and_then(|c| c.as_str())
2021 .unwrap_or("");
2022
2023 let mentions = event
2024 .pointer("/message/mentions")
2025 .and_then(|m| m.as_array())
2026 .cloned()
2027 .unwrap_or_default();
2028
2029 let content_str = event
2030 .pointer("/message/content")
2031 .and_then(|c| c.as_str())
2032 .unwrap_or("");
2033
2034 let evt_message_id = event
2035 .pointer("/message/message_id")
2036 .and_then(|m| m.as_str())
2037 .unwrap_or("");
2038
2039 let (text, post_mentioned_open_ids): (String, Vec<String>) = match msg_type {
2040 "text" => {
2041 let extracted = serde_json::from_str::<serde_json::Value>(content_str)
2042 .ok()
2043 .and_then(|v| {
2044 v.get("text")
2045 .and_then(|t| t.as_str())
2046 .filter(|s| !s.is_empty())
2047 .map(String::from)
2048 });
2049 match extracted {
2050 Some(t) => (t, Vec::new()),
2051 None => return messages,
2052 }
2053 }
2054 "post" => match parse_post_content_details(content_str) {
2055 Some(details) => (details.text, details.mentioned_open_ids),
2056 None => return messages,
2057 },
2058 "image" => {
2059 let image_key = serde_json::from_str::<serde_json::Value>(content_str)
2060 .ok()
2061 .and_then(|v| {
2062 v.get("image_key")
2063 .and_then(|k| k.as_str())
2064 .map(String::from)
2065 });
2066 match image_key {
2067 Some(key) => {
2068 let marker = match self.download_image_as_marker(&key).await {
2069 Some(m) => m,
2070 None => {
2071 ::zeroclaw_log::record!(
2072 WARN,
2073 ::zeroclaw_log::Event::new(
2074 module_path!(),
2075 ::zeroclaw_log::Action::Note
2076 )
2077 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
2078 .with_attrs(::serde_json::json!({"key": key})),
2079 "failed to download image"
2080 );
2081 format!("[IMAGE:{key} | download failed]")
2082 }
2083 };
2084 (marker, Vec::new())
2085 }
2086 None => {
2087 ::zeroclaw_log::record!(
2088 DEBUG,
2089 ::zeroclaw_log::Event::new(
2090 module_path!(),
2091 ::zeroclaw_log::Action::Note
2092 ),
2093 "image message missing image_key"
2094 );
2095 return messages;
2096 }
2097 }
2098 }
2099 "file" => {
2100 let parsed = serde_json::from_str::<serde_json::Value>(content_str).ok();
2101 let file_key = parsed
2102 .as_ref()
2103 .and_then(|v| v.get("file_key").and_then(|k| k.as_str()))
2104 .map(String::from);
2105 let file_name = parsed
2106 .as_ref()
2107 .and_then(|v| v.get("file_name").and_then(|n| n.as_str()))
2108 .unwrap_or("unknown_file")
2109 .to_string();
2110 match file_key {
2111 Some(key) => {
2112 let content = match self
2113 .download_file_as_content(evt_message_id, &key, &file_name)
2114 .await
2115 {
2116 Some(c) => c,
2117 None => {
2118 ::zeroclaw_log::record!(
2119 WARN,
2120 ::zeroclaw_log::Event::new(
2121 module_path!(),
2122 ::zeroclaw_log::Action::Note
2123 )
2124 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
2125 .with_attrs(::serde_json::json!({"key": key})),
2126 "failed to download file"
2127 );
2128 format!("[ATTACHMENT:{file_name} | download failed]")
2129 }
2130 };
2131 (content, Vec::new())
2132 }
2133 None => {
2134 ::zeroclaw_log::record!(
2135 DEBUG,
2136 ::zeroclaw_log::Event::new(
2137 module_path!(),
2138 ::zeroclaw_log::Action::Note
2139 ),
2140 "file message missing file_key"
2141 );
2142 return messages;
2143 }
2144 }
2145 }
2146 "list" => match parse_list_content(content_str) {
2147 Some(t) => (t, Vec::new()),
2148 None => {
2149 ::zeroclaw_log::record!(
2150 DEBUG,
2151 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
2152 "list message with no extractable text"
2153 );
2154 return messages;
2155 }
2156 },
2157 _ => {
2158 ::zeroclaw_log::record!(
2159 DEBUG,
2160 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2161 .with_attrs(::serde_json::json!({"msg_type": msg_type})),
2162 "skipping unsupported message type"
2163 );
2164 return messages;
2165 }
2166 };
2167
2168 let bot_open_id = self.resolved_bot_open_id();
2169 if chat_type == "group"
2170 && !should_respond_in_group(
2171 self.mention_only,
2172 bot_open_id.as_deref(),
2173 &mentions,
2174 &post_mentioned_open_ids,
2175 )
2176 {
2177 return messages;
2178 }
2179
2180 let timestamp = event
2181 .pointer("/message/create_time")
2182 .and_then(|t| t.as_str())
2183 .and_then(|t| t.parse::<u64>().ok())
2184 .map(|ms| ms / 1000)
2186 .unwrap_or_else(|| {
2187 std::time::SystemTime::now()
2188 .duration_since(std::time::UNIX_EPOCH)
2189 .unwrap_or_default()
2190 .as_secs()
2191 });
2192
2193 let chat_id = event
2194 .pointer("/message/chat_id")
2195 .and_then(|c| c.as_str())
2196 .unwrap_or(open_id);
2197
2198 messages.push(ChannelMessage {
2199 id: Uuid::new_v4().to_string(),
2200 sender: chat_id.to_string(),
2201 reply_target: chat_id.to_string(),
2202 content: text,
2203 channel: self.channel_name().to_string(),
2204 channel_alias: Some(self.alias.clone()),
2205 timestamp,
2206 thread_ts: None,
2207 interruption_scope_id: None,
2208 attachments: vec![],
2209 subject: None,
2210 });
2211
2212 messages
2213 }
2214}
2215
2216impl ::zeroclaw_api::attribution::Attributable for LarkChannel {
2217 fn role(&self) -> ::zeroclaw_api::attribution::Role {
2218 ::zeroclaw_api::attribution::Role::Channel(::zeroclaw_api::attribution::ChannelKind::Lark)
2219 }
2220 fn alias(&self) -> &str {
2221 &self.alias
2222 }
2223}
2224
2225#[async_trait]
2226impl Channel for LarkChannel {
2227 fn name(&self) -> &str {
2228 self.channel_name()
2229 }
2230
2231 async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
2232 let token = self.get_tenant_access_token().await?;
2233 let url = self.send_message_url();
2234
2235 let chunks = split_markdown_chunks(&message.content, LARK_CARD_MARKDOWN_MAX_BYTES);
2236 for chunk in &chunks {
2237 let body = build_interactive_card_body(&message.recipient, chunk);
2238
2239 let (status, response) = self.send_text_once(&url, &token, &body).await?;
2240
2241 if should_refresh_lark_tenant_token(status, &response) {
2242 self.invalidate_token().await;
2244 let new_token = self.get_tenant_access_token().await?;
2245 let (retry_status, retry_response) =
2246 self.send_text_once(&url, &new_token, &body).await?;
2247
2248 if should_refresh_lark_tenant_token(retry_status, &retry_response) {
2249 anyhow::bail!(
2250 "send failed after token refresh: status={retry_status}, body={retry_response}"
2251 );
2252 }
2253
2254 ensure_lark_send_success(retry_status, &retry_response, "after token refresh")?;
2255 } else {
2256 ensure_lark_send_success(status, &response, "without token refresh")?;
2257 }
2258 }
2259
2260 Ok(())
2261 }
2262
2263 async fn listen(&self, tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> anyhow::Result<()> {
2264 use zeroclaw_config::schema::LarkReceiveMode;
2265 match self.receive_mode {
2266 LarkReceiveMode::Websocket => self.listen_ws(tx).await,
2267 LarkReceiveMode::Webhook => self.listen_http(tx).await,
2268 }
2269 }
2270
2271 async fn health_check(&self) -> bool {
2272 self.get_tenant_access_token().await.is_ok()
2273 }
2274
2275 async fn request_approval(
2276 &self,
2277 recipient: &str,
2278 request: &zeroclaw_api::channel::ChannelApprovalRequest,
2279 ) -> anyhow::Result<Option<zeroclaw_api::channel::ChannelApprovalResponse>> {
2280 let approval_id = Uuid::new_v4().to_string();
2281 let card =
2282 build_approval_card(&approval_id, &request.tool_name, &request.arguments_summary);
2283
2284 let token = self.get_tenant_access_token().await?;
2285 let url = self.send_message_url();
2286 let body = serde_json::json!({
2287 "receive_id": recipient,
2288 "receive_id_type": "chat_id",
2289 "msg_type": "interactive",
2290 "content": serde_json::to_string(&card)?,
2291 });
2292
2293 let response_body = {
2294 let (status, resp) = self.send_text_once(&url, &token, &body).await?;
2295 if should_refresh_lark_tenant_token(status, &resp) {
2296 self.invalidate_token().await;
2297 let new_token = self.get_tenant_access_token().await?;
2298 let (retry_status, retry_body) =
2299 self.send_text_once(&url, &new_token, &body).await?;
2300 ensure_lark_send_success(retry_status, &retry_body, "approval retry")?;
2301 retry_body
2302 } else {
2303 ensure_lark_send_success(status, &resp, "approval")?;
2304 resp
2305 }
2306 };
2307
2308 let message_id = response_body
2309 .pointer("/data/message_id")
2310 .and_then(|v| v.as_str())
2311 .map(str::to_string)
2312 .unwrap_or_else(|| {
2313 ::zeroclaw_log::record!(
2314 WARN,
2315 ::zeroclaw_log::Event::new(
2316 module_path!(),
2317 ::zeroclaw_log::Action::Note
2318 )
2319 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
2320 .with_attrs(::serde_json::json!({"approval_id": approval_id})),
2321 "Lark: approval card sent but no data.message_id in response — post-click card update will be skipped"
2322 );
2323 String::new()
2324 });
2325
2326 let (tx, rx) = tokio::sync::oneshot::channel();
2327 self.pending_approvals.lock().await.insert(
2328 approval_id.clone(),
2329 PendingApproval {
2330 sender: tx,
2331 message_id,
2332 tool_name: request.tool_name.clone(),
2333 arguments_summary: request.arguments_summary.clone(),
2334 },
2335 );
2336
2337 Ok(Some(self.wait_for_decision(rx, &approval_id).await))
2338 }
2339}
2340
2341impl LarkChannel {
2342 async fn wait_for_decision(
2345 &self,
2346 rx: tokio::sync::oneshot::Receiver<zeroclaw_api::channel::ChannelApprovalResponse>,
2347 approval_id: &str,
2348 ) -> zeroclaw_api::channel::ChannelApprovalResponse {
2349 use zeroclaw_api::channel::ChannelApprovalResponse;
2350 match tokio::time::timeout(Duration::from_secs(self.approval_timeout_secs), rx).await {
2351 Ok(Ok(response)) => response,
2352 _ => {
2353 self.pending_approvals.lock().await.remove(approval_id);
2354 ChannelApprovalResponse::Deny
2355 }
2356 }
2357 }
2358
2359 async fn patch_approval_card_resolved(
2364 &self,
2365 message_id: &str,
2366 tool_name: &str,
2367 arguments_summary: &str,
2368 decision: zeroclaw_api::channel::ChannelApprovalResponse,
2369 ) {
2370 let card = build_resolved_approval_card(tool_name, arguments_summary, decision);
2371 let url = self.patch_message_url(message_id);
2372 let body = serde_json::json!({
2373 "content": card.to_string(),
2374 });
2375
2376 ::zeroclaw_log::record!(
2377 INFO,
2378 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Send)
2379 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
2380 .with_attrs(::serde_json::json!({
2381 "message_id": message_id,
2382 "decision": format!("{decision:?}"),
2383 })),
2384 "Lark: approval card PATCH dispatching"
2385 );
2386
2387 let (status, response) = match self.patch_or_send_once(&url, &body, true).await {
2388 Ok(pair) => pair,
2389 Err(e) => {
2390 ::zeroclaw_log::record!(
2391 WARN,
2392 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Send)
2393 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
2394 .with_attrs(::serde_json::json!({
2395 "message_id": message_id,
2396 "error": e.to_string(),
2397 })),
2398 "Lark: approval card PATCH transport error"
2399 );
2400 return;
2401 }
2402 };
2403
2404 let final_body = if should_refresh_lark_tenant_token(status, &response) {
2405 self.invalidate_token().await;
2406 match self.patch_or_send_once(&url, &body, true).await {
2407 Ok((retry_status, retry_response)) => {
2408 if should_refresh_lark_tenant_token(retry_status, &retry_response) {
2409 ::zeroclaw_log::record!(
2410 WARN,
2411 ::zeroclaw_log::Event::new(
2412 module_path!(),
2413 ::zeroclaw_log::Action::Send
2414 )
2415 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
2416 .with_attrs(::serde_json::json!({
2417 "message_id": message_id,
2418 "body": retry_response.to_string(),
2419 })),
2420 "Lark: approval card PATCH still unauthorized after token refresh"
2421 );
2422 return;
2423 }
2424 retry_response
2425 }
2426 Err(e) => {
2427 ::zeroclaw_log::record!(
2428 WARN,
2429 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Send)
2430 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
2431 .with_attrs(::serde_json::json!({
2432 "message_id": message_id,
2433 "error": e.to_string(),
2434 })),
2435 "Lark: approval card PATCH retry transport error"
2436 );
2437 return;
2438 }
2439 }
2440 } else {
2441 response
2442 };
2443
2444 let code = extract_lark_response_code(&final_body).unwrap_or(0);
2445 if code == LARK_DRAFT_RATE_LIMIT_CODE {
2446 ::zeroclaw_log::record!(
2447 WARN,
2448 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Send)
2449 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
2450 .with_attrs(::serde_json::json!({
2451 "message_id": message_id,
2452 "code": LARK_DRAFT_RATE_LIMIT_CODE,
2453 })),
2454 "Lark: approval card PATCH rate-limited"
2455 );
2456 } else if code != 0 {
2457 ::zeroclaw_log::record!(
2458 WARN,
2459 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Send)
2460 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
2461 .with_attrs(::serde_json::json!({
2462 "message_id": message_id,
2463 "code": code,
2464 "status": status.to_string(),
2465 "body": final_body.to_string(),
2466 })),
2467 "Lark: approval card PATCH soft-failed"
2468 );
2469 } else {
2470 ::zeroclaw_log::record!(
2471 INFO,
2472 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Send)
2473 .with_outcome(::zeroclaw_log::EventOutcome::Success)
2474 .with_attrs(::serde_json::json!({
2475 "message_id": message_id,
2476 "status": status.to_string(),
2477 })),
2478 "Lark: approval card PATCH succeeded"
2479 );
2480 }
2481 }
2482
2483 async fn patch_or_send_once(
2488 &self,
2489 url: &str,
2490 body: &serde_json::Value,
2491 is_patch: bool,
2492 ) -> anyhow::Result<(reqwest::StatusCode, serde_json::Value)> {
2493 let token = self.get_tenant_access_token().await?;
2494 let builder = if is_patch {
2495 self.http_client().patch(url)
2496 } else {
2497 self.http_client().post(url)
2498 };
2499 let resp = builder
2500 .header("Authorization", format!("Bearer {token}"))
2501 .header("Content-Type", "application/json; charset=utf-8")
2502 .json(body)
2503 .send()
2504 .await?;
2505 let status = resp.status();
2506 let raw = resp.text().await.unwrap_or_default();
2507 let parsed = serde_json::from_str::<serde_json::Value>(&raw)
2508 .unwrap_or_else(|_| serde_json::json!({ "raw": raw }));
2509 Ok((status, parsed))
2510 }
2511
2512 async fn handle_card_action_event(
2518 &self,
2519 event_payload: &serde_json::Value,
2520 ) -> anyhow::Result<()> {
2521 use zeroclaw_api::channel::ChannelApprovalResponse;
2522
2523 ::zeroclaw_log::record!(
2544 DEBUG,
2545 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Receive).with_attrs(
2546 ::serde_json::json!({
2547 "sanitized_payload": sanitize_card_action_payload(event_payload),
2548 })
2549 ),
2550 "card.action.trigger sanitized payload"
2551 );
2552
2553 let value = event_payload
2559 .pointer("/action/value")
2560 .or_else(|| event_payload.pointer("/action/behaviors/0/value"))
2561 .ok_or_else(|| {
2562 ::zeroclaw_log::record!(
2563 WARN,
2564 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
2565 .with_outcome(::zeroclaw_log::EventOutcome::Failure),
2566 "card.action.trigger: missing event.action.value or event.action.behaviors[0].value"
2567 );
2568 anyhow::Error::msg(
2569 "card.action.trigger: missing event.action.value or event.action.behaviors[0].value",
2570 )
2571 })?;
2572
2573 let approval_id = value
2574 .get("approval_id")
2575 .and_then(|v| v.as_str())
2576 .ok_or_else(|| {
2577 ::zeroclaw_log::record!(
2578 WARN,
2579 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
2580 .with_outcome(::zeroclaw_log::EventOutcome::Failure),
2581 "card.action.trigger: missing approval_id in value"
2582 );
2583 anyhow::Error::msg("card.action.trigger: missing approval_id in value")
2584 })?;
2585
2586 let decision_str = value
2587 .get("decision")
2588 .and_then(|v| v.as_str())
2589 .ok_or_else(|| {
2590 ::zeroclaw_log::record!(
2591 WARN,
2592 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
2593 .with_outcome(::zeroclaw_log::EventOutcome::Failure),
2594 "card.action.trigger: missing decision in value"
2595 );
2596 anyhow::Error::msg("card.action.trigger: missing decision in value")
2597 })?;
2598
2599 let decision = match decision_str {
2600 "approve" => ChannelApprovalResponse::Approve,
2601 "deny" => ChannelApprovalResponse::Deny,
2602 "always" => ChannelApprovalResponse::AlwaysApprove,
2603 other => {
2604 ::zeroclaw_log::record!(
2605 WARN,
2606 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2607 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
2608 .with_attrs(::serde_json::json!({"decision_str": other})),
2609 "Lark: unknown approval decision — treating as deny"
2610 );
2611 ChannelApprovalResponse::Deny
2612 }
2613 };
2614
2615 let pending = self.pending_approvals.lock().await.remove(approval_id);
2616 let Some(pending) = pending else {
2617 ::zeroclaw_log::record!(
2618 INFO,
2619 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2620 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
2621 .with_attrs(::serde_json::json!({
2622 "approval_id": approval_id,
2623 "decision": format!("{decision:?}"),
2624 })),
2625 "Lark: card action for unknown/expired approval_id"
2626 );
2627 return Ok(());
2628 };
2629
2630 ::zeroclaw_log::record!(
2631 INFO,
2632 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Receive)
2633 .with_outcome(::zeroclaw_log::EventOutcome::Success)
2634 .with_attrs(::serde_json::json!({
2635 "approval_id": approval_id,
2636 "decision": format!("{decision:?}"),
2637 "message_id": pending.message_id,
2638 "has_message_id": !pending.message_id.is_empty(),
2639 })),
2640 "Lark: card action received"
2641 );
2642
2643 let _ = pending.sender.send(decision);
2644
2645 if !pending.message_id.is_empty() {
2646 self.patch_approval_card_resolved(
2647 &pending.message_id,
2648 &pending.tool_name,
2649 &pending.arguments_summary,
2650 decision,
2651 )
2652 .await;
2653 }
2654
2655 Ok(())
2656 }
2657}
2658
2659impl LarkChannel {
2660 pub async fn listen_http(
2663 &self,
2664 tx: tokio::sync::mpsc::Sender<ChannelMessage>,
2665 ) -> anyhow::Result<()> {
2666 self.ensure_bot_open_id().await;
2667 use axum::{Json, Router, extract::State, routing::post};
2668
2669 #[derive(Clone)]
2670 struct AppState {
2671 verification_token: String,
2672 channel: Arc<LarkChannel>,
2673 tx: tokio::sync::mpsc::Sender<ChannelMessage>,
2674 }
2675
2676 async fn handle_event(
2677 State(state): State<AppState>,
2678 Json(payload): Json<serde_json::Value>,
2679 ) -> axum::response::Response {
2680 use axum::http::StatusCode;
2681 use axum::response::IntoResponse;
2682
2683 if let Some(challenge) = payload.get("challenge").and_then(|c| c.as_str()) {
2685 let token_ok = payload
2687 .get("token")
2688 .and_then(|t| t.as_str())
2689 .is_none_or(|t| t == state.verification_token);
2690
2691 if !token_ok {
2692 return (StatusCode::FORBIDDEN, "invalid token").into_response();
2693 }
2694
2695 let resp = serde_json::json!({ "challenge": challenge });
2696 return (StatusCode::OK, Json(resp)).into_response();
2697 }
2698
2699 let event_type = payload
2703 .pointer("/header/event_type")
2704 .and_then(|v| v.as_str())
2705 .unwrap_or("");
2706 if event_type == "card.action.trigger"
2707 && let Some(inner) = payload.get("event")
2708 {
2709 if let Err(e) = state.channel.handle_card_action_event(inner).await {
2710 ::zeroclaw_log::record!(
2711 WARN,
2712 ::zeroclaw_log::Event::new(
2713 module_path!(),
2714 ::zeroclaw_log::Action::Dispatch
2715 )
2716 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
2717 .with_attrs(::serde_json::json!({"error": e.to_string()})),
2718 "Lark webhook: card action dispatch error"
2719 );
2720 }
2721 return (StatusCode::OK, "ok").into_response();
2722 }
2723
2724 let messages = state.channel.parse_event_payload_async(&payload).await;
2726 if !messages.is_empty()
2727 && let Some(message_id) = payload
2728 .pointer("/event/message/message_id")
2729 .and_then(|m| m.as_str())
2730 {
2731 let ack_text = messages.first().map_or("", |msg| msg.content.as_str());
2732 let ack_emoji =
2733 random_lark_ack_reaction(payload.get("event"), ack_text).to_string();
2734 let reaction_channel = Arc::clone(&state.channel);
2735 let reaction_message_id = message_id.to_string();
2736 tokio::spawn(async move {
2737 reaction_channel
2738 .try_add_ack_reaction(&reaction_message_id, &ack_emoji)
2739 .await;
2740 });
2741 }
2742
2743 for msg in messages {
2744 if state.tx.send(msg).await.is_err() {
2745 ::zeroclaw_log::record!(
2746 WARN,
2747 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2748 .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
2749 "message channel closed"
2750 );
2751 break;
2752 }
2753 }
2754
2755 (StatusCode::OK, "ok").into_response()
2756 }
2757
2758 let port = self.port.ok_or_else(|| {
2759 ::zeroclaw_log::record!(
2760 ERROR,
2761 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
2762 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
2763 .with_attrs(::serde_json::json!({"mode": "webhook", "missing": "port"})),
2764 "lark: webhook mode requires port"
2765 );
2766 anyhow::Error::msg("webhook mode requires `port` to be set in [channels_config.lark]")
2767 })?;
2768
2769 let state = AppState {
2770 verification_token: self.verification_token.clone(),
2771 channel: Arc::new(self.clone()),
2772 tx,
2773 };
2774
2775 let app = Router::new()
2776 .route("/lark", post(handle_event))
2777 .with_state(state);
2778
2779 let addr = std::net::SocketAddr::from(([0, 0, 0, 0], port));
2780 ::zeroclaw_log::record!(
2781 INFO,
2782 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2783 .with_attrs(::serde_json::json!({"addr": addr})),
2784 "event callback server listening on"
2785 );
2786
2787 let listener = tokio::net::TcpListener::bind(addr).await?;
2788 axum::serve(listener, app).await?;
2789
2790 Ok(())
2791 }
2792}
2793
2794fn inferred_audio_filename(file_key: &str) -> String {
2799 const SUPPORTED_EXTENSIONS: &[&str] = &[".m4a", ".ogg", ".mp3", ".aac", ".wav"];
2800 let file_key_lower = file_key.to_lowercase();
2801 if SUPPORTED_EXTENSIONS
2802 .iter()
2803 .any(|ext| file_key_lower.ends_with(ext))
2804 {
2805 file_key.to_string()
2806 } else {
2807 "voice.m4a".to_string()
2808 }
2809}
2810
2811fn pick_uniform_index(len: usize) -> usize {
2812 debug_assert!(len > 0);
2813 let upper = len as u64;
2814 let reject_threshold = (u64::MAX / upper) * upper;
2815
2816 loop {
2817 let value = rand::random::<u64>();
2818 if value < reject_threshold {
2819 #[allow(clippy::cast_possible_truncation)]
2820 return (value % upper) as usize;
2821 }
2822 }
2823}
2824
2825fn random_from_pool(pool: &'static [&'static str]) -> &'static str {
2826 pool[pick_uniform_index(pool.len())]
2827}
2828
2829fn lark_ack_pool(locale: LarkAckLocale) -> &'static [&'static str] {
2830 match locale {
2831 LarkAckLocale::ZhCn => LARK_ACK_REACTIONS_ZH_CN,
2832 LarkAckLocale::ZhTw => LARK_ACK_REACTIONS_ZH_TW,
2833 LarkAckLocale::En => LARK_ACK_REACTIONS_EN,
2834 LarkAckLocale::Ja => LARK_ACK_REACTIONS_JA,
2835 }
2836}
2837
2838fn map_locale_tag(tag: &str) -> Option<LarkAckLocale> {
2839 let normalized = tag.trim().to_ascii_lowercase().replace('-', "_");
2840 if normalized.is_empty() {
2841 return None;
2842 }
2843
2844 if normalized.starts_with("ja") {
2845 return Some(LarkAckLocale::Ja);
2846 }
2847 if normalized.starts_with("en") {
2848 return Some(LarkAckLocale::En);
2849 }
2850 if normalized.contains("hant")
2851 || normalized.starts_with("zh_tw")
2852 || normalized.starts_with("zh_hk")
2853 || normalized.starts_with("zh_mo")
2854 {
2855 return Some(LarkAckLocale::ZhTw);
2856 }
2857 if normalized.starts_with("zh") {
2858 return Some(LarkAckLocale::ZhCn);
2859 }
2860 None
2861}
2862
2863fn find_locale_hint(value: &serde_json::Value) -> Option<String> {
2864 match value {
2865 serde_json::Value::Object(map) => {
2866 for key in [
2867 "locale",
2868 "language",
2869 "lang",
2870 "i18n_locale",
2871 "user_locale",
2872 "locale_id",
2873 ] {
2874 if let Some(locale) = map.get(key).and_then(serde_json::Value::as_str) {
2875 return Some(locale.to_string());
2876 }
2877 }
2878
2879 for child in map.values() {
2880 if let Some(locale) = find_locale_hint(child) {
2881 return Some(locale);
2882 }
2883 }
2884 None
2885 }
2886 serde_json::Value::Array(items) => {
2887 for child in items {
2888 if let Some(locale) = find_locale_hint(child) {
2889 return Some(locale);
2890 }
2891 }
2892 None
2893 }
2894 _ => None,
2895 }
2896}
2897
2898fn detect_locale_from_post_content(content: &str) -> Option<LarkAckLocale> {
2899 let parsed = serde_json::from_str::<serde_json::Value>(content).ok()?;
2900 let obj = parsed.as_object()?;
2901 for key in obj.keys() {
2902 if let Some(locale) = map_locale_tag(key) {
2903 return Some(locale);
2904 }
2905 }
2906 None
2907}
2908
2909fn is_japanese_kana(ch: char) -> bool {
2910 matches!(
2911 ch as u32,
2912 0x3040..=0x309F | 0x30A0..=0x30FF | 0x31F0..=0x31FF )
2916}
2917
2918fn is_cjk_han(ch: char) -> bool {
2919 matches!(
2920 ch as u32,
2921 0x3400..=0x4DBF | 0x4E00..=0x9FFF )
2924}
2925
2926fn is_traditional_only_han(ch: char) -> bool {
2927 matches!(
2928 ch,
2929 '奮' | '鬥'
2930 | '強'
2931 | '體'
2932 | '國'
2933 | '臺'
2934 | '萬'
2935 | '與'
2936 | '為'
2937 | '這'
2938 | '學'
2939 | '機'
2940 | '開'
2941 | '裡'
2942 )
2943}
2944
2945fn is_simplified_only_han(ch: char) -> bool {
2946 matches!(
2947 ch,
2948 '奋' | '斗'
2949 | '强'
2950 | '体'
2951 | '国'
2952 | '台'
2953 | '万'
2954 | '与'
2955 | '为'
2956 | '这'
2957 | '学'
2958 | '机'
2959 | '开'
2960 | '里'
2961 )
2962}
2963
2964fn detect_locale_from_text(text: &str) -> Option<LarkAckLocale> {
2965 if text.chars().any(is_japanese_kana) {
2966 return Some(LarkAckLocale::Ja);
2967 }
2968 if text.chars().any(is_traditional_only_han) {
2969 return Some(LarkAckLocale::ZhTw);
2970 }
2971 if text.chars().any(is_simplified_only_han) {
2972 return Some(LarkAckLocale::ZhCn);
2973 }
2974 if text.chars().any(is_cjk_han) {
2975 return Some(LarkAckLocale::ZhCn);
2976 }
2977 None
2978}
2979
2980fn detect_lark_ack_locale(
2981 payload: Option<&serde_json::Value>,
2982 fallback_text: &str,
2983) -> LarkAckLocale {
2984 if let Some(payload) = payload {
2985 if let Some(locale) = find_locale_hint(payload).and_then(|hint| map_locale_tag(&hint)) {
2986 return locale;
2987 }
2988
2989 let message_content = payload
2990 .pointer("/message/content")
2991 .and_then(serde_json::Value::as_str)
2992 .or_else(|| {
2993 payload
2994 .pointer("/event/message/content")
2995 .and_then(serde_json::Value::as_str)
2996 });
2997
2998 if let Some(locale) = message_content.and_then(detect_locale_from_post_content) {
2999 return locale;
3000 }
3001 }
3002
3003 detect_locale_from_text(fallback_text).unwrap_or(LarkAckLocale::En)
3004}
3005
3006fn lark_detect_image_mime(content_type: Option<&str>, bytes: &[u8]) -> Option<String> {
3008 if bytes.len() >= 8 && bytes.starts_with(&[0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n']) {
3009 return Some("image/png".to_string());
3010 }
3011 if bytes.len() >= 3 && bytes.starts_with(&[0xff, 0xd8, 0xff]) {
3012 return Some("image/jpeg".to_string());
3013 }
3014 if bytes.len() >= 6 && (bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a")) {
3015 return Some("image/gif".to_string());
3016 }
3017 if bytes.len() >= 12 && &bytes[0..4] == b"RIFF" && &bytes[8..12] == b"WEBP" {
3018 return Some("image/webp".to_string());
3019 }
3020 if bytes.len() >= 2 && bytes.starts_with(b"BM") {
3021 return Some("image/bmp".to_string());
3022 }
3023 content_type
3024 .and_then(|ct| ct.split(';').next())
3025 .map(|ct| ct.trim().to_lowercase())
3026 .filter(|ct| ct.starts_with("image/"))
3027}
3028
3029fn lark_is_text_filename(name: &str) -> bool {
3031 let ext = name.rsplit('.').next().unwrap_or("").to_ascii_lowercase();
3032 matches!(
3033 ext.as_str(),
3034 "txt"
3035 | "md"
3036 | "rs"
3037 | "py"
3038 | "js"
3039 | "ts"
3040 | "tsx"
3041 | "jsx"
3042 | "java"
3043 | "c"
3044 | "h"
3045 | "cpp"
3046 | "hpp"
3047 | "go"
3048 | "rb"
3049 | "sh"
3050 | "bash"
3051 | "zsh"
3052 | "toml"
3053 | "yaml"
3054 | "yml"
3055 | "json"
3056 | "xml"
3057 | "html"
3058 | "css"
3059 | "sql"
3060 | "csv"
3061 | "tsv"
3062 | "log"
3063 | "cfg"
3064 | "ini"
3065 | "conf"
3066 | "env"
3067 | "dockerfile"
3068 | "makefile"
3069 )
3070}
3071
3072fn random_lark_ack_reaction(
3073 payload: Option<&serde_json::Value>,
3074 fallback_text: &str,
3075) -> &'static str {
3076 let locale = detect_lark_ack_locale(payload, fallback_text);
3077 random_from_pool(lark_ack_pool(locale))
3078}
3079
3080struct ParsedPostContent {
3086 text: String,
3087 mentioned_open_ids: Vec<String>,
3088}
3089
3090fn parse_post_content_details(content: &str) -> Option<ParsedPostContent> {
3091 let parsed = serde_json::from_str::<serde_json::Value>(content).ok()?;
3092 let locale = parsed
3093 .get("zh_cn")
3094 .or_else(|| parsed.get("en_us"))
3095 .or_else(|| {
3096 parsed
3097 .as_object()
3098 .and_then(|m| m.values().find(|v| v.is_object()))
3099 })?;
3100
3101 let mut text = String::new();
3102 let mut mentioned_open_ids = Vec::new();
3103
3104 if let Some(title) = locale
3105 .get("title")
3106 .and_then(|t| t.as_str())
3107 .filter(|s| !s.is_empty())
3108 {
3109 text.push_str(title);
3110 text.push_str("\n\n");
3111 }
3112
3113 if let Some(paragraphs) = locale.get("content").and_then(|c| c.as_array()) {
3114 for para in paragraphs {
3115 if let Some(elements) = para.as_array() {
3116 for el in elements {
3117 match el.get("tag").and_then(|t| t.as_str()).unwrap_or("") {
3118 "text" => {
3119 if let Some(t) = el.get("text").and_then(|t| t.as_str()) {
3120 text.push_str(t);
3121 }
3122 }
3123 "a" => {
3124 text.push_str(
3125 el.get("text")
3126 .and_then(|t| t.as_str())
3127 .filter(|s| !s.is_empty())
3128 .or_else(|| el.get("href").and_then(|h| h.as_str()))
3129 .unwrap_or(""),
3130 );
3131 }
3132 "at" => {
3133 let n = el
3134 .get("user_name")
3135 .and_then(|n| n.as_str())
3136 .or_else(|| el.get("user_id").and_then(|i| i.as_str()))
3137 .unwrap_or("user");
3138 text.push('@');
3139 text.push_str(n);
3140 if let Some(open_id) = el
3141 .get("user_id")
3142 .and_then(|i| i.as_str())
3143 .map(str::trim)
3144 .filter(|id| !id.is_empty())
3145 {
3146 mentioned_open_ids.push(open_id.to_string());
3147 }
3148 }
3149 _ => {
3150 if let Some(t) = el.get("text").and_then(|t| t.as_str()) {
3154 text.push_str(t);
3155 }
3156 }
3157 }
3158 }
3159 text.push('\n');
3160 }
3161 }
3162 }
3163
3164 let result = text.trim().to_string();
3165 if result.is_empty() {
3166 None
3167 } else {
3168 Some(ParsedPostContent {
3169 text: result,
3170 mentioned_open_ids,
3171 })
3172 }
3173}
3174
3175fn parse_list_content(content: &str) -> Option<String> {
3181 let parsed = serde_json::from_str::<serde_json::Value>(content).ok()?;
3182
3183 let items = parsed
3186 .get("items")
3187 .and_then(|v| v.as_array())
3188 .or_else(|| parsed.get("content").and_then(|v| v.as_array()))?;
3189
3190 let mut lines = Vec::new();
3191 collect_list_items(items, &mut lines, 0);
3192
3193 let result = lines.join("\n").trim().to_string();
3194 if result.is_empty() {
3195 None
3196 } else {
3197 Some(result)
3198 }
3199}
3200
3201fn collect_list_items(items: &[serde_json::Value], lines: &mut Vec<String>, depth: usize) {
3204 let indent = " ".repeat(depth);
3205 for item in items {
3206 let (inline_elements, children) = if let Some(arr) = item.as_array() {
3209 (arr.as_slice(), None)
3210 } else if let Some(obj) = item.as_object() {
3211 let inlines = obj
3212 .get("content")
3213 .and_then(|v| v.as_array())
3214 .map(|a| a.as_slice())
3215 .unwrap_or(&[]);
3216 let kids = obj.get("children").and_then(|v| v.as_array());
3217 (inlines, kids)
3218 } else {
3219 continue;
3220 };
3221
3222 let mut text = String::new();
3223 for el in inline_elements {
3224 if let Some(inner_arr) = el.as_array() {
3226 for inner_el in inner_arr {
3227 extract_inline_text(inner_el, &mut text);
3228 }
3229 } else {
3230 extract_inline_text(el, &mut text);
3231 }
3232 }
3233
3234 let trimmed = text.trim();
3235 if !trimmed.is_empty() {
3236 lines.push(format!("{indent}- {trimmed}"));
3237 }
3238
3239 if let Some(kids) = children {
3240 collect_list_items(kids, lines, depth + 1);
3241 }
3242 }
3243}
3244
3245fn extract_inline_text(el: &serde_json::Value, out: &mut String) {
3247 match el.get("tag").and_then(|t| t.as_str()).unwrap_or("") {
3248 "text" => {
3249 if let Some(t) = el.get("text").and_then(|t| t.as_str()) {
3250 out.push_str(t);
3251 }
3252 }
3253 "a" => {
3254 out.push_str(
3255 el.get("text")
3256 .and_then(|t| t.as_str())
3257 .filter(|s| !s.is_empty())
3258 .or_else(|| el.get("href").and_then(|h| h.as_str()))
3259 .unwrap_or(""),
3260 );
3261 }
3262 "at" => {
3263 let n = el
3264 .get("user_name")
3265 .and_then(|n| n.as_str())
3266 .or_else(|| el.get("user_id").and_then(|i| i.as_str()))
3267 .unwrap_or("user");
3268 out.push('@');
3269 out.push_str(n);
3270 }
3271 _ => {}
3272 }
3273}
3274
3275fn mention_matches_bot_open_id(mention: &serde_json::Value, bot_open_id: &str) -> bool {
3276 mention
3277 .pointer("/id/open_id")
3278 .or_else(|| mention.pointer("/open_id"))
3279 .and_then(|v| v.as_str())
3280 .is_some_and(|value| value == bot_open_id)
3281}
3282
3283fn should_respond_in_group(
3285 mention_only: bool,
3286 bot_open_id: Option<&str>,
3287 mentions: &[serde_json::Value],
3288 post_mentioned_open_ids: &[String],
3289) -> bool {
3290 if !mention_only {
3291 return true;
3292 }
3293 let Some(bot_open_id) = bot_open_id.filter(|id| !id.is_empty()) else {
3294 return false;
3295 };
3296 if mentions.is_empty() && post_mentioned_open_ids.is_empty() {
3297 return false;
3298 }
3299 mentions
3300 .iter()
3301 .any(|mention| mention_matches_bot_open_id(mention, bot_open_id))
3302 || post_mentioned_open_ids
3303 .iter()
3304 .any(|id| id.as_str() == bot_open_id)
3305}
3306
3307#[cfg(test)]
3308mod tests {
3309 use super::*;
3310
3311 fn with_bot_open_id(ch: LarkChannel, bot_open_id: &str) -> LarkChannel {
3312 ch.set_resolved_bot_open_id(Some(bot_open_id.to_string()));
3313 ch
3314 }
3315
3316 fn resolver_from(peers: Vec<String>) -> Arc<dyn Fn() -> Vec<String> + Send + Sync> {
3317 Arc::new(move || peers.clone())
3318 }
3319
3320 fn make_channel() -> LarkChannel {
3321 with_bot_open_id(
3322 LarkChannel::new(
3323 "cli_test_app_id".into(),
3324 "test_app_secret".into(),
3325 "test_verification_token".into(),
3326 None,
3327 "lark_test_alias",
3328 resolver_from(vec!["ou_testuser123".into()]),
3329 true,
3330 ),
3331 "ou_bot",
3332 )
3333 }
3334
3335 #[test]
3336 fn lark_channel_name() {
3337 let ch = make_channel();
3338 assert_eq!(ch.name(), "lark");
3339 }
3340
3341 #[test]
3342 fn lark_ws_activity_refreshes_heartbeat_watchdog() {
3343 assert!(should_refresh_last_recv(&WsMsg::Binary(
3344 vec![1, 2, 3].into()
3345 )));
3346 assert!(should_refresh_last_recv(&WsMsg::Ping(vec![9, 9].into())));
3347 assert!(should_refresh_last_recv(&WsMsg::Pong(vec![8, 8].into())));
3348 }
3349
3350 #[test]
3351 fn lark_ws_non_activity_frames_do_not_refresh_heartbeat_watchdog() {
3352 assert!(!should_refresh_last_recv(&WsMsg::Text("hello".into())));
3353 assert!(!should_refresh_last_recv(&WsMsg::Close(None)));
3354 }
3355
3356 #[test]
3357 fn lark_group_response_requires_matching_bot_mention_when_ids_available() {
3358 let mentions = vec![serde_json::json!({
3359 "id": { "open_id": "ou_other" }
3360 })];
3361 assert!(!should_respond_in_group(
3362 true,
3363 Some("ou_bot"),
3364 &mentions,
3365 &[]
3366 ));
3367
3368 let mentions = vec![serde_json::json!({
3369 "id": { "open_id": "ou_bot" }
3370 })];
3371 assert!(should_respond_in_group(
3372 true,
3373 Some("ou_bot"),
3374 &mentions,
3375 &[]
3376 ));
3377 }
3378
3379 #[test]
3380 fn lark_group_response_requires_resolved_open_id_when_mention_only_enabled() {
3381 let mentions = vec![serde_json::json!({
3382 "id": { "open_id": "ou_any" }
3383 })];
3384 assert!(!should_respond_in_group(true, None, &mentions, &[]));
3385 }
3386
3387 #[test]
3388 fn lark_group_response_allows_post_mentions_for_bot_open_id() {
3389 assert!(should_respond_in_group(
3390 true,
3391 Some("ou_bot"),
3392 &[],
3393 &[String::from("ou_bot")]
3394 ));
3395 }
3396
3397 #[test]
3398 fn lark_should_refresh_token_on_http_401() {
3399 let body = serde_json::json!({ "code": 0 });
3400 assert!(should_refresh_lark_tenant_token(
3401 reqwest::StatusCode::UNAUTHORIZED,
3402 &body
3403 ));
3404 }
3405
3406 #[test]
3407 fn lark_should_refresh_token_on_body_code_99991663() {
3408 let body = serde_json::json!({
3409 "code": LARK_INVALID_ACCESS_TOKEN_CODE,
3410 "msg": "Invalid access token for authorization."
3411 });
3412 assert!(should_refresh_lark_tenant_token(
3413 reqwest::StatusCode::OK,
3414 &body
3415 ));
3416 }
3417
3418 #[test]
3419 fn lark_should_not_refresh_token_on_success_body() {
3420 let body = serde_json::json!({ "code": 0, "msg": "ok" });
3421 assert!(!should_refresh_lark_tenant_token(
3422 reqwest::StatusCode::OK,
3423 &body
3424 ));
3425 }
3426
3427 #[test]
3428 fn lark_extract_token_ttl_seconds_supports_expire_and_expires_in() {
3429 let body_expire = serde_json::json!({ "expire": 7200 });
3430 let body_expires_in = serde_json::json!({ "expires_in": 3600 });
3431 let body_missing = serde_json::json!({});
3432 assert_eq!(extract_lark_token_ttl_seconds(&body_expire), 7200);
3433 assert_eq!(extract_lark_token_ttl_seconds(&body_expires_in), 3600);
3434 assert_eq!(
3435 extract_lark_token_ttl_seconds(&body_missing),
3436 LARK_DEFAULT_TOKEN_TTL.as_secs()
3437 );
3438 }
3439
3440 #[test]
3441 fn lark_next_token_refresh_deadline_reserves_refresh_skew() {
3442 let now = Instant::now();
3443 let regular = next_token_refresh_deadline(now, 7200);
3444 let short_ttl = next_token_refresh_deadline(now, 60);
3445
3446 assert_eq!(regular.duration_since(now), Duration::from_secs(7080));
3447 assert_eq!(short_ttl.duration_since(now), Duration::from_secs(1));
3448 }
3449
3450 #[test]
3451 fn lark_ensure_send_success_rejects_non_zero_code() {
3452 let ok = serde_json::json!({ "code": 0 });
3453 let bad = serde_json::json!({ "code": 12345, "msg": "bad request" });
3454
3455 assert!(ensure_lark_send_success(reqwest::StatusCode::OK, &ok, "test").is_ok());
3456 assert!(ensure_lark_send_success(reqwest::StatusCode::OK, &bad, "test").is_err());
3457 }
3458
3459 #[test]
3460 fn lark_user_allowed_exact() {
3461 let ch = make_channel();
3462 assert!(ch.is_user_allowed("ou_testuser123"));
3463 assert!(!ch.is_user_allowed("ou_other"));
3464 }
3465
3466 #[test]
3467 fn lark_user_allowed_wildcard() {
3468 let ch = LarkChannel::new(
3469 "id".into(),
3470 "secret".into(),
3471 "token".into(),
3472 None,
3473 "lark_test_alias",
3474 resolver_from(vec!["*".into()]),
3475 true,
3476 );
3477 assert!(ch.is_user_allowed("ou_anyone"));
3478 }
3479
3480 #[test]
3481 fn lark_user_denied_empty() {
3482 let ch = LarkChannel::new(
3483 "id".into(),
3484 "secret".into(),
3485 "token".into(),
3486 None,
3487 "lark_test_alias",
3488 resolver_from(vec![]),
3489 true,
3490 );
3491 assert!(!ch.is_user_allowed("ou_anyone"));
3492 }
3493
3494 #[tokio::test]
3495 async fn lark_parse_challenge() {
3496 let ch = make_channel();
3497 let payload = serde_json::json!({
3498 "challenge": "abc123",
3499 "token": "test_verification_token",
3500 "type": "url_verification"
3501 });
3502 let msgs = ch.parse_event_payload(&payload).await;
3504 assert!(msgs.is_empty());
3505 }
3506
3507 #[tokio::test]
3508 async fn lark_parse_valid_text_message() {
3509 let ch = make_channel();
3510 let payload = serde_json::json!({
3511 "header": {
3512 "event_type": "im.message.receive_v1"
3513 },
3514 "event": {
3515 "sender": {
3516 "sender_id": {
3517 "open_id": "ou_testuser123"
3518 }
3519 },
3520 "message": {
3521 "message_type": "text",
3522 "content": "{\"text\":\"Hello ZeroClaw!\"}",
3523 "chat_id": "oc_chat123",
3524 "create_time": "1699999999000"
3525 }
3526 }
3527 });
3528
3529 let msgs = ch.parse_event_payload(&payload).await;
3530 assert_eq!(msgs.len(), 1);
3531 assert_eq!(msgs[0].content, "Hello ZeroClaw!");
3532 assert_eq!(msgs[0].sender, "oc_chat123");
3533 assert_eq!(msgs[0].channel, "lark");
3534 assert_eq!(msgs[0].timestamp, 1_699_999_999);
3535 }
3536
3537 #[tokio::test]
3538 async fn lark_parse_unauthorized_user() {
3539 let ch = make_channel();
3540 let payload = serde_json::json!({
3541 "header": { "event_type": "im.message.receive_v1" },
3542 "event": {
3543 "sender": { "sender_id": { "open_id": "ou_unauthorized" } },
3544 "message": {
3545 "message_type": "text",
3546 "content": "{\"text\":\"spam\"}",
3547 "chat_id": "oc_chat",
3548 "create_time": "1000"
3549 }
3550 }
3551 });
3552
3553 let msgs = ch.parse_event_payload(&payload).await;
3554 assert!(msgs.is_empty());
3555 }
3556
3557 #[tokio::test]
3558 async fn lark_parse_unsupported_message_type_skipped() {
3559 let ch = LarkChannel::new(
3560 "id".into(),
3561 "secret".into(),
3562 "token".into(),
3563 None,
3564 "lark_test_alias",
3565 resolver_from(vec!["*".into()]),
3566 true,
3567 );
3568 let payload = serde_json::json!({
3569 "header": { "event_type": "im.message.receive_v1" },
3570 "event": {
3571 "sender": { "sender_id": { "open_id": "ou_user" } },
3572 "message": {
3573 "message_type": "sticker",
3574 "content": "{}",
3575 "chat_id": "oc_chat"
3576 }
3577 }
3578 });
3579
3580 let msgs = ch.parse_event_payload(&payload).await;
3581 assert!(msgs.is_empty());
3582 }
3583
3584 #[test]
3585 fn parse_list_content_flat_items() {
3586 let content = r#"{"items":[[{"tag":"text","text":"first item"}],[{"tag":"text","text":"second item"}]]}"#;
3588 let result = parse_list_content(content).unwrap();
3589 assert_eq!(result, "- first item\n- second item");
3590 }
3591
3592 #[test]
3593 fn parse_list_content_nested_children() {
3594 let content = r#"{"items":[{"content":[[{"tag":"text","text":"parent"}]],"children":[{"content":[[{"tag":"text","text":"child"}]]}]}]}"#;
3596 let result = parse_list_content(content).unwrap();
3597 assert_eq!(result, "- parent\n - child");
3598 }
3599
3600 #[test]
3601 fn parse_list_content_with_links() {
3602 let content = r#"{"items":[[{"tag":"text","text":"see "},{"tag":"a","text":"docs","href":"https://example.com"}]]}"#;
3603 let result = parse_list_content(content).unwrap();
3604 assert_eq!(result, "- see docs");
3605 }
3606
3607 #[test]
3608 fn parse_list_content_empty_returns_none() {
3609 let content = r#"{"items":[]}"#;
3610 assert!(parse_list_content(content).is_none());
3611 }
3612
3613 #[test]
3614 fn parse_list_content_invalid_json_returns_none() {
3615 assert!(parse_list_content("not json").is_none());
3616 }
3617
3618 #[tokio::test]
3619 async fn lark_parse_list_message_type() {
3620 let ch = LarkChannel::new(
3621 "id".into(),
3622 "secret".into(),
3623 "token".into(),
3624 None,
3625 "lark_test_alias",
3626 resolver_from(vec!["*".into()]),
3627 true,
3628 );
3629 let payload = serde_json::json!({
3630 "header": { "event_type": "im.message.receive_v1" },
3631 "event": {
3632 "sender": { "sender_id": { "open_id": "ou_user" } },
3633 "message": {
3634 "message_type": "list",
3635 "content": "{\"items\":[[{\"tag\":\"text\",\"text\":\"buy milk\"}],[{\"tag\":\"text\",\"text\":\"buy eggs\"}]]}",
3636 "chat_id": "oc_chat",
3637 "create_time": "1000"
3638 }
3639 }
3640 });
3641
3642 let msgs = ch.parse_event_payload(&payload).await;
3643 assert_eq!(msgs.len(), 1);
3644 assert!(msgs[0].content.contains("buy milk"));
3645 assert!(msgs[0].content.contains("buy eggs"));
3646 }
3647
3648 #[tokio::test]
3649 async fn lark_parse_image_missing_key_skipped() {
3650 let ch = LarkChannel::new(
3651 "id".into(),
3652 "secret".into(),
3653 "token".into(),
3654 None,
3655 "lark_test_alias",
3656 resolver_from(vec!["*".into()]),
3657 true,
3658 );
3659 let payload = serde_json::json!({
3660 "header": { "event_type": "im.message.receive_v1" },
3661 "event": {
3662 "sender": { "sender_id": { "open_id": "ou_user" } },
3663 "message": {
3664 "message_type": "image",
3665 "content": "{}",
3666 "chat_id": "oc_chat"
3667 }
3668 }
3669 });
3670
3671 let msgs = ch.parse_event_payload(&payload).await;
3672 assert!(msgs.is_empty());
3673 }
3674
3675 #[tokio::test]
3676 async fn lark_parse_file_missing_key_skipped() {
3677 let ch = LarkChannel::new(
3678 "id".into(),
3679 "secret".into(),
3680 "token".into(),
3681 None,
3682 "lark_test_alias",
3683 resolver_from(vec!["*".into()]),
3684 true,
3685 );
3686 let payload = serde_json::json!({
3687 "header": { "event_type": "im.message.receive_v1" },
3688 "event": {
3689 "sender": { "sender_id": { "open_id": "ou_user" } },
3690 "message": {
3691 "message_type": "file",
3692 "content": "{}",
3693 "chat_id": "oc_chat"
3694 }
3695 }
3696 });
3697
3698 let msgs = ch.parse_event_payload(&payload).await;
3699 assert!(msgs.is_empty());
3700 }
3701
3702 #[tokio::test]
3703 async fn lark_parse_empty_text_skipped() {
3704 let ch = LarkChannel::new(
3705 "id".into(),
3706 "secret".into(),
3707 "token".into(),
3708 None,
3709 "lark_test_alias",
3710 resolver_from(vec!["*".into()]),
3711 true,
3712 );
3713 let payload = serde_json::json!({
3714 "header": { "event_type": "im.message.receive_v1" },
3715 "event": {
3716 "sender": { "sender_id": { "open_id": "ou_user" } },
3717 "message": {
3718 "message_type": "text",
3719 "content": "{\"text\":\"\"}",
3720 "chat_id": "oc_chat"
3721 }
3722 }
3723 });
3724
3725 let msgs = ch.parse_event_payload(&payload).await;
3726 assert!(msgs.is_empty());
3727 }
3728
3729 #[tokio::test]
3730 async fn lark_parse_wrong_event_type() {
3731 let ch = make_channel();
3732 let payload = serde_json::json!({
3733 "header": { "event_type": "im.chat.disbanded_v1" },
3734 "event": {}
3735 });
3736
3737 let msgs = ch.parse_event_payload(&payload).await;
3738 assert!(msgs.is_empty());
3739 }
3740
3741 #[tokio::test]
3742 async fn lark_parse_missing_sender() {
3743 let ch = LarkChannel::new(
3744 "id".into(),
3745 "secret".into(),
3746 "token".into(),
3747 None,
3748 "lark_test_alias",
3749 resolver_from(vec!["*".into()]),
3750 true,
3751 );
3752 let payload = serde_json::json!({
3753 "header": { "event_type": "im.message.receive_v1" },
3754 "event": {
3755 "message": {
3756 "message_type": "text",
3757 "content": "{\"text\":\"hello\"}",
3758 "chat_id": "oc_chat"
3759 }
3760 }
3761 });
3762
3763 let msgs = ch.parse_event_payload(&payload).await;
3764 assert!(msgs.is_empty());
3765 }
3766
3767 #[tokio::test]
3768 async fn lark_parse_unicode_message() {
3769 let ch = LarkChannel::new(
3770 "id".into(),
3771 "secret".into(),
3772 "token".into(),
3773 None,
3774 "lark_test_alias",
3775 resolver_from(vec!["*".into()]),
3776 true,
3777 );
3778 let payload = serde_json::json!({
3779 "header": { "event_type": "im.message.receive_v1" },
3780 "event": {
3781 "sender": { "sender_id": { "open_id": "ou_user" } },
3782 "message": {
3783 "message_type": "text",
3784 "content": "{\"text\":\"Hello world 🌍\"}",
3785 "chat_id": "oc_chat",
3786 "create_time": "1000"
3787 }
3788 }
3789 });
3790
3791 let msgs = ch.parse_event_payload(&payload).await;
3792 assert_eq!(msgs.len(), 1);
3793 assert_eq!(msgs[0].content, "Hello world 🌍");
3794 }
3795
3796 #[tokio::test]
3797 async fn lark_parse_missing_event() {
3798 let ch = make_channel();
3799 let payload = serde_json::json!({
3800 "header": { "event_type": "im.message.receive_v1" }
3801 });
3802
3803 let msgs = ch.parse_event_payload(&payload).await;
3804 assert!(msgs.is_empty());
3805 }
3806
3807 #[tokio::test]
3808 async fn lark_parse_invalid_content_json() {
3809 let ch = LarkChannel::new(
3810 "id".into(),
3811 "secret".into(),
3812 "token".into(),
3813 None,
3814 "lark_test_alias",
3815 resolver_from(vec!["*".into()]),
3816 true,
3817 );
3818 let payload = serde_json::json!({
3819 "header": { "event_type": "im.message.receive_v1" },
3820 "event": {
3821 "sender": { "sender_id": { "open_id": "ou_user" } },
3822 "message": {
3823 "message_type": "text",
3824 "content": "not valid json",
3825 "chat_id": "oc_chat"
3826 }
3827 }
3828 });
3829
3830 let msgs = ch.parse_event_payload(&payload).await;
3831 assert!(msgs.is_empty());
3832 }
3833
3834 #[test]
3835 fn lark_config_serde() {
3836 use zeroclaw_config::schema::{LarkConfig, LarkReceiveMode};
3837 let lc = LarkConfig {
3838 enabled: true,
3839 app_id: "cli_app123".into(),
3840 app_secret: "secret456".into(),
3841 encrypt_key: None,
3842 verification_token: Some("vtoken789".into()),
3843 mention_only: false,
3844 use_feishu: false,
3845 receive_mode: LarkReceiveMode::default(),
3846 port: None,
3847 proxy_url: None,
3848 excluded_tools: vec![],
3849 default_target: None,
3850 };
3851 let json = serde_json::to_string(&lc).unwrap();
3852 let parsed: LarkConfig = serde_json::from_str(&json).unwrap();
3853 assert_eq!(parsed.app_id, "cli_app123");
3854 assert_eq!(parsed.app_secret, "secret456");
3855 assert_eq!(parsed.verification_token.as_deref(), Some("vtoken789"));
3856 }
3857
3858 #[test]
3859 fn lark_config_toml_roundtrip() {
3860 use zeroclaw_config::schema::{LarkConfig, LarkReceiveMode};
3861 let lc = LarkConfig {
3862 enabled: true,
3863 app_id: "app".into(),
3864 app_secret: "secret".into(),
3865 encrypt_key: None,
3866 verification_token: Some("tok".into()),
3867 mention_only: false,
3868 use_feishu: false,
3869 receive_mode: LarkReceiveMode::Webhook,
3870 port: Some(9898),
3871 proxy_url: None,
3872 excluded_tools: vec![],
3873 default_target: None,
3874 };
3875 let toml_str = toml::to_string(&lc).unwrap();
3876 let parsed: LarkConfig = toml::from_str(&toml_str).unwrap();
3877 assert_eq!(parsed.app_id, "app");
3878 assert_eq!(parsed.verification_token.as_deref(), Some("tok"));
3879 }
3880
3881 #[test]
3882 fn lark_config_defaults_optional_fields() {
3883 use zeroclaw_config::schema::{LarkConfig, LarkReceiveMode};
3884 let json = r#"{"app_id":"a","app_secret":"s"}"#;
3885 let parsed: LarkConfig = serde_json::from_str(json).unwrap();
3886 assert!(parsed.verification_token.is_none());
3887 assert!(!parsed.mention_only);
3888 assert_eq!(parsed.receive_mode, LarkReceiveMode::Websocket);
3889 assert!(parsed.port.is_none());
3890 }
3891
3892 #[test]
3893 fn lark_from_config_preserves_mode_and_region() {
3894 use zeroclaw_config::schema::{LarkConfig, LarkReceiveMode};
3895
3896 let cfg = LarkConfig {
3897 enabled: true,
3898 app_id: "cli_app123".into(),
3899 app_secret: "secret456".into(),
3900 encrypt_key: None,
3901 verification_token: Some("vtoken789".into()),
3902 mention_only: false,
3903 use_feishu: false,
3904 receive_mode: LarkReceiveMode::Webhook,
3905 port: Some(9898),
3906 proxy_url: None,
3907 excluded_tools: vec![],
3908 default_target: None,
3909 };
3910
3911 let ch = LarkChannel::from_config(&cfg, "lark_test_alias", resolver_from(vec!["*".into()]));
3912
3913 assert_eq!(ch.api_base(), LARK_BASE_URL);
3914 assert_eq!(ch.ws_base(), LARK_WS_BASE_URL);
3915 assert_eq!(ch.receive_mode, LarkReceiveMode::Webhook);
3916 assert_eq!(ch.port, Some(9898));
3917 }
3918
3919 #[test]
3920 fn lark_from_config_with_use_feishu_routes_to_feishu() {
3921 use zeroclaw_config::schema::{LarkConfig, LarkReceiveMode};
3922
3923 let cfg = LarkConfig {
3924 enabled: true,
3925 app_id: "cli_feishu_app123".into(),
3926 app_secret: "secret456".into(),
3927 encrypt_key: None,
3928 verification_token: Some("vtoken789".into()),
3929 mention_only: false,
3930 use_feishu: true,
3931 receive_mode: LarkReceiveMode::Webhook,
3932 port: Some(9898),
3933 proxy_url: None,
3934 excluded_tools: vec![],
3935 default_target: None,
3936 };
3937
3938 let ch =
3939 LarkChannel::from_config(&cfg, "feishu_test_alias", resolver_from(vec!["*".into()]));
3940
3941 assert_eq!(ch.api_base(), FEISHU_BASE_URL);
3942 assert_eq!(ch.ws_base(), FEISHU_WS_BASE_URL);
3943 assert_eq!(ch.name(), "feishu");
3944 }
3945
3946 #[tokio::test]
3947 async fn lark_parse_fallback_sender_to_open_id() {
3948 let ch = LarkChannel::new(
3950 "id".into(),
3951 "secret".into(),
3952 "token".into(),
3953 None,
3954 "lark_test_alias",
3955 resolver_from(vec!["*".into()]),
3956 true,
3957 );
3958 let payload = serde_json::json!({
3959 "header": { "event_type": "im.message.receive_v1" },
3960 "event": {
3961 "sender": { "sender_id": { "open_id": "ou_user" } },
3962 "message": {
3963 "message_type": "text",
3964 "content": "{\"text\":\"hello\"}",
3965 "create_time": "1000"
3966 }
3967 }
3968 });
3969
3970 let msgs = ch.parse_event_payload(&payload).await;
3971 assert_eq!(msgs.len(), 1);
3972 assert_eq!(msgs[0].sender, "ou_user");
3973 }
3974
3975 #[tokio::test]
3976 async fn lark_parse_group_message_requires_bot_mention_when_enabled() {
3977 let ch = with_bot_open_id(
3978 LarkChannel::new(
3979 "cli_app123".into(),
3980 "secret".into(),
3981 "token".into(),
3982 None,
3983 "lark_test_alias",
3984 resolver_from(vec!["*".into()]),
3985 true,
3986 ),
3987 "ou_bot_123",
3988 );
3989
3990 let no_mention_payload = serde_json::json!({
3991 "header": { "event_type": "im.message.receive_v1" },
3992 "event": {
3993 "sender": { "sender_id": { "open_id": "ou_user" } },
3994 "message": {
3995 "message_type": "text",
3996 "content": "{\"text\":\"hello\"}",
3997 "chat_type": "group",
3998 "chat_id": "oc_chat",
3999 "mentions": []
4000 }
4001 }
4002 });
4003 assert!(ch.parse_event_payload(&no_mention_payload).await.is_empty());
4004
4005 let wrong_mention_payload = serde_json::json!({
4006 "header": { "event_type": "im.message.receive_v1" },
4007 "event": {
4008 "sender": { "sender_id": { "open_id": "ou_user" } },
4009 "message": {
4010 "message_type": "text",
4011 "content": "{\"text\":\"hello\"}",
4012 "chat_type": "group",
4013 "chat_id": "oc_chat",
4014 "mentions": [{ "id": { "open_id": "ou_other" } }]
4015 }
4016 }
4017 });
4018 assert!(
4019 ch.parse_event_payload(&wrong_mention_payload)
4020 .await
4021 .is_empty()
4022 );
4023
4024 let bot_mention_payload = serde_json::json!({
4025 "header": { "event_type": "im.message.receive_v1" },
4026 "event": {
4027 "sender": { "sender_id": { "open_id": "ou_user" } },
4028 "message": {
4029 "message_type": "text",
4030 "content": "{\"text\":\"hello\"}",
4031 "chat_type": "group",
4032 "chat_id": "oc_chat",
4033 "mentions": [{ "id": { "open_id": "ou_bot_123" } }]
4034 }
4035 }
4036 });
4037 assert_eq!(ch.parse_event_payload(&bot_mention_payload).await.len(), 1);
4038 }
4039
4040 #[tokio::test]
4041 async fn lark_parse_group_post_message_accepts_at_when_top_level_mentions_empty() {
4042 let ch = with_bot_open_id(
4043 LarkChannel::new(
4044 "cli_app123".into(),
4045 "secret".into(),
4046 "token".into(),
4047 None,
4048 "lark_test_alias",
4049 resolver_from(vec!["*".into()]),
4050 true,
4051 ),
4052 "ou_bot_123",
4053 );
4054
4055 let payload = serde_json::json!({
4056 "header": { "event_type": "im.message.receive_v1" },
4057 "event": {
4058 "sender": { "sender_id": { "open_id": "ou_user" } },
4059 "message": {
4060 "message_type": "post",
4061 "chat_type": "group",
4062 "chat_id": "oc_chat",
4063 "mentions": [],
4064 "content": "{\"zh_cn\":{\"title\":\"\",\"content\":[[{\"tag\":\"at\",\"user_id\":\"ou_bot_123\",\"user_name\":\"Bot\"},{\"tag\":\"text\",\"text\":\" hi\"}]]}}"
4065 }
4066 }
4067 });
4068
4069 assert_eq!(ch.parse_event_payload(&payload).await.len(), 1);
4070 }
4071
4072 #[tokio::test]
4073 async fn lark_parse_post_message_accepts_md_tag_text_content() {
4074 let ch = make_channel();
4075 let payload = serde_json::json!({
4076 "header": { "event_type": "im.message.receive_v1" },
4077 "event": {
4078 "sender": { "sender_id": { "open_id": "ou_testuser123" } },
4079 "message": {
4080 "message_type": "post",
4081 "chat_type": "p2p",
4082 "chat_id": "oc_chat",
4083 "mentions": [],
4084 "content": "{\"zh_cn\":{\"title\":\"\",\"content\":[[{\"tag\":\"md\",\"text\":\"* 1\\n* 2\"}]]}}"
4085 }
4086 }
4087 });
4088
4089 let msgs = ch.parse_event_payload(&payload).await;
4090 assert_eq!(msgs.len(), 1);
4091 assert_eq!(msgs[0].content, "* 1\n* 2");
4092 }
4093
4094 #[tokio::test]
4095 async fn lark_parse_group_message_allows_without_mention_when_disabled() {
4096 let ch = LarkChannel::new(
4097 "cli_app123".into(),
4098 "secret".into(),
4099 "token".into(),
4100 None,
4101 "lark_test_alias",
4102 resolver_from(vec!["*".into()]),
4103 false,
4104 );
4105
4106 let payload = serde_json::json!({
4107 "header": { "event_type": "im.message.receive_v1" },
4108 "event": {
4109 "sender": { "sender_id": { "open_id": "ou_user" } },
4110 "message": {
4111 "message_type": "text",
4112 "content": "{\"text\":\"hello\"}",
4113 "chat_type": "group",
4114 "chat_id": "oc_chat",
4115 "mentions": []
4116 }
4117 }
4118 });
4119
4120 assert_eq!(ch.parse_event_payload(&payload).await.len(), 1);
4121 }
4122
4123 #[test]
4124 fn lark_reaction_url_matches_region() {
4125 let ch_lark = make_channel();
4126 assert_eq!(
4127 ch_lark.message_reaction_url("om_test_message_id"),
4128 "https://open.larksuite.com/open-apis/im/v1/messages/om_test_message_id/reactions"
4129 );
4130
4131 let feishu_cfg = zeroclaw_config::schema::LarkConfig {
4132 enabled: true,
4133 app_id: "cli_app123".into(),
4134 app_secret: "secret456".into(),
4135 encrypt_key: None,
4136 verification_token: Some("vtoken789".into()),
4137 mention_only: false,
4138 use_feishu: true,
4139 receive_mode: zeroclaw_config::schema::LarkReceiveMode::Webhook,
4140 port: Some(9898),
4141 proxy_url: None,
4142 excluded_tools: vec![],
4143 default_target: None,
4144 };
4145 let ch_feishu = LarkChannel::from_config(
4146 &feishu_cfg,
4147 "feishu_test_alias",
4148 resolver_from(vec!["*".into()]),
4149 );
4150 assert_eq!(
4151 ch_feishu.message_reaction_url("om_test_message_id"),
4152 "https://open.feishu.cn/open-apis/im/v1/messages/om_test_message_id/reactions"
4153 );
4154 }
4155
4156 #[test]
4157 fn lark_image_download_url_matches_region() {
4158 let ch = make_channel();
4159 assert_eq!(
4160 ch.image_download_url("img_abc123"),
4161 "https://open.larksuite.com/open-apis/im/v1/images/img_abc123"
4162 );
4163 }
4164
4165 #[test]
4166 fn lark_file_download_url_matches_region() {
4167 let ch = make_channel();
4168 assert_eq!(
4169 ch.file_download_url("om_msg123", "file_abc"),
4170 "https://open.larksuite.com/open-apis/im/v1/messages/om_msg123/resources/file_abc?type=file"
4171 );
4172 }
4173
4174 #[test]
4175 fn lark_detect_image_mime_from_magic_bytes() {
4176 let png = [0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n'];
4177 assert_eq!(
4178 lark_detect_image_mime(None, &png).as_deref(),
4179 Some("image/png")
4180 );
4181
4182 let jpeg = [0xff, 0xd8, 0xff, 0xe0];
4183 assert_eq!(
4184 lark_detect_image_mime(None, &jpeg).as_deref(),
4185 Some("image/jpeg")
4186 );
4187
4188 let gif = b"GIF89a...";
4189 assert_eq!(
4190 lark_detect_image_mime(None, gif).as_deref(),
4191 Some("image/gif")
4192 );
4193
4194 let unknown = [0x00, 0x01, 0x02];
4196 assert_eq!(
4197 lark_detect_image_mime(Some("image/webp"), &unknown).as_deref(),
4198 Some("image/webp")
4199 );
4200
4201 assert_eq!(lark_detect_image_mime(Some("text/html"), &unknown), None);
4203
4204 assert_eq!(lark_detect_image_mime(None, &unknown), None);
4206 }
4207
4208 #[test]
4209 fn lark_is_text_filename_recognizes_common_extensions() {
4210 assert!(lark_is_text_filename("script.py"));
4211 assert!(lark_is_text_filename("config.toml"));
4212 assert!(lark_is_text_filename("data.csv"));
4213 assert!(lark_is_text_filename("README.md"));
4214 assert!(!lark_is_text_filename("image.png"));
4215 assert!(!lark_is_text_filename("archive.zip"));
4216 assert!(!lark_is_text_filename("binary.exe"));
4217 }
4218
4219 #[test]
4220 fn lark_reaction_locale_explicit_language_tags() {
4221 assert_eq!(map_locale_tag("zh-CN"), Some(LarkAckLocale::ZhCn));
4222 assert_eq!(map_locale_tag("zh_TW"), Some(LarkAckLocale::ZhTw));
4223 assert_eq!(map_locale_tag("zh-Hant"), Some(LarkAckLocale::ZhTw));
4224 assert_eq!(map_locale_tag("en-US"), Some(LarkAckLocale::En));
4225 assert_eq!(map_locale_tag("ja-JP"), Some(LarkAckLocale::Ja));
4226 assert_eq!(map_locale_tag("fr-FR"), None);
4227 }
4228
4229 #[test]
4230 fn lark_reaction_locale_prefers_explicit_payload_locale() {
4231 let payload = serde_json::json!({
4232 "sender": {
4233 "locale": "ja-JP"
4234 },
4235 "message": {
4236 "content": "{\"text\":\"hello\"}"
4237 }
4238 });
4239 assert_eq!(
4240 detect_lark_ack_locale(Some(&payload), "你好,世界"),
4241 LarkAckLocale::Ja
4242 );
4243 }
4244
4245 #[test]
4246 fn lark_reaction_locale_unsupported_payload_falls_back_to_text_script() {
4247 let payload = serde_json::json!({
4248 "sender": {
4249 "locale": "fr-FR"
4250 },
4251 "message": {
4252 "content": "{\"text\":\"頑張れ\"}"
4253 }
4254 });
4255 assert_eq!(
4256 detect_lark_ack_locale(Some(&payload), "頑張ってください"),
4257 LarkAckLocale::Ja
4258 );
4259 }
4260
4261 #[test]
4262 fn lark_reaction_locale_detects_simplified_and_traditional_text() {
4263 assert_eq!(
4264 detect_lark_ack_locale(None, "继续奋斗,今天很强"),
4265 LarkAckLocale::ZhCn
4266 );
4267 assert_eq!(
4268 detect_lark_ack_locale(None, "繼續奮鬥,今天很強"),
4269 LarkAckLocale::ZhTw
4270 );
4271 }
4272
4273 #[test]
4274 fn lark_reaction_locale_defaults_to_english_for_unsupported_text() {
4275 assert_eq!(
4276 detect_lark_ack_locale(None, "Bonjour tout le monde"),
4277 LarkAckLocale::En
4278 );
4279 }
4280
4281 #[test]
4282 fn random_lark_ack_reaction_respects_detected_locale_pool() {
4283 let payload = serde_json::json!({
4284 "sender": {
4285 "locale": "zh-CN"
4286 }
4287 });
4288 let selected = random_lark_ack_reaction(Some(&payload), "hello");
4289 assert!(LARK_ACK_REACTIONS_ZH_CN.contains(&selected));
4290
4291 let payload = serde_json::json!({
4292 "sender": {
4293 "locale": "zh-TW"
4294 }
4295 });
4296 let selected = random_lark_ack_reaction(Some(&payload), "hello");
4297 assert!(LARK_ACK_REACTIONS_ZH_TW.contains(&selected));
4298
4299 let payload = serde_json::json!({
4300 "sender": {
4301 "locale": "en-US"
4302 }
4303 });
4304 let selected = random_lark_ack_reaction(Some(&payload), "hello");
4305 assert!(LARK_ACK_REACTIONS_EN.contains(&selected));
4306
4307 let payload = serde_json::json!({
4308 "sender": {
4309 "locale": "ja-JP"
4310 }
4311 });
4312 let selected = random_lark_ack_reaction(Some(&payload), "hello");
4313 assert!(LARK_ACK_REACTIONS_JA.contains(&selected));
4314 }
4315
4316 #[test]
4317 fn build_interactive_card_body_produces_correct_structure() {
4318 let body = build_interactive_card_body("oc_chat123", "**Hello** world");
4319 assert_eq!(body["receive_id"], "oc_chat123");
4320 assert_eq!(body["msg_type"], "interactive");
4321
4322 let content: serde_json::Value =
4323 serde_json::from_str(body["content"].as_str().unwrap()).unwrap();
4324 assert_eq!(content["schema"], "2.0");
4325 let elements = content["body"]["elements"].as_array().unwrap();
4326 assert_eq!(elements.len(), 1);
4327 assert_eq!(elements[0]["tag"], "markdown");
4328 assert_eq!(elements[0]["content"], "**Hello** world");
4329 }
4330
4331 #[test]
4332 fn build_card_content_produces_valid_json() {
4333 let content = build_card_content("# Title\n\n**Bold** text");
4334 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
4335 assert_eq!(parsed["schema"], "2.0");
4336 assert_eq!(parsed["body"]["elements"][0]["tag"], "markdown");
4337 assert_eq!(
4338 parsed["body"]["elements"][0]["content"],
4339 "# Title\n\n**Bold** text"
4340 );
4341 }
4342
4343 #[test]
4344 fn split_markdown_chunks_single_chunk_for_small_content() {
4345 let text = "Hello world";
4346 let chunks = split_markdown_chunks(text, LARK_CARD_MARKDOWN_MAX_BYTES);
4347 assert_eq!(chunks, vec!["Hello world"]);
4348 }
4349
4350 #[test]
4351 fn split_markdown_chunks_splits_on_newline_boundaries() {
4352 let line = "abcdefghij\n"; let text = line.repeat(10); let chunks = split_markdown_chunks(&text, 33); assert_eq!(chunks.len(), 4);
4356 for chunk in &chunks[..3] {
4357 assert!(chunk.len() <= 33);
4358 assert!(chunk.ends_with('\n'));
4359 }
4360 }
4361
4362 #[test]
4363 fn split_markdown_chunks_handles_no_newlines() {
4364 let text = "a".repeat(100);
4365 let chunks = split_markdown_chunks(&text, 30);
4366 assert!(chunks.len() > 1);
4367 let reassembled: String = chunks.concat();
4368 assert_eq!(reassembled, text);
4369 }
4370
4371 #[test]
4372 fn split_markdown_chunks_exact_boundary() {
4373 let text = "abc";
4374 let chunks = split_markdown_chunks(text, 3);
4375 assert_eq!(chunks, vec!["abc"]);
4376 }
4377
4378 #[test]
4379 fn lark_manager_none_when_transcription_not_configured() {
4380 let ch = make_channel();
4381 assert!(ch.transcription_manager.is_none());
4382 }
4383
4384 #[test]
4385 fn lark_manager_none_when_disabled() {
4386 let tc = zeroclaw_config::schema::TranscriptionConfig {
4387 enabled: false,
4388 ..Default::default()
4389 };
4390 let ch = make_channel().with_transcription(tc);
4391 assert!(ch.transcription_manager.is_none());
4392 }
4393
4394 #[test]
4395 fn lark_manager_none_and_warn_on_init_failure() {
4396 let tc = zeroclaw_config::schema::TranscriptionConfig {
4397 enabled: true,
4398 api_key: Some(String::new()),
4399 ..Default::default()
4400 };
4401 let ch = make_channel().with_transcription(tc);
4402 assert!(ch.transcription_manager.is_none());
4403 assert!(ch.transcription.is_some());
4404 }
4405
4406 #[test]
4407 fn lark_audio_extensionless_file_key_falls_back_to_m4a() {
4408 assert_eq!(inferred_audio_filename("abc123"), "voice.m4a");
4409 assert_eq!(inferred_audio_filename("file_without_ext"), "voice.m4a");
4410 }
4411
4412 #[test]
4413 fn lark_audio_extensionless_file_key_preserves_existing_extension() {
4414 assert_eq!(inferred_audio_filename("abc.m4a"), "abc.m4a");
4415 assert_eq!(inferred_audio_filename("voice.ogg"), "voice.ogg");
4416 assert_eq!(inferred_audio_filename("audio.mp3"), "audio.mp3");
4417 assert_eq!(inferred_audio_filename("note.aac"), "note.aac");
4418 assert_eq!(inferred_audio_filename("file.wav"), "file.wav");
4419 }
4420
4421 #[tokio::test]
4422 async fn lark_parse_audio_message_type_skipped_without_manager() {
4423 let ch = make_channel();
4424 let payload = serde_json::json!({
4425 "header": {
4426 "event_type": "im.message.receive_v1"
4427 },
4428 "event": {
4429 "sender": {
4430 "sender_id": {
4431 "open_id": "ou_testuser123"
4432 }
4433 },
4434 "message": {
4435 "message_id": "om_audio123",
4436 "message_type": "audio",
4437 "content": "{\"file_key\":\"audio_file_key\"}",
4438 "chat_id": "oc_chat123",
4439 "chat_type": "p2p",
4440 "create_time": "1699999999000"
4441 }
4442 }
4443 });
4444
4445 let msgs = ch.parse_event_payload_async(&payload).await;
4446 assert!(msgs.is_empty());
4447 }
4448
4449 #[tokio::test]
4450 async fn lark_parse_text_still_works_via_async_path() {
4451 let ch = make_channel();
4452 let payload = serde_json::json!({
4453 "header": {
4454 "event_type": "im.message.receive_v1"
4455 },
4456 "event": {
4457 "sender": {
4458 "sender_id": {
4459 "open_id": "ou_testuser123"
4460 }
4461 },
4462 "message": {
4463 "message_id": "om_text123",
4464 "message_type": "text",
4465 "content": "{\"text\":\"Hello async!\"}",
4466 "chat_id": "oc_chat123",
4467 "chat_type": "p2p",
4468 "create_time": "1699999999000"
4469 }
4470 }
4471 });
4472
4473 let msgs = ch.parse_event_payload_async(&payload).await;
4474 assert_eq!(msgs.len(), 1);
4475 assert_eq!(msgs[0].content, "Hello async!");
4476 }
4477
4478 #[tokio::test]
4479 async fn lark_audio_group_without_mention_skips_before_download() {
4480 let ch = make_channel();
4481 let payload = serde_json::json!({
4482 "header": {
4483 "event_type": "im.message.receive_v1"
4484 },
4485 "event": {
4486 "sender": {
4487 "sender_id": {
4488 "open_id": "ou_testuser123"
4489 }
4490 },
4491 "message": {
4492 "message_id": "om_audio_group",
4493 "message_type": "audio",
4494 "content": "{\"file_key\":\"audio_file_key\"}",
4495 "chat_id": "oc_group123",
4496 "chat_type": "group",
4497 "mentions": [],
4498 "create_time": "1699999999000"
4499 }
4500 }
4501 });
4502
4503 let msgs = ch.parse_event_payload_async(&payload).await;
4504 assert!(msgs.is_empty());
4505 }
4506
4507 #[test]
4508 fn lark_feishu_audio_uses_feishu_api_base() {
4509 let ch = LarkChannel::new_with_platform(
4510 "app_id".into(),
4511 "secret".into(),
4512 "token".into(),
4513 None,
4514 "feishu_test_alias",
4515 resolver_from(vec![]),
4516 false,
4517 LarkPlatform::Feishu,
4518 );
4519 assert_eq!(ch.api_base(), FEISHU_BASE_URL);
4520 }
4521
4522 #[tokio::test]
4523 async fn lark_audio_file_key_missing_returns_none() {
4524 let ch = make_channel();
4525 let tc = zeroclaw_config::schema::TranscriptionConfig {
4526 enabled: true,
4527 local_whisper: Some(zeroclaw_config::schema::LocalWhisperConfig {
4528 url: "http://localhost:0/v1/transcribe".to_string(),
4529 bearer_token: Some("unused".to_string()),
4530 max_audio_bytes: 10 * 1024 * 1024,
4531 timeout_secs: 30,
4532 }),
4533 ..Default::default()
4534 };
4535 let ch = ch.with_transcription(tc);
4536 let manager = ch.transcription_manager.as_deref().unwrap();
4537
4538 let result = ch
4539 .try_transcribe_audio_message("om_123", "{}", manager)
4540 .await;
4541 assert!(result.is_none());
4542 }
4543
4544 #[tokio::test]
4545 async fn lark_audio_skips_when_manager_none() {
4546 let ch = make_channel();
4547 assert!(ch.transcription_manager.is_none());
4548
4549 let payload = serde_json::json!({
4550 "header": {
4551 "event_type": "im.message.receive_v1"
4552 },
4553 "event": {
4554 "sender": {
4555 "sender_id": { "open_id": "ou_testuser123" }
4556 },
4557 "message": {
4558 "message_id": "om_audio_1",
4559 "message_type": "audio",
4560 "content": "{\"file_key\":\"fk_abc123\"}",
4561 "chat_id": "oc_chat1",
4562 "chat_type": "p2p",
4563 "mentions": [],
4564 "create_time": "1699999999000"
4565 }
4566 }
4567 });
4568
4569 let msgs = ch.parse_event_payload_async(&payload).await;
4570 assert!(msgs.is_empty());
4571 }
4572
4573 #[tokio::test]
4574 async fn lark_audio_routes_through_transcription_manager() {
4575 use wiremock::matchers::{method, path_regex};
4576 use wiremock::{Mock, MockServer, ResponseTemplate};
4577
4578 let mock_server = MockServer::start().await;
4579
4580 Mock::given(method("POST"))
4582 .and(path_regex("/auth/v3/tenant_access_token/internal"))
4583 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
4584 "code": 0,
4585 "tenant_access_token": "test-tenant-token",
4586 "expire": 7200
4587 })))
4588 .mount(&mock_server)
4589 .await;
4590
4591 Mock::given(method("GET"))
4593 .and(path_regex("/im/v1/messages/.+/resources/.+"))
4594 .respond_with(ResponseTemplate::new(200).set_body_bytes(vec![0u8; 128]))
4595 .mount(&mock_server)
4596 .await;
4597
4598 let whisper_server = MockServer::start().await;
4600 Mock::given(method("POST"))
4601 .and(path_regex("/v1/transcribe"))
4602 .respond_with(
4603 ResponseTemplate::new(200)
4604 .set_body_json(serde_json::json!({"text": "test transcript"})),
4605 )
4606 .mount(&whisper_server)
4607 .await;
4608
4609 let config = zeroclaw_config::schema::TranscriptionConfig {
4610 enabled: true,
4611 local_whisper: Some(zeroclaw_config::schema::LocalWhisperConfig {
4612 url: format!("{}/v1/transcribe", whisper_server.uri()),
4613 bearer_token: Some("test-token".to_string()),
4614 max_audio_bytes: 10 * 1024 * 1024,
4615 timeout_secs: 30,
4616 }),
4617 ..Default::default()
4618 };
4619
4620 let mut ch = make_channel();
4621 ch.api_base_override = Some(mock_server.uri());
4622 let ch = ch.with_transcription(config);
4623
4624 let payload = serde_json::json!({
4625 "header": {
4626 "event_type": "im.message.receive_v1"
4627 },
4628 "event": {
4629 "sender": {
4630 "sender_id": { "open_id": "ou_testuser123" }
4631 },
4632 "message": {
4633 "message_id": "om_audio_2",
4634 "message_type": "audio",
4635 "content": "{\"file_key\":\"fk_abc123\"}",
4636 "chat_id": "oc_chat1",
4637 "chat_type": "p2p",
4638 "mentions": [],
4639 "create_time": "1699999999000"
4640 }
4641 }
4642 });
4643
4644 let msgs = ch.parse_event_payload_async(&payload).await;
4645 assert_eq!(msgs.len(), 1);
4646 assert_eq!(msgs[0].content, "test transcript");
4647 }
4648
4649 #[tokio::test]
4650 async fn lark_audio_token_refresh_on_invalid_token_response() {
4651 use wiremock::matchers::{method, path_regex};
4652 use wiremock::{Mock, MockServer, ResponseTemplate};
4653
4654 let mock_server = MockServer::start().await;
4655
4656 Mock::given(method("POST"))
4658 .and(path_regex("/auth/v3/tenant_access_token/internal"))
4659 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
4660 "code": 0,
4661 "tenant_access_token": "refreshed-token",
4662 "expire": 7200
4663 })))
4664 .mount(&mock_server)
4665 .await;
4666
4667 Mock::given(method("GET"))
4669 .and(path_regex("/im/v1/messages/.+/resources/.+"))
4670 .respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({
4671 "code": 99_991_663,
4672 "msg": "token invalid"
4673 })))
4674 .up_to_n_times(1)
4675 .mount(&mock_server)
4676 .await;
4677
4678 Mock::given(method("GET"))
4679 .and(path_regex("/im/v1/messages/.+/resources/.+"))
4680 .respond_with(ResponseTemplate::new(200).set_body_bytes(vec![0u8; 64]))
4681 .mount(&mock_server)
4682 .await;
4683
4684 let mut ch = make_channel();
4685 ch.api_base_override = Some(mock_server.uri());
4686
4687 let result = ch.download_audio_resource("om_msg_1", "fk_audio_key").await;
4688 assert!(result.is_ok());
4689 let (bytes, filename) = result.unwrap();
4690 assert_eq!(bytes.len(), 64);
4691 assert_eq!(filename, "voice.m4a");
4692 }
4693
4694 #[test]
4699 fn build_approval_card_contains_all_three_buttons() {
4700 let card = build_approval_card("test-id", "shell", "rm -rf /tmp/foo");
4701
4702 assert_eq!(
4706 card.get("schema").and_then(|v| v.as_str()),
4707 Some("2.0"),
4708 "approval card must use Card JSON 2.0 schema"
4709 );
4710
4711 let columns = card
4712 .pointer("/body/elements/1/columns")
4713 .and_then(|v| v.as_array())
4714 .expect("column_set with columns missing");
4715 assert_eq!(
4716 columns.len(),
4717 3,
4718 "expected 3 button columns (Approve/Deny/Always)"
4719 );
4720
4721 let decisions: Vec<&str> = columns
4722 .iter()
4723 .filter_map(|c| {
4724 c.pointer("/elements/0/behaviors/0/value/decision")
4725 .and_then(|d| d.as_str())
4726 })
4727 .collect();
4728 assert_eq!(decisions, vec!["approve", "deny", "always"]);
4729 }
4730
4731 #[test]
4732 fn build_approval_card_round_trips_approval_id_in_all_buttons() {
4733 let card = build_approval_card("approval-abc-123", "tool", "args");
4734 let columns = card["body"]["elements"][1]["columns"]
4735 .as_array()
4736 .expect("columns array");
4737 for column in columns {
4738 assert_eq!(
4739 column["elements"][0]["behaviors"][0]["value"]["approval_id"],
4740 "approval-abc-123"
4741 );
4742 }
4743 }
4744
4745 #[test]
4746 fn build_approval_card_and_resolved_card_share_schema_version() {
4747 use zeroclaw_api::channel::ChannelApprovalResponse;
4748
4749 let send_card = build_approval_card("id", "shell", "args");
4750 let patch_card =
4751 build_resolved_approval_card("shell", "args", ChannelApprovalResponse::Approve);
4752
4753 let send_schema = send_card.get("schema").and_then(|v| v.as_str());
4754 let patch_schema = patch_card.get("schema").and_then(|v| v.as_str());
4755
4756 assert_eq!(
4757 send_schema, patch_schema,
4758 "send-time approval card and PATCH-time resolved card MUST use the same Card JSON schema; \
4759 Feishu's IM PATCH endpoint silently fails to re-render on the client when send/patch \
4760 schema versions differ"
4761 );
4762 assert_eq!(send_schema, Some("2.0"));
4763 }
4764
4765 #[test]
4766 fn build_resolved_approval_card_uses_decision_specific_banner() {
4767 use zeroclaw_api::channel::ChannelApprovalResponse;
4768
4769 for (decision, expected_template, expected_text_fragment) in [
4770 (ChannelApprovalResponse::Approve, "green", "Approved"),
4771 (
4772 ChannelApprovalResponse::AlwaysApprove,
4773 "green",
4774 "Approved (always)",
4775 ),
4776 (ChannelApprovalResponse::Deny, "red", "Denied"),
4777 ] {
4778 let card = build_resolved_approval_card("shell", "args", decision);
4779 assert_eq!(
4780 card.pointer("/header/template").and_then(|v| v.as_str()),
4781 Some(expected_template),
4782 "decision={decision:?} should use header template {expected_template}"
4783 );
4784 let title = card
4785 .pointer("/header/title/content")
4786 .and_then(|v| v.as_str())
4787 .unwrap_or("");
4788 assert!(
4789 title.contains(expected_text_fragment),
4790 "decision={decision:?} header title `{title}` should contain `{expected_text_fragment}`"
4791 );
4792 }
4793 }
4794
4795 #[test]
4796 fn sanitize_card_action_payload_redacts_sensitive_fields() {
4797 let raw = serde_json::json!({
4798 "action": {
4799 "tag": "button",
4800 "value": {
4801 "approval_id": "2ecbcc0f-59f0-4216-ba1c-5b6f4deaf7c7",
4802 "decision": "approve"
4803 }
4804 },
4805 "context": {
4806 "open_chat_id": "oc_real_chat_id_LEAKED",
4807 "open_message_id": "om_real_msg_id_LEAKED"
4808 },
4809 "host": "im_message",
4810 "operator": {
4811 "open_id": "ou_real_user_id_LEAKED",
4812 "tenant_key": "real_tenant_key_LEAKED",
4813 "union_id": "on_real_union_id_LEAKED",
4814 "user_id": "real_user_id_LEAKED"
4815 },
4816 "token": "c-real_callback_token_LEAKED"
4817 });
4818
4819 let sanitized = sanitize_card_action_payload(&raw);
4820 let dumped = serde_json::to_string(&sanitized).expect("sanitized must serialize");
4821
4822 for forbidden in [
4823 "oc_real_chat_id_LEAKED",
4824 "om_real_msg_id_LEAKED",
4825 "ou_real_user_id_LEAKED",
4826 "real_tenant_key_LEAKED",
4827 "on_real_union_id_LEAKED",
4828 "real_user_id_LEAKED",
4829 "c-real_callback_token_LEAKED",
4830 ] {
4831 assert!(
4832 !dumped.contains(forbidden),
4833 "sanitized payload must not contain raw value {forbidden:?}; got {dumped}"
4834 );
4835 }
4836
4837 assert_eq!(sanitized["token"], "REDACTED_TOKEN");
4838 assert_eq!(
4839 sanitized["operator"]["open_id"],
4840 "REDACTED_OPERATOR_OPEN_ID"
4841 );
4842 assert_eq!(
4843 sanitized["operator"]["union_id"],
4844 "REDACTED_OPERATOR_UNION_ID"
4845 );
4846 assert_eq!(
4847 sanitized["operator"]["user_id"],
4848 "REDACTED_OPERATOR_USER_ID"
4849 );
4850 assert_eq!(
4851 sanitized["operator"]["tenant_key"],
4852 "REDACTED_OPERATOR_TENANT_KEY"
4853 );
4854 assert_eq!(
4855 sanitized["context"]["open_chat_id"],
4856 "REDACTED_OPEN_CHAT_ID"
4857 );
4858 assert_eq!(
4859 sanitized["context"]["open_message_id"],
4860 "REDACTED_OPEN_MESSAGE_ID"
4861 );
4862
4863 assert_eq!(
4864 sanitized["action"]["value"]["approval_id"],
4865 "2ecbcc0f-59f0-4216-ba1c-5b6f4deaf7c7"
4866 );
4867 assert_eq!(sanitized["action"]["value"]["decision"], "approve");
4868 assert_eq!(sanitized["action"]["tag"], "button");
4869 assert_eq!(sanitized["host"], "im_message");
4870
4871 assert_eq!(raw["token"], "c-real_callback_token_LEAKED");
4872 assert_eq!(raw["operator"]["open_id"], "ou_real_user_id_LEAKED");
4873 }
4874
4875 #[test]
4876 fn sanitize_card_action_payload_handles_missing_optional_fields() {
4877 let raw = serde_json::json!({
4878 "action": { "value": { "approval_id": "x", "decision": "approve" } }
4879 });
4880 let sanitized = sanitize_card_action_payload(&raw);
4881 assert!(sanitized.get("token").is_none());
4882 assert!(sanitized.get("operator").is_none());
4883 assert!(sanitized.get("context").is_none());
4884 assert_eq!(sanitized["action"]["value"]["decision"], "approve");
4885 }
4886
4887 #[test]
4888 fn sanitize_card_action_payload_redacts_committed_fixtures() {
4889 let fixtures: [(&str, &str); 3] = [
4890 (
4891 "card_action_approve.json",
4892 include_str!("../tests/fixtures/lark/card_action_approve.json"),
4893 ),
4894 (
4895 "card_action_deny.json",
4896 include_str!("../tests/fixtures/lark/card_action_deny.json"),
4897 ),
4898 (
4899 "card_action_always.json",
4900 include_str!("../tests/fixtures/lark/card_action_always.json"),
4901 ),
4902 ];
4903 for (name, raw_text) in fixtures {
4904 let raw: serde_json::Value = serde_json::from_str(raw_text)
4905 .unwrap_or_else(|e| panic!("parse fixture {name}: {e}"));
4906 let sanitized = sanitize_card_action_payload(&raw);
4907 let dumped =
4908 serde_json::to_string(&sanitized).expect("sanitized fixture must serialize");
4909 for placeholder_field in [
4910 "REDACTED_TOKEN",
4911 "REDACTED_OPERATOR_OPEN_ID",
4912 "REDACTED_OPEN_CHAT_ID",
4913 ] {
4914 assert!(
4915 dumped.contains(placeholder_field),
4916 "sanitizer output for {name} must contain {placeholder_field}; got {dumped}"
4917 );
4918 }
4919 }
4920 }
4921
4922 #[tokio::test]
4923 async fn handle_card_action_event_routes_approve_to_pending_sender() {
4924 use zeroclaw_api::channel::ChannelApprovalResponse;
4925
4926 let ch = make_channel();
4927 let (tx, rx) = tokio::sync::oneshot::channel();
4928 let approval_id = "test-approval-1".to_string();
4929 ch.pending_approvals.lock().await.insert(
4930 approval_id.clone(),
4931 PendingApproval {
4932 sender: tx,
4933 message_id: String::new(),
4934 tool_name: String::new(),
4935 arguments_summary: String::new(),
4936 },
4937 );
4938
4939 let event = serde_json::json!({
4940 "action": {
4941 "value": { "approval_id": approval_id, "decision": "approve" },
4942 "tag": "button"
4943 }
4944 });
4945 ch.handle_card_action_event(&event)
4946 .await
4947 .expect("handler ok");
4948 let result = rx.await.expect("oneshot delivered");
4949 assert_eq!(result, ChannelApprovalResponse::Approve);
4950 }
4951
4952 #[tokio::test]
4953 async fn handle_card_action_event_parses_card_v2_behaviors_value_payload() {
4954 use zeroclaw_api::channel::ChannelApprovalResponse;
4955
4956 let ch = make_channel();
4960 let (tx, rx) = tokio::sync::oneshot::channel();
4961 let approval_id = "test-v2-approval".to_string();
4962 ch.pending_approvals.lock().await.insert(
4963 approval_id.clone(),
4964 PendingApproval {
4965 sender: tx,
4966 message_id: String::new(),
4967 tool_name: String::new(),
4968 arguments_summary: String::new(),
4969 },
4970 );
4971
4972 let event = serde_json::json!({
4973 "action": {
4974 "tag": "button",
4975 "behaviors": [{
4976 "type": "callback",
4977 "value": { "approval_id": approval_id, "decision": "always" }
4978 }]
4979 }
4980 });
4981 ch.handle_card_action_event(&event)
4982 .await
4983 .expect("handler ok");
4984 let result = rx.await.expect("oneshot delivered");
4985 assert_eq!(result, ChannelApprovalResponse::AlwaysApprove);
4986 }
4987
4988 #[tokio::test]
4989 async fn handle_card_action_event_for_unknown_approval_is_not_an_error() {
4990 let ch = make_channel();
4991 let event = serde_json::json!({
4992 "action": {
4993 "value": { "approval_id": "never-existed", "decision": "deny" }
4994 }
4995 });
4996 ch.handle_card_action_event(&event)
5000 .await
5001 .expect("unknown approval id should not error");
5002 }
5003 async fn mount_lark_token_and_send_mocks(mock_server: &wiremock::MockServer) {
5004 use wiremock::matchers::{method, path, query_param};
5005 use wiremock::{Mock, ResponseTemplate};
5006
5007 Mock::given(method("POST"))
5008 .and(path("/auth/v3/tenant_access_token/internal"))
5009 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
5010 "code": 0,
5011 "tenant_access_token": "test-tenant-token",
5012 "expire": 7200
5013 })))
5014 .mount(mock_server)
5015 .await;
5016
5017 Mock::given(method("POST"))
5018 .and(path("/im/v1/messages"))
5019 .and(query_param("receive_id_type", "chat_id"))
5020 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
5021 "code": 0,
5022 "data": { "message_id": "om_test_message_id" }
5023 })))
5024 .expect(1)
5025 .mount(mock_server)
5026 .await;
5027 }
5028
5029 async fn assert_send_body_matches_recipient_and_text(
5030 mock_server: &wiremock::MockServer,
5031 expected_recipient: &str,
5032 expected_text: &str,
5033 ) {
5034 let requests = mock_server
5035 .received_requests()
5036 .await
5037 .expect("mock server should record requests");
5038 let send_request = requests
5039 .iter()
5040 .find(|r| r.url.path() == "/im/v1/messages")
5041 .expect("expected at least one POST /im/v1/messages");
5042 assert_eq!(
5043 send_request.url.query(),
5044 Some("receive_id_type=chat_id"),
5045 "send URL must carry receive_id_type=chat_id query param"
5046 );
5047 let body: serde_json::Value =
5048 serde_json::from_slice(&send_request.body).expect("send body should be valid JSON");
5049 assert_eq!(
5050 body["receive_id"].as_str(),
5051 Some(expected_recipient),
5052 "receive_id must match the SendMessage recipient; full body: {body}"
5053 );
5054 assert_eq!(
5055 body["msg_type"].as_str(),
5056 Some("interactive"),
5057 "msg_type must be 'interactive'; full body: {body}"
5058 );
5059 let content_str = body["content"]
5060 .as_str()
5061 .expect("content must be a JSON string per Lark interactive-card spec");
5062 assert!(
5063 content_str.contains(expected_text),
5064 "card content should embed the message text {expected_text:?}; got: {content_str}"
5065 );
5066 }
5067
5068 #[tokio::test]
5069 async fn lark_send_via_from_config_emits_post_to_messages_endpoint() {
5070 let mock_server = wiremock::MockServer::start().await;
5071 mount_lark_token_and_send_mocks(&mock_server).await;
5072
5073 let config = zeroclaw_config::schema::LarkConfig {
5074 enabled: true,
5075 use_feishu: false,
5076 app_id: "cli_test_app_id".to_string(),
5077 app_secret: "test_app_secret".to_string(),
5078 ..Default::default()
5079 };
5080 let mut ch = LarkChannel::from_config(&config, "test_alias", resolver_from(vec![]));
5081 ch.api_base_override = Some(mock_server.uri());
5082
5083 assert_eq!(
5084 ch.name(),
5085 "lark",
5086 "use_feishu=false must keep the channel identity as 'lark'"
5087 );
5088
5089 let message = SendMessage::new("hi from cron", "oc_test_chat_id");
5090 Channel::send(&ch, &message)
5091 .await
5092 .expect("Channel::send should succeed against mocked Lark endpoint");
5093
5094 assert_send_body_matches_recipient_and_text(
5095 &mock_server,
5096 "oc_test_chat_id",
5097 "hi from cron",
5098 )
5099 .await;
5100 }
5101
5102 #[tokio::test]
5103 async fn feishu_send_via_from_config_emits_post_to_messages_endpoint() {
5104 let mock_server = wiremock::MockServer::start().await;
5105 mount_lark_token_and_send_mocks(&mock_server).await;
5106
5107 let config = zeroclaw_config::schema::LarkConfig {
5108 enabled: true,
5109 use_feishu: true,
5110 app_id: "cli_test_app_id".to_string(),
5111 app_secret: "test_app_secret".to_string(),
5112 ..Default::default()
5113 };
5114 let mut ch = LarkChannel::from_config(&config, "test_alias", resolver_from(vec![]));
5115 ch.api_base_override = Some(mock_server.uri());
5116
5117 assert_eq!(
5118 ch.name(),
5119 "feishu",
5120 "use_feishu=true must surface the channel identity as 'feishu' \
5121 (registry key alignment — see orchestrator::deliver_announcement)"
5122 );
5123
5124 let message = SendMessage::new("hi from cron", "oc_test_chat_id");
5125 Channel::send(&ch, &message)
5126 .await
5127 .expect("Channel::send should succeed against mocked Feishu endpoint");
5128
5129 assert_send_body_matches_recipient_and_text(
5130 &mock_server,
5131 "oc_test_chat_id",
5132 "hi from cron",
5133 )
5134 .await;
5135 }
5136}