Skip to main content

zeroclaw_channels/
lark.rs

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// ─────────────────────────────────────────────────────────────────────────────
105// Feishu WebSocket long-connection: pbbp2.proto frame codec
106// ─────────────────────────────────────────────────────────────────────────────
107
108#[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/// Feishu WS frame (pbbp2.proto).
117/// method=0 → CONTROL (ping/pong)  method=1 → DATA (events)
118#[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/// Server-sent client config (parsed from pong payload)
145#[derive(Debug, serde::Deserialize, Default, Clone)]
146struct WsClientConfig {
147    #[serde(rename = "PingInterval")]
148    ping_interval: Option<u64>,
149}
150
151/// POST /callback/ws/endpoint response
152#[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/// LarkEvent envelope (method=1 / type=event payload)
170#[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
213/// Heartbeat timeout for WS connection — must be larger than ping_interval (default 120 s).
214/// If no binary frame (pong or event) is received within this window, reconnect.
215const WS_HEARTBEAT_TIMEOUT: Duration = Duration::from_secs(300);
216/// Refresh tenant token this many seconds before the announced expiry.
217const LARK_TOKEN_REFRESH_SKEW: Duration = Duration::from_secs(120);
218/// Fallback tenant token TTL when `expire`/`expires_in` is absent.
219const LARK_DEFAULT_TOKEN_TTL: Duration = Duration::from_secs(7200);
220/// Feishu/Lark API business code for expired/invalid tenant access token.
221const LARK_INVALID_ACCESS_TOKEN_CODE: i64 = 99_991_663;
222
223/// Feishu/Lark API business code returned when a card PATCH (or any draft
224/// message edit) is rate-limited. Treated as a soft-failure: we log a warning
225/// but never propagate to the caller, since the user-visible decision is
226/// already delivered out-of-band via the approval oneshot.
227const LARK_DRAFT_RATE_LIMIT_CODE: i64 = 230_020;
228
229/// Max byte size for a single interactive card's markdown content.
230/// Lark card payloads have a ~30 KB limit; leave margin for JSON envelope.
231const LARK_CARD_MARKDOWN_MAX_BYTES: usize = 28_000;
232
233/// Maximum image size we will download and inline (5 MiB).
234const LARK_IMAGE_MAX_BYTES: usize = 5 * 1024 * 1024;
235
236/// Maximum file size we will download and present as text (512 KiB).
237const LARK_FILE_MAX_BYTES: usize = 512 * 1024;
238
239/// Image MIME types we support for inline base64 encoding.
240const LARK_SUPPORTED_IMAGE_MIMES: &[&str] = &[
241    "image/png",
242    "image/jpeg",
243    "image/gif",
244    "image/webp",
245    "image/bmp",
246];
247
248/// Returns true when the WebSocket frame indicates live traffic that should
249/// refresh the heartbeat watchdog.
250fn should_refresh_last_recv(msg: &WsMsg) -> bool {
251    matches!(msg, WsMsg::Binary(_) | WsMsg::Ping(_) | WsMsg::Pong(_))
252}
253
254/// Build an interactive card JSON string with a single markdown element.
255/// Uses Card JSON 2.0 structure so that headings, tables, blockquotes,
256/// and inline code render correctly.
257fn 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
270/// Build an approval-request interactive card (Card JSON 2.0).
271///
272/// Card 2.0 is required so PATCH-time updates from
273/// `build_resolved_approval_card` can re-render the card on the user's
274/// client. Feishu's IM PATCH endpoint accepts cross-version PATCH
275/// (1.0 send → 2.0 patch) with `code: 0` but does NOT guarantee the
276/// client re-renders; the same schema must be used on both sides.
277///
278/// Each button's `behaviors[0].value.approval_id` round-trips back via
279/// the `card.action.trigger` event, parsed by `handle_card_action_event`.
280fn 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
336/// Resolved-state rendering of the approval card (no buttons, decision banner).
337///
338/// Uses Card JSON 2.0 schema (matching `build_card_content`) because the
339/// Feishu IM PATCH endpoint accepts Card 1.0 envelopes with `code: 0` but
340/// silently refuses to re-render the client-side card. Using Card 2.0 (the
341/// schema that the production-validated `build_card_content` uses) is what
342/// actually causes the visual update to land on the user's screen.
343fn 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
379/// Build a sanitized copy of a `card.action.trigger` event payload that is
380/// safe to emit to structured logs / dashboards / persisted JSONL.
381///
382/// The raw inbound payload from Lark/Feishu carries tenant-specific
383/// identifiers and a callback verification token. These values are
384/// classified as PII / callback secrets by the project's privacy policy
385/// (see each fixture's `_fixture_note` under `tests/fixtures/lark/` for the
386/// authoritative list of fields that must be redacted before any
387/// persistence).
388///
389/// This function replaces the following with deterministic `REDACTED_*`
390/// placeholder strings:
391///
392/// - top-level `token` (Lark callback verification token)
393/// - `operator.open_id` / `union_id` / `user_id` / `tenant_key`
394/// - `context.open_chat_id` / `context.open_message_id`
395///
396/// Non-sensitive business fields (`action.*`, `host`, etc.) are preserved
397/// verbatim so DEBUG operators can still capture production payload shape
398/// for fixture collection.
399///
400/// The input is borrowed read-only; a fresh owned `Value` is returned. The
401/// regression test `sanitize_card_action_payload_redacts_sensitive_fields`
402/// is the gate that fails if any of those raw values can leak through this
403/// path.
404fn 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    // Top-level callback verification token.
410    if let Some(token) = sanitized.get_mut("token")
411        && !token.is_null()
412    {
413        *token = Value::String("REDACTED_TOKEN".to_string());
414    }
415
416    // operator.* identifiers — only overwrite keys that are actually present
417    // so the sanitized payload still reflects production shape (don't
418    // invent fields that the real event didn't carry).
419    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    // context.open_* identifiers.
433    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
447/// Build the full message body for sending an interactive card message.
448fn 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
456/// Split markdown content into chunks that fit within the card size limit.
457/// Splits on line boundaries to avoid breaking markdown syntax.
458fn 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
561/// State carried between sending an approval card and the user's click.
562///
563/// Used to (a) wake the awaiting future via `sender` and (b) re-render
564/// the card after the click so the buttons disappear.
565struct PendingApproval {
566    sender: tokio::sync::oneshot::Sender<zeroclaw_api::channel::ChannelApprovalResponse>,
567    /// `data.message_id` returned by the send-card POST. Empty string is a
568    /// sentinel meaning "card was sent but message_id was missing from the
569    /// response" — handler will skip the post-click PATCH in that case.
570    message_id: String,
571    tool_name: String,
572    arguments_summary: String,
573}
574
575/// Lark/Feishu channel.
576///
577/// Supports two receive modes (configured via `receive_mode` in config):
578/// - **`websocket`** (default): persistent WSS long-connection; no public URL needed.
579/// - **`webhook`**: HTTP callback server; requires a public HTTPS endpoint.
580#[derive(Clone)]
581pub struct LarkChannel {
582    app_id: String,
583    app_secret: String,
584    verification_token: String,
585    port: Option<u16>,
586    /// The alias key under `[channels.lark.<alias>]` this handle is bound to.
587    /// Used to scope peer-group writes and resolver lookups. (Pre-V3 Feishu
588    /// blocks are folded into `[channels.lark]` with `use_feishu = true`.)
589    alias: String,
590    /// Resolves inbound external peers from canonical state at message-time.
591    /// No cache (see AGENTS.md "ABSOLUTE RULE — SINGLE SOURCE OF TRUTH").
592    peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync>,
593    /// Bot open_id resolved at runtime via `/bot/v3/info`.
594    resolved_bot_open_id: Arc<StdRwLock<Option<String>>>,
595    mention_only: bool,
596    /// Platform variant: Lark (international) or Feishu (CN).
597    platform: LarkPlatform,
598    /// How to receive events: WebSocket long-connection or HTTP webhook.
599    receive_mode: zeroclaw_config::schema::LarkReceiveMode,
600    /// Cached tenant access token
601    tenant_token: Arc<RwLock<Option<CachedTenantToken>>>,
602    /// Dedup set: WS message_ids seen in last ~30 min to prevent double-dispatch
603    ws_seen_ids: Arc<RwLock<HashMap<String, Instant>>>,
604    /// Per-channel proxy URL override.
605    proxy_url: Option<String>,
606    transcription: Option<zeroclaw_config::schema::TranscriptionConfig>,
607    transcription_manager: Option<Arc<super::transcription::TranscriptionManager>>,
608    /// In-flight approval requests keyed by `approval_id` (UUID v4).
609    /// Populated by `request_approval`, drained by `handle_card_action_event`.
610    pending_approvals: Arc<tokio::sync::Mutex<std::collections::HashMap<String, PendingApproval>>>,
611    /// Seconds to wait for the user's button click before auto-denying.
612    /// Currently hard-coded to 120; lift to `LarkConfig` when a use case
613    /// for per-channel overrides arises.
614    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    /// Return the alias under `[channels.lark.<alias>]` that this
642    /// channel handle is bound to.
643    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    /// Build from `LarkConfig` using legacy compatibility:
681    /// when `use_feishu=true`, this instance routes to Feishu endpoints.
682    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                // Bind the sole registered provider as the agent transcription
717                // provider for the channel-direct ingest path. Multi-provider
718                // setups still resolve via the orchestrator's per-agent
719                // routing (see orchestrator/mod.rs). See wati.rs for full
720                // rationale.
721                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    /// PATCH endpoint for updating the content of a previously-sent message
780    /// (used to flip an approval card from its interactive state to its
781    /// resolved/banner state after the user clicks a button).
782    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    /// Best-effort "received" signal for incoming messages.
840    /// Failures are logged and never block normal message handling.
841    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    /// POST /callback/ws/endpoint → (wss_url, client_config)
914    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    /// WS long-connection event loop.  Returns Ok(()) when the connection closes
947    /// (the caller reconnects).
948    #[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; // consume immediate tick
987
988        let mut seq: u64 = 0;
989        let mut last_recv = Instant::now();
990
991        // Send initial ping immediately (like the official SDK) so the server
992        // starts responding with pongs and we can calibrate the ping_interval.
993        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        // message_id → (fragment_slots, created_at) for multi-part reassembly
1013        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                    // GC stale fragments > 5 min
1032                    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                    // CONTROL frame
1066                    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                    // DATA frame
1082                    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                    // ACK immediately (Feishu requires within 3 s)
1088                    {
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                    // Fragment reassembly
1096                    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                    // Dedup
1157                    {
1158                        let now = Instant::now();
1159                        let mut seen = self.ws_seen_ids.write().await;
1160                        // GC
1161                        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                    // Decode content by type (mirrors clawdbot-feishu parsing)
1170                    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                    // Group-chat: only respond when explicitly @-mentioned
1247                    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    /// Check if a user open_id is allowed
1295    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    /// Get or refresh tenant access token
1301    async fn get_tenant_access_token(&self) -> anyhow::Result<String> {
1302        // Check cache first
1303        {
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        // Cache it with proactive refresh metadata.
1353        {
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    /// Invalidate cached token (called when API reports an expired tenant token).
1365    async fn invalidate_token(&self) {
1366        let mut cached = self.tenant_token.write().await;
1367        *cached = None;
1368    }
1369
1370    /// Download an image from the Lark API and return an `[IMAGE:data:...]` marker string.
1371    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    /// Download a file from the Lark API and return a text content marker.
1487    /// For text-like files, the content is inlined. For binary files, a summary is returned.
1488    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 the content is image-like, return as image marker
1594        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 the file looks like text, inline it
1604        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    /// Parse an event callback payload and extract messages.
1970    /// Supports text, post, image, and file message types.
1971    pub async fn parse_event_payload(&self, payload: &serde_json::Value) -> Vec<ChannelMessage> {
1972        let mut messages = Vec::new();
1973
1974        // Lark event v2 structure:
1975        // { "header": { "event_type": "im.message.receive_v1" }, "event": { "message": { ... }, "sender": { ... } } }
1976        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        // Extract sender open_id
1991        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        // Check allowlist
2001        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        // Extract message content (text and post supported)
2013        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            // Lark timestamps are in milliseconds
2185            .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                // Token expired/invalid, invalidate and retry once.
2243                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    /// Wait for the user's approval click; on timeout, evict the pending entry
2343    /// and synthesize a `Deny` response. Never panics.
2344    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    /// PATCH an approval card to its resolved state. Soft-fails on every error
2360    /// path (transport / token refresh / rate-limited / non-zero code) — never
2361    /// propagates to the caller, since the user-visible decision is already
2362    /// delivered via the oneshot.
2363    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    /// Single-shot HTTP request used by `patch_approval_card_resolved`. Builds
2484    /// PATCH (when `is_patch=true`) or POST request with current tenant token,
2485    /// returns parsed JSON body and the HTTP status. Caller decides whether to
2486    /// retry on token refresh.
2487    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    /// Handle a `card.action.trigger` event: parse `approval_id` + `decision`
2513    /// from `event.action.value` (or `event.action.behaviors[0].value` for
2514    /// Card 2.0 button click events), resolve the pending oneshot, and
2515    /// forward the response. Unknown / expired approval IDs are silently
2516    /// dropped (info-log only).
2517    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        // Diagnostic: emit a SANITIZED copy of the inbound payload at DEBUG
2524        // so operators can capture real Lark/Feishu `card.action.trigger`
2525        // shape evidence for fixture collection WITHOUT leaking
2526        // tenant-specific identifiers (token, operator.*, context.open_*)
2527        // to runtime logs / dashboards / persisted JSONL.
2528        //
2529        // `sanitize_card_action_payload` replaces those fields with
2530        // deterministic `REDACTED_*` placeholders before the value reaches
2531        // `record!`. The regression test
2532        // `sanitize_card_action_payload_redacts_sensitive_fields` will fail
2533        // if any of those raw values can leak through this path again.
2534        //
2535        // Default production RUST_LOG (=info) leaves this off, so it costs
2536        // nothing at runtime; opt in with:
2537        //
2538        //   RUST_LOG=info,zeroclaw_log_event=debug
2539        //
2540        // Captured payloads should land in
2541        // `crates/zeroclaw-channels/tests/fixtures/lark/` and are replayed
2542        // by the integration test in `tests/lark_approval_live_evidence.rs`.
2543        ::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        // Feishu Card 2.0 button click events MAY round-trip the button value at
2554        // `event.action.behaviors[0].value` instead of `event.action.value`
2555        // (the Card 1.0 path). Both pointers are accepted for forward-compat;
2556        // captured fixtures under `tests/fixtures/lark/` lock the shape that
2557        // production currently emits.
2558        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    /// HTTP callback server (legacy — requires a public endpoint).
2661    /// Use `listen()` (WS long-connection) for new deployments.
2662    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            // URL verification challenge
2684            if let Some(challenge) = payload.get("challenge").and_then(|c| c.as_str()) {
2685                // Verify token if present
2686                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            // Card button click events are not message events — route them
2700            // through the approval-card resolver and short-circuit before the
2701            // generic message parser sees them.
2702            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            // Parse event messages
2725            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
2794// ─────────────────────────────────────────────────────────────────────────────
2795// WS helper functions
2796// ─────────────────────────────────────────────────────────────────────────────
2797
2798fn 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 | // Hiragana
2913        0x30A0..=0x30FF | // Katakana
2914        0x31F0..=0x31FF // Katakana Phonetic Extensions
2915    )
2916}
2917
2918fn is_cjk_han(ch: char) -> bool {
2919    matches!(
2920        ch as u32,
2921        0x3400..=0x4DBF | // CJK Extension A
2922        0x4E00..=0x9FFF // CJK Unified Ideographs
2923    )
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
3006/// Detect image MIME type from magic bytes, falling back to Content-Type header.
3007fn 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
3029/// Check if a filename looks like a text file based on extension.
3030fn 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
3080/// Flatten a Feishu `post` rich-text message to plain text.
3081///
3082/// Returns `None` when the content cannot be parsed or yields no usable text,
3083/// so callers can simply `continue` rather than forwarding a meaningless
3084/// placeholder string to the agent.
3085struct 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                            // Some Feishu rich-text tags (for example `md`) still carry useful
3151                            // human text in a `text` field. Keep that text instead of dropping
3152                            // the whole message as empty.
3153                            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
3175/// Parse Feishu `list` message content into plain-text bullet lines.
3176///
3177/// Feishu sends list/bullet content as a JSON structure with nested items,
3178/// each containing inline elements (text, links, etc.).  We flatten them
3179/// into `"- item"` lines separated by newlines.
3180fn parse_list_content(content: &str) -> Option<String> {
3181    let parsed = serde_json::from_str::<serde_json::Value>(content).ok()?;
3182
3183    // The top-level structure may contain an "items" array directly, or the
3184    // items might be under a "content" key.  Walk both shapes.
3185    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
3201/// Recursively collect list item text.  Each item may itself contain nested
3202/// sub-lists via a `"children"` field.
3203fn collect_list_items(items: &[serde_json::Value], lines: &mut Vec<String>, depth: usize) {
3204    let indent = "  ".repeat(depth);
3205    for item in items {
3206        // Each item can be an array of inline elements, or an object with
3207        // "content" (inline elements array) and optional "children" (sub-items).
3208        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            // Handle flat inline elements or nested arrays of inline elements
3225            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
3245/// Extract text from a single Feishu inline element (text, link, at-mention).
3246fn 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
3283/// In group chats, only respond when the bot is explicitly @-mentioned.
3284fn 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        // Challenge payloads should not produce messages
3503        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        // Flat structure: items is an array of arrays of inline elements
3587        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        // Nested structure: items are objects with content + children
3595        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        // When chat_id is missing, sender should fall back to open_id
3949        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        // Unknown bytes should fall back to content-type header
4195        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        // Non-image content-type should be rejected
4202        assert_eq!(lark_detect_image_mime(Some("text/html"), &unknown), None);
4203
4204        // No info at all should return None
4205        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"; // 11 bytes per line
4353        let text = line.repeat(10); // 110 bytes total
4354        let chunks = split_markdown_chunks(&text, 33); // ~3 lines per chunk
4355        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 the tenant access token endpoint
4581        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 the audio resource download endpoint
4592        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        // Mock whisper transcription endpoint
4599        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        // Token endpoint always returns valid token
4657        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        // Resource endpoint: first call returns 401, second returns audio bytes
4668        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    // ─────────────────────────────────────────────────────────────────────
4695    // Card 2.0 approval card tests
4696    // ─────────────────────────────────────────────────────────────────────
4697
4698    #[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        // Card 2.0 schema lock — guard against future regressions where the
4703        // send-side schema drifts back to 1.0 (which Feishu's PATCH endpoint
4704        // silently refuses to re-render after the click).
4705        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        // Card 2.0 button click events MAY round-trip via
4957        // event.action.behaviors[0].value instead of event.action.value.
4958        // Verify the dual-pointer fallback.
4959        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        // Unknown approval IDs are dropped silently (info-log only); the
4997        // handler must NOT propagate an error to the caller, since stray
4998        // clicks (resent after restart) are routine.
4999        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}