Skip to main content

bamboo_server/connect/platforms/feishu/
mod.rs

1//! Feishu/Lark event long-connection platform adapter (epic #447 phase 3).
2//!
3//! No public IP / webhook: inbound events arrive over a WS long-connection
4//! (`ws.rs`); outbound sends and the `tenant_access_token` cache go over
5//! plain REST (`api.rs`); the wire frame shape is `pbbp2.rs`. See
6//! `docs/feishu-adapter-plan.md` §2c for the full protocol writeup this
7//! module implements.
8//!
9//! Module split:
10//! - `pbbp2.rs` — vendored `Frame`/`Header` proto2 wire types.
11//! - `ws.rs` — the long-connection client (bootstrap/ping/ack/reassembly/
12//!   reconnect), ignorant of Feishu's EVENT semantics (that's this file's
13//!   job, via the [`ws::EventSink`] trait).
14//! - `api.rs` — REST: token cache, send/update message, bot self-info, the
15//!   per-chat rate limiter.
16//! - `mod.rs` (this file) — [`FeishuPlatform`]: the `Platform` impl, inbound
17//!   event → `InboundMessage`/`CallbackQuery` mapping, outbound card
18//!   building, and the card-callback pending-ack map.
19
20mod api;
21mod pbbp2;
22mod ws;
23
24use std::collections::HashMap;
25use std::sync::Arc;
26use std::time::Duration;
27
28use tokio::sync::{mpsc, oneshot, Mutex as AsyncMutex};
29
30use super::super::platform::{
31    Button, CallbackQuery, Capabilities, Inbound, InboundMessage, MessageRef, OutboundMessage,
32    Platform, PlatformError, PlatformResult, ReplyCtx,
33};
34use super::super::render::{chunk_message, MAX_MESSAGE_CHARS};
35
36/// Default outgoing rate limit: 1 message/second per chat (same
37/// conservative default as `telegram.rs`; Feishu's actual documented limits
38/// are more generous — 5 QPS/user in a p2p chat — but 1/s is a safe,
39/// simple default and matches the existing adapter's precedent).
40const DEFAULT_RATE_LIMIT_INTERVAL: Duration = Duration::from_secs(1);
41/// How long a `card.action.trigger`'s pending-ack waits for the bridge to
42/// call `answer_callback` before auto-acking with a bare `{"code":200}`
43/// (empty `data`). Kept comfortably under `ws.rs::ACK_HARD_DEADLINE`
44/// (2.9s) so the WS transport always has headroom to get the frame back.
45const ACK_SOFT_DEADLINE: Duration = Duration::from_millis(2500);
46/// The fixed `element_id` on every card's markdown body element (so
47/// `edit()`'s PATCH always targets the same element — not load-bearing for
48/// Feishu's API, which replaces the whole `content` string, but documents
49/// the stable shape).
50const MAIN_TEXT_ELEMENT_ID: &str = "main_text";
51
52pub struct FeishuPlatform {
53    app_id: String,
54    app_secret: String,
55    base_url: String,
56    tokens: api::TokenCache,
57    rate_limiter: api::RateLimiter,
58    /// This app's own `open_id`, fetched once in `start()` via
59    /// `GET /open-apis/bot/v3/info` — used for @mention detection in group
60    /// chats. `None` until fetched (or forever, if the fetch failed — group
61    /// messages are then treated as unmentioned/dropped, per plan).
62    bot_open_id: Arc<AsyncMutex<Option<String>>>,
63    /// `card.action.trigger` events parked here (keyed by `event_id`,
64    /// Feishu's `callback_query_id`) while `start()`'s WS event handler
65    /// waits for the bridge to call `answer_callback`.
66    pending_acks: Arc<AsyncMutex<HashMap<String, oneshot::Sender<serde_json::Value>>>>,
67    /// Test seam: skip the real WS client entirely (`start()` returns
68    /// `Ok(())` immediately) so REST-only tests (reply/edit/rate-limit)
69    /// never touch the network for the long-connection.
70    ws_disabled: bool,
71}
72
73impl FeishuPlatform {
74    /// Production constructor. `base_url` is the ALREADY-RESOLVED REST/WS
75    /// bootstrap base (e.g. `https://open.feishu.cn`) — the
76    /// `feishu`/`lark`/custom-URL `domain` config field is resolved by the
77    /// server's registration arm (`connect/mod.rs`, owned by another agent
78    /// for this epic), not here; this constructor only ever sees a plain
79    /// string.
80    pub fn new(app_id: String, app_secret: String, base_url: String) -> Self {
81        Self::with_options(
82            app_id,
83            app_secret,
84            base_url,
85            DEFAULT_RATE_LIMIT_INTERVAL,
86            false,
87        )
88    }
89
90    /// Test/advanced constructor: override the rate-limit interval (kept
91    /// tiny in tests) and optionally disable the WS client entirely
92    /// (`ws_disabled`) so `start()` is a no-op — REST-only tests construct
93    /// the platform this way and never call `start()`'s WS half.
94    pub fn with_options(
95        app_id: String,
96        app_secret: String,
97        base_url: String,
98        rate_limit_interval: Duration,
99        ws_disabled: bool,
100    ) -> Self {
101        Self {
102            app_id,
103            app_secret,
104            base_url,
105            tokens: api::TokenCache::new(),
106            rate_limiter: api::RateLimiter::new(rate_limit_interval),
107            bot_open_id: Arc::new(AsyncMutex::new(None)),
108            pending_acks: Arc::new(AsyncMutex::new(HashMap::new())),
109            ws_disabled,
110        }
111    }
112
113    fn extract_chat_id(ctx: &serde_json::Value) -> PlatformResult<String> {
114        ctx.get("chat_id")
115            .and_then(|v| v.as_str())
116            .map(|s| s.to_string())
117            .ok_or_else(|| PlatformError::other("reply_ctx is missing chat_id"))
118    }
119
120    fn extract_message_id(msg_ref: &serde_json::Value) -> PlatformResult<String> {
121        msg_ref
122            .get("message_id")
123            .and_then(|v| v.as_str())
124            .map(|s| s.to_string())
125            .ok_or_else(|| PlatformError::other("message_ref is missing message_id"))
126    }
127}
128
129#[async_trait::async_trait]
130impl Platform for FeishuPlatform {
131    fn name(&self) -> &str {
132        "feishu"
133    }
134
135    fn capabilities(&self) -> Capabilities {
136        Capabilities {
137            buttons: true,
138            edit_message: true,
139            images: false,
140            files: false,
141        }
142    }
143
144    async fn start(&self, inbound: mpsc::Sender<Inbound>) -> PlatformResult<()> {
145        if self.ws_disabled {
146            return Ok(());
147        }
148
149        match api::fetch_bot_open_id(&self.tokens, &self.base_url, &self.app_id, &self.app_secret)
150            .await
151        {
152            Ok(open_id) => {
153                *self.bot_open_id.lock().await = Some(open_id);
154            }
155            Err(error) => {
156                tracing::warn!(
157                    "connect: feishu bot/v3/info failed ({error}); group messages will be \
158                     treated as unmentioned (dropped) until this succeeds"
159                );
160            }
161        }
162
163        let sink: Arc<dyn ws::EventSink> = Arc::new(FeishuEventSink {
164            bot_open_id: self.bot_open_id.clone(),
165            pending_acks: self.pending_acks.clone(),
166            inbound,
167            ack_soft_deadline: ACK_SOFT_DEADLINE,
168        });
169
170        ws::run_with_reconnect(
171            self.app_id.clone(),
172            self.app_secret.clone(),
173            self.base_url.clone(),
174            sink,
175        )
176        .await
177    }
178
179    async fn reply(&self, ctx: &ReplyCtx, msg: OutboundMessage) -> PlatformResult<MessageRef> {
180        let chat_id = Self::extract_chat_id(&ctx.0)?;
181        let chunks = chunk_message(&msg.text, MAX_MESSAGE_CHARS);
182        let chunk_count = chunks.len();
183        let mut last_message_id = String::new();
184
185        for (index, chunk) in chunks.into_iter().enumerate() {
186            self.rate_limiter.wait(&format!("chat:{chat_id}")).await;
187
188            // ALWAYS an interactive card (never plain "text"): render.rs
189            // calls `reply()` for the initial streaming status message too,
190            // and `edit()` can only PATCH a card — never a text message (the
191            // text PUT has a 20-edit cap). Sending a uniform card shape for
192            // every reply means this adapter never has to guess which call
193            // site it's serving. See `docs/feishu-adapter-plan.md` §2c
194            // outbound decision 4.
195            let buttons = if index + 1 == chunk_count {
196                msg.buttons.as_deref()
197            } else {
198                None
199            };
200            let card = build_card(&chunk, buttons);
201            let content = card.to_string();
202
203            last_message_id = api::send_message(
204                &self.tokens,
205                &self.base_url,
206                &self.app_id,
207                &self.app_secret,
208                &chat_id,
209                "interactive",
210                &content,
211            )
212            .await?;
213        }
214
215        Ok(MessageRef(serde_json::json!({
216            "chat_id": chat_id,
217            "message_id": last_message_id,
218        })))
219    }
220
221    async fn edit(&self, msg_ref: &MessageRef, new: OutboundMessage) -> PlatformResult<()> {
222        let message_id = Self::extract_message_id(&msg_ref.0)?;
223        self.rate_limiter.wait(&format!("msg:{message_id}")).await;
224
225        let card = build_card(&new.text, new.buttons.as_deref());
226        api::update_card(
227            &self.tokens,
228            &self.base_url,
229            &self.app_id,
230            &self.app_secret,
231            &message_id,
232            &card.to_string(),
233        )
234        .await
235    }
236
237    async fn answer_callback(
238        &self,
239        callback_query_id: &str,
240        text: Option<&str>,
241    ) -> PlatformResult<()> {
242        // NOT rate-limited: this resolves a parked WS frame ack, not a REST
243        // call — see `ws.rs::EventSink`/`FeishuEventSink::handle_card_action_event`.
244        let sender = self.pending_acks.lock().await.remove(callback_query_id);
245        if let Some(sender) = sender {
246            let value = match text {
247                Some(text) => serde_json::json!({ "toast": { "type": "info", "content": text } }),
248                None => serde_json::json!({}),
249            };
250            // A send error just means the WS event handler already gave up
251            // waiting (past its own soft deadline) — nothing left to do.
252            let _ = sender.send(value);
253        }
254        Ok(())
255    }
256
257    async fn stop(&self) -> PlatformResult<()> {
258        // Best-effort no-op, matching `telegram.rs`'s precedent: real
259        // cancellation happens via `ConnectManager`'s `Drop`, which aborts
260        // the JoinHandle running `start()` — that unwinds the whole WS
261        // connection (and its ping/ack machinery) at once.
262        Ok(())
263    }
264}
265
266// ---------------------------------------------------------------------
267// Outbound: card building
268// ---------------------------------------------------------------------
269
270/// Escapes characters Feishu's card markdown element treats specially, so
271/// arbitrary agent output (which render.rs treats as PLAIN TEXT — see
272/// `platform.rs`'s `OutboundMessage` doc, there is no markdown flag) never
273/// gets reinterpreted as markdown syntax or a `<at>`/`<a>` tag. This is a
274/// deliberately simple backslash-escape of the documented Feishu markdown
275/// special characters — NOT a general HTML/markdown sanitizer, and not
276/// exhaustive of every card-markdown edge case, but sufficient for MVP text
277/// (tool output, assistant replies) which is prose, not hand-crafted markup.
278fn escape_feishu_markdown(text: &str) -> String {
279    let mut escaped = String::with_capacity(text.len());
280    for ch in text.chars() {
281        if matches!(
282            ch,
283            '\\' | '`'
284                | '*'
285                | '_'
286                | '~'
287                | '#'
288                | '+'
289                | '-'
290                | '.'
291                | '!'
292                | '['
293                | ']'
294                | '('
295                | ')'
296                | '<'
297                | '>'
298                | '|'
299        ) {
300            escaped.push('\\');
301        }
302        escaped.push(ch);
303    }
304    escaped
305}
306
307/// Builds a schema-2.0 interactive card: `config.update_multi:true`, a
308/// markdown body element (fixed `element_id:"main_text"`, escaped text), and
309/// — when present — one `column_set` element per button row (one weighted
310/// column per button), per `docs/feishu-adapter-plan.md` §2c outbound
311/// decision 2. The exact button element wrapper is this
312/// adapter's own choice: the plan pins ONLY the `behaviors`/`value.cb`
313/// shape, not the surrounding container, so this is the one place protocol
314/// shape was inferred rather than verified — flagged in the task report.
315fn build_card(text: &str, buttons: Option<&[Vec<Button>]>) -> serde_json::Value {
316    let mut elements = vec![serde_json::json!({
317        "tag": "markdown",
318        "element_id": MAIN_TEXT_ELEMENT_ID,
319        "content": escape_feishu_markdown(text),
320    })];
321
322    if let Some(rows) = buttons {
323        for row in rows {
324            // Schema 2.0 dropped the v1 `tag:"action"` row container (the
325            // real API rejects it with `200861 unsupported tag action` —
326            // Magpie e2e 2026-07-15, bigduu/Magpie#7): rows are laid out as
327            // a `column_set` with one weighted column per button instead.
328            let columns: Vec<serde_json::Value> = row
329                .iter()
330                .map(|button| {
331                    serde_json::json!({
332                        "tag": "column",
333                        "width": "weighted",
334                        "weight": 1,
335                        "elements": [{
336                            "tag": "button",
337                            "text": { "tag": "plain_text", "content": button.label },
338                            "type": "default",
339                            "width": "fill",
340                            "behaviors": [
341                                { "type": "callback", "value": { "cb": button.callback_data } }
342                            ],
343                        }],
344                    })
345                })
346                .collect();
347            elements.push(serde_json::json!({ "tag": "column_set", "columns": columns }));
348        }
349    }
350
351    serde_json::json!({
352        "schema": "2.0",
353        "config": { "update_multi": true },
354        // Schema 2.0 nests `elements` under `body` — a top-level `elements`
355        // key is the v1 location and the real API rejects the card with
356        // `200621 parse card json err: unknown property "elements"` (caught
357        // in Magpie's 2026-07-15 real-device e2e; same builder, same bug).
358        "body": { "elements": elements },
359    })
360}
361
362// ---------------------------------------------------------------------
363// Inbound: event envelope + mapping
364// ---------------------------------------------------------------------
365
366#[derive(Debug, serde::Deserialize)]
367struct EventEnvelope {
368    #[serde(default)]
369    #[allow(dead_code)]
370    // documents the wire shape; not branched on (only "2.0" is ever sent on this transport)
371    schema: Option<String>,
372    header: EventHeader,
373    event: serde_json::Value,
374}
375
376#[derive(Debug, serde::Deserialize)]
377struct EventHeader {
378    event_id: String,
379    event_type: String,
380}
381
382#[derive(Debug, serde::Deserialize)]
383struct MessageReceiveEvent {
384    sender: EventSender,
385    message: EventMessage,
386}
387
388#[derive(Debug, serde::Deserialize)]
389struct EventSender {
390    sender_id: EventSenderId,
391    #[serde(default)]
392    sender_type: String,
393}
394
395#[derive(Debug, serde::Deserialize)]
396struct EventSenderId {
397    open_id: String,
398}
399
400#[derive(Debug, serde::Deserialize)]
401struct EventMessage {
402    message_id: String,
403    chat_id: String,
404    #[serde(default)]
405    chat_type: String,
406    message_type: String,
407    content: String,
408    create_time: String,
409    #[serde(default)]
410    mentions: Vec<EventMention>,
411}
412
413#[derive(Debug, serde::Deserialize)]
414struct EventMention {
415    key: String,
416    id: EventMentionId,
417    #[serde(default)]
418    name: String,
419}
420
421#[derive(Debug, serde::Deserialize)]
422struct EventMentionId {
423    open_id: String,
424}
425
426#[derive(Debug, serde::Deserialize)]
427struct CardActionEvent {
428    operator: CardOperator,
429    context: CardContext,
430    action: CardAction,
431}
432
433#[derive(Debug, serde::Deserialize)]
434struct CardOperator {
435    open_id: String,
436}
437
438#[derive(Debug, serde::Deserialize)]
439struct CardContext {
440    open_chat_id: String,
441}
442
443#[derive(Debug, serde::Deserialize)]
444struct CardAction {
445    value: serde_json::Value,
446}
447
448/// Replaces `@_user_N` placeholders in `text` per `mentions[].key`: the
449/// bot's OWN mention (`id.open_id == bot_open_id`) is dropped entirely, any
450/// other mention becomes `@{name}`. Leftover whitespace from a dropped
451/// placeholder is normalized (runs of whitespace collapsed to one space,
452/// trimmed) — a deliberate simplification (documented deviation: this does
453/// not preserve exact original whitespace/newline structure around a
454/// stripped mention, since MVP text is prose, not formatted markup).
455fn strip_mentions(text: &str, mentions: &[EventMention], bot_open_id: Option<&str>) -> String {
456    let mut result = text.to_string();
457    for mention in mentions {
458        let replacement = if Some(mention.id.open_id.as_str()) == bot_open_id {
459            String::new()
460        } else {
461            format!("@{}", mention.name)
462        };
463        result = result.replace(&mention.key, &replacement);
464    }
465    result.split_whitespace().collect::<Vec<_>>().join(" ")
466}
467
468/// Group-chat gating: p2p always passes; a group message passes only when
469/// it @-mentions this bot (found in `mentions[].id.open_id`) or is an
470/// `@所有人`/`@_all` broadcast (`mentions` is empty for those — a literal
471/// substring check on the raw text is the documented way to detect it).
472/// KNOWN LIMITATION: the wire format makes a real `@所有人` mention
473/// indistinguishable from a member literally typing the characters `@_all`
474/// (both arrive as `{"text":"…@_all…"}` with zero mention entries), so the
475/// latter also passes this gate. Accepted: it only widens "processed vs
476/// ignored", never authorization — `allow_from` still gates the sender's
477/// identity downstream in the bridge.
478/// `bot_open_id: None` (the startup `bot/v3/info` fetch failed) means every
479/// group message is treated as unmentioned and dropped.
480fn passes_group_gate(
481    chat_type: &str,
482    raw_text: &str,
483    mentions: &[EventMention],
484    bot_open_id: Option<&str>,
485) -> bool {
486    if chat_type != "group" {
487        return true;
488    }
489    if raw_text.contains("@_all") {
490        return true;
491    }
492    match bot_open_id {
493        Some(id) => mentions.iter().any(|m| m.id.open_id == id),
494        None => false,
495    }
496}
497
498/// Maps one already-parsed `im.message.receive_v1` event body to an
499/// [`InboundMessage`], or `None` for anything this MVP doesn't forward:
500/// a bot sender, a non-text message, or a group message that fails the
501/// @mention gate. Pure/sync so it's directly unit-testable.
502fn map_message_event(
503    event: &MessageReceiveEvent,
504    bot_open_id: Option<&str>,
505) -> Option<InboundMessage> {
506    if event.sender.sender_type == "bot" {
507        return None;
508    }
509    if event.message.message_type != "text" {
510        return None;
511    }
512
513    let raw_text: String = serde_json::from_str::<serde_json::Value>(&event.message.content)
514        .ok()
515        .and_then(|v| {
516            v.get("text")
517                .and_then(|t| t.as_str())
518                .map(|s| s.to_string())
519        })
520        .unwrap_or_default();
521
522    if !passes_group_gate(
523        &event.message.chat_type,
524        &raw_text,
525        &event.message.mentions,
526        bot_open_id,
527    ) {
528        return None;
529    }
530
531    let text = strip_mentions(&raw_text, &event.message.mentions, bot_open_id);
532
533    let sent_at = event
534        .message
535        .create_time
536        .parse::<i64>()
537        .ok()
538        .and_then(chrono::DateTime::<chrono::Utc>::from_timestamp_millis)
539        .unwrap_or_else(chrono::Utc::now);
540
541    Some(InboundMessage {
542        platform: "feishu".to_string(),
543        chat_id: event.message.chat_id.clone(),
544        user_id: event.sender.sender_id.open_id.clone(),
545        message_id: event.message.message_id.clone(),
546        sent_at,
547        text,
548        reply_ctx: ReplyCtx(serde_json::json!({
549            "chat_id": event.message.chat_id,
550            "message_id": event.message.message_id,
551        })),
552    })
553}
554
555/// Maps one already-parsed `card.action.trigger` event body to a
556/// [`CallbackQuery`]. Returns `None` when `action.value` doesn't carry our
557/// `"cb"` key (not a button this adapter produced — nothing to route).
558fn map_card_action_event(event_id: &str, event: &CardActionEvent) -> Option<CallbackQuery> {
559    let data = event
560        .action
561        .value
562        .get("cb")
563        .and_then(|v| v.as_str())?
564        .to_string();
565    Some(CallbackQuery {
566        platform: "feishu".to_string(),
567        chat_id: event.context.open_chat_id.clone(),
568        user_id: event.operator.open_id.clone(),
569        callback_query_id: event_id.to_string(),
570        data,
571        reply_ctx: ReplyCtx(serde_json::json!({ "chat_id": event.context.open_chat_id })),
572    })
573}
574
575/// Bridges `ws.rs`'s transport-only [`ws::EventSink`] to Feishu event
576/// semantics. Holds only OWNED/`Arc`-cloned state (never `&FeishuPlatform`)
577/// so it satisfies `EventSink`'s `'static` bound — `start()` constructs one
578/// per WS connection lifetime from `self`'s `Arc`-wrapped fields.
579struct FeishuEventSink {
580    bot_open_id: Arc<AsyncMutex<Option<String>>>,
581    pending_acks: Arc<AsyncMutex<HashMap<String, oneshot::Sender<serde_json::Value>>>>,
582    inbound: mpsc::Sender<Inbound>,
583    ack_soft_deadline: Duration,
584}
585
586impl FeishuEventSink {
587    async fn handle_message_event(&self, event: serde_json::Value) {
588        let parsed: MessageReceiveEvent = match serde_json::from_value(event) {
589            Ok(parsed) => parsed,
590            Err(error) => {
591                tracing::warn!("connect: feishu im.message.receive_v1 parse failed: {error}");
592                return;
593            }
594        };
595        let bot_open_id = self.bot_open_id.lock().await.clone();
596        let Some(message) = map_message_event(&parsed, bot_open_id.as_deref()) else {
597            return;
598        };
599        // Per task spec: a send failure here means the bridge/manager is
600        // shutting down (the receiving end of `dispatch_loop`'s channel was
601        // dropped) — nothing more to do for this event; real cleanup of the
602        // WS connection itself happens via `ConnectManager`'s `Drop`
603        // (JoinHandle abort), not by this handler propagating an error.
604        let _ = self.inbound.send(Inbound::Message(message)).await;
605    }
606
607    /// Returns the ack payload to echo back over the WS frame: the
608    /// bridge-supplied toast (or `{}`) if `answer_callback` resolved the
609    /// pending-ack before `ack_soft_deadline`, otherwise `None` (a bare
610    /// `{"code":200}` ack with null `data`).
611    async fn handle_card_action_event(
612        &self,
613        event_id: String,
614        event: serde_json::Value,
615    ) -> Option<serde_json::Value> {
616        let parsed: CardActionEvent = match serde_json::from_value(event) {
617            Ok(parsed) => parsed,
618            Err(error) => {
619                tracing::warn!("connect: feishu card.action.trigger parse failed: {error}");
620                return None;
621            }
622        };
623        let Some(callback) = map_card_action_event(&event_id, &parsed) else {
624            tracing::debug!("connect: feishu card action missing our 'cb' value; ignoring");
625            return None;
626        };
627
628        let (tx, rx) = oneshot::channel();
629        self.pending_acks.lock().await.insert(event_id.clone(), tx);
630
631        if self
632            .inbound
633            .send(Inbound::Callback(callback))
634            .await
635            .is_err()
636        {
637            self.pending_acks.lock().await.remove(&event_id);
638            return None;
639        }
640
641        match tokio::time::timeout(self.ack_soft_deadline, rx).await {
642            Ok(Ok(value)) => Some(value),
643            Ok(Err(_)) => None, // sender dropped without resolving
644            Err(_elapsed) => {
645                self.pending_acks.lock().await.remove(&event_id);
646                None
647            }
648        }
649    }
650}
651
652#[async_trait::async_trait]
653impl ws::EventSink for FeishuEventSink {
654    async fn handle_event(&self, payload: Vec<u8>) -> Option<serde_json::Value> {
655        let envelope: EventEnvelope = match serde_json::from_slice(&payload) {
656            Ok(envelope) => envelope,
657            Err(error) => {
658                tracing::warn!("connect: feishu event envelope parse failed: {error}");
659                return None;
660            }
661        };
662        match envelope.header.event_type.as_str() {
663            "im.message.receive_v1" => {
664                self.handle_message_event(envelope.event).await;
665                None
666            }
667            "card.action.trigger" => {
668                self.handle_card_action_event(envelope.header.event_id, envelope.event)
669                    .await
670            }
671            other => {
672                tracing::debug!("connect: feishu event type '{other}' not handled (MVP)");
673                None
674            }
675        }
676    }
677}
678
679#[cfg(test)]
680mod tests {
681    use super::*;
682
683    fn platform_with_stub(base_url: String) -> FeishuPlatform {
684        FeishuPlatform::with_options(
685            "cli_test_app".to_string(),
686            "test-app-secret".to_string(),
687            base_url,
688            Duration::from_millis(50),
689            true,
690        )
691    }
692
693    async fn wait_for_requests(
694        server: &wiremock::MockServer,
695        expected: usize,
696    ) -> Vec<wiremock::Request> {
697        for _ in 0..200 {
698            if let Some(requests) = server.received_requests().await {
699                if requests.len() >= expected {
700                    return requests;
701                }
702            }
703            tokio::time::sleep(Duration::from_millis(10)).await;
704        }
705        server.received_requests().await.unwrap_or_default()
706    }
707
708    fn mount_token(server: &wiremock::MockServer) -> impl std::future::Future<Output = ()> + '_ {
709        wiremock::Mock::given(wiremock::matchers::method("POST"))
710            .and(wiremock::matchers::path(
711                "/open-apis/auth/v3/tenant_access_token/internal",
712            ))
713            .respond_with(
714                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
715                    "code": 0,
716                    "msg": "ok",
717                    "tenant_access_token": "tok-abc",
718                    "expire": 7200
719                })),
720            )
721            .mount(server)
722    }
723
724    #[tokio::test]
725    async fn capabilities_advertise_buttons_and_edit_message_only() {
726        let platform = platform_with_stub("http://localhost:0".to_string());
727        let caps = platform.capabilities();
728        assert!(caps.buttons);
729        assert!(caps.edit_message);
730        assert!(!caps.images);
731        assert!(!caps.files);
732    }
733
734    #[tokio::test]
735    async fn reply_sends_an_interactive_card_with_escaped_markdown_content() {
736        let server = wiremock::MockServer::start().await;
737        mount_token(&server).await;
738        wiremock::Mock::given(wiremock::matchers::method("POST"))
739            .and(wiremock::matchers::path("/open-apis/im/v1/messages"))
740            .respond_with(
741                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
742                    "code": 0,
743                    "data": { "message_id": "om_1" }
744                })),
745            )
746            .mount(&server)
747            .await;
748
749        let platform = platform_with_stub(server.uri());
750        let ctx = ReplyCtx(serde_json::json!({ "chat_id": "oc_1" }));
751        let msg_ref = platform
752            .reply(&ctx, OutboundMessage::text("hello *world*"))
753            .await
754            .expect("reply succeeds");
755        assert_eq!(
756            msg_ref.0.get("message_id").and_then(|v| v.as_str()),
757            Some("om_1")
758        );
759
760        let requests = wait_for_requests(&server, 1).await;
761        let send_request = requests
762            .iter()
763            .find(|r| r.url.path() == "/open-apis/im/v1/messages")
764            .expect("send request present");
765        let body: serde_json::Value = serde_json::from_slice(&send_request.body).unwrap();
766        assert_eq!(body["msg_type"], serde_json::json!("interactive"));
767        assert_eq!(body["receive_id"], serde_json::json!("oc_1"));
768        let content: serde_json::Value =
769            serde_json::from_str(body["content"].as_str().expect("content is a JSON string"))
770                .unwrap();
771        assert_eq!(content["schema"], serde_json::json!("2.0"));
772        assert_eq!(content["config"]["update_multi"], serde_json::json!(true));
773        let markdown_content = content["body"]["elements"][0]["content"].as_str().unwrap();
774        assert!(
775            markdown_content.contains("\\*world\\*"),
776            "got: {markdown_content}"
777        );
778    }
779
780    #[tokio::test]
781    async fn reply_with_buttons_puts_callback_value_under_cb_key() {
782        let server = wiremock::MockServer::start().await;
783        mount_token(&server).await;
784        wiremock::Mock::given(wiremock::matchers::method("POST"))
785            .and(wiremock::matchers::path("/open-apis/im/v1/messages"))
786            .respond_with(
787                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
788                    "code": 0,
789                    "data": { "message_id": "om_2" }
790                })),
791            )
792            .mount(&server)
793            .await;
794
795        let platform = platform_with_stub(server.uri());
796        let ctx = ReplyCtx(serde_json::json!({ "chat_id": "oc_1" }));
797        let outbound = OutboundMessage::text("Approve?")
798            .with_buttons(vec![vec![Button::new("Approve", "n1:0")]]);
799        platform
800            .reply(&ctx, outbound)
801            .await
802            .expect("reply succeeds");
803
804        let requests = wait_for_requests(&server, 2).await;
805        let send_request = requests
806            .iter()
807            .find(|r| r.url.path() == "/open-apis/im/v1/messages")
808            .expect("send request present");
809        let body: serde_json::Value = serde_json::from_slice(&send_request.body).unwrap();
810        let content: serde_json::Value =
811            serde_json::from_str(body["content"].as_str().unwrap()).unwrap();
812        let row = &content["body"]["elements"][1];
813        assert_eq!(row["tag"], serde_json::json!("column_set"));
814        let button = &row["columns"][0]["elements"][0];
815        assert_eq!(button["tag"], serde_json::json!("button"));
816        assert_eq!(
817            button["behaviors"][0]["value"]["cb"],
818            serde_json::json!("n1:0")
819        );
820    }
821
822    #[tokio::test]
823    async fn reply_chunks_long_text_into_multiple_card_sends() {
824        let server = wiremock::MockServer::start().await;
825        mount_token(&server).await;
826        wiremock::Mock::given(wiremock::matchers::method("POST"))
827            .and(wiremock::matchers::path("/open-apis/im/v1/messages"))
828            .respond_with(
829                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
830                    "code": 0,
831                    "data": { "message_id": "om_3" }
832                })),
833            )
834            .mount(&server)
835            .await;
836
837        let platform = platform_with_stub(server.uri());
838        let ctx = ReplyCtx(serde_json::json!({ "chat_id": "oc_1" }));
839        let long_text = "a".repeat(9000); // -> 3 chunks at MAX_MESSAGE_CHARS=4096
840
841        platform
842            .reply(&ctx, OutboundMessage::text(long_text))
843            .await
844            .expect("reply succeeds");
845
846        let requests = wait_for_requests(&server, 3).await;
847        let send_requests: Vec<_> = requests
848            .iter()
849            .filter(|r| r.url.path() == "/open-apis/im/v1/messages")
850            .collect();
851        assert_eq!(send_requests.len(), 3, "expected exactly 3 card sends");
852    }
853
854    #[tokio::test]
855    async fn edit_patches_the_card_at_the_message_id() {
856        let server = wiremock::MockServer::start().await;
857        mount_token(&server).await;
858        wiremock::Mock::given(wiremock::matchers::method("PATCH"))
859            .and(wiremock::matchers::path("/open-apis/im/v1/messages/om_9"))
860            .respond_with(
861                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
862                    "code": 0,
863                    "data": {}
864                })),
865            )
866            .mount(&server)
867            .await;
868
869        let platform = platform_with_stub(server.uri());
870        let msg_ref = MessageRef(serde_json::json!({ "chat_id": "oc_1", "message_id": "om_9" }));
871        platform
872            .edit(&msg_ref, OutboundMessage::text("updated"))
873            .await
874            .expect("edit succeeds");
875
876        let requests = wait_for_requests(&server, 1).await;
877        let request = requests
878            .iter()
879            .find(|r| r.url.path() == "/open-apis/im/v1/messages/om_9")
880            .expect("patch request present");
881        let body: serde_json::Value = serde_json::from_slice(&request.body).unwrap();
882        let content: serde_json::Value =
883            serde_json::from_str(body["content"].as_str().unwrap()).unwrap();
884        assert_eq!(
885            content["body"]["elements"][0]["content"],
886            serde_json::json!("updated")
887        );
888    }
889
890    #[tokio::test]
891    async fn edit_returns_err_on_api_error_instead_of_panicking() {
892        let server = wiremock::MockServer::start().await;
893        mount_token(&server).await;
894        wiremock::Mock::given(wiremock::matchers::method("PATCH"))
895            .and(wiremock::matchers::path("/open-apis/im/v1/messages/om_9"))
896            .respond_with(
897                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
898                    "code": 230001,
899                    "msg": "card not found"
900                })),
901            )
902            .mount(&server)
903            .await;
904
905        let platform = platform_with_stub(server.uri());
906        let msg_ref = MessageRef(serde_json::json!({ "chat_id": "oc_1", "message_id": "om_9" }));
907        let result = platform
908            .edit(&msg_ref, OutboundMessage::text("updated"))
909            .await;
910        assert!(result.is_err());
911    }
912
913    #[tokio::test]
914    async fn token_is_cached_across_two_sends_then_force_refreshed_on_99991663() {
915        let server = wiremock::MockServer::start().await;
916        // First token call returns tok-1; a SECOND call (post-invalidate)
917        // returns tok-2 — wiremock matches requests in mount order and
918        // dispatches the first that still has un-exhausted expectations, so
919        // an explicit `up_to_n_times` differentiates the two responses.
920        wiremock::Mock::given(wiremock::matchers::method("POST"))
921            .and(wiremock::matchers::path(
922                "/open-apis/auth/v3/tenant_access_token/internal",
923            ))
924            .respond_with(
925                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
926                    "code": 0, "tenant_access_token": "tok-1", "expire": 7200
927                })),
928            )
929            .up_to_n_times(1)
930            .mount(&server)
931            .await;
932        wiremock::Mock::given(wiremock::matchers::method("POST"))
933            .and(wiremock::matchers::path(
934                "/open-apis/auth/v3/tenant_access_token/internal",
935            ))
936            .respond_with(
937                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
938                    "code": 0, "tenant_access_token": "tok-2", "expire": 7200
939                })),
940            )
941            .mount(&server)
942            .await;
943
944        // First send: 99991663 (stale token) -> the adapter must invalidate
945        // and retry once, succeeding on the retry.
946        wiremock::Mock::given(wiremock::matchers::method("POST"))
947            .and(wiremock::matchers::path("/open-apis/im/v1/messages"))
948            .respond_with(
949                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
950                    "code": 99991663, "msg": "invalid access token"
951                })),
952            )
953            .up_to_n_times(1)
954            .mount(&server)
955            .await;
956        wiremock::Mock::given(wiremock::matchers::method("POST"))
957            .and(wiremock::matchers::path("/open-apis/im/v1/messages"))
958            .respond_with(
959                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
960                    "code": 0, "data": { "message_id": "om_1" }
961                })),
962            )
963            .mount(&server)
964            .await;
965
966        let platform = platform_with_stub(server.uri());
967        let ctx = ReplyCtx(serde_json::json!({ "chat_id": "oc_1" }));
968
969        platform
970            .reply(&ctx, OutboundMessage::text("first"))
971            .await
972            .expect("reply succeeds after one token-invalid retry");
973
974        let token_requests = wait_for_requests(&server, 2).await;
975        let token_calls = token_requests
976            .iter()
977            .filter(|r| r.url.path() == "/open-apis/auth/v3/tenant_access_token/internal")
978            .count();
979        assert_eq!(
980            token_calls, 2,
981            "expected an initial fetch + one forced refresh"
982        );
983    }
984
985    #[tokio::test]
986    async fn transport_errors_never_leak_the_app_secret() {
987        let secret = "SECRET-APP-SECRET-MUST-NOT-LEAK";
988        let platform = FeishuPlatform::with_options(
989            "cli_test_app".to_string(),
990            secret.to_string(),
991            "http://127.0.0.1:1".to_string(),
992            Duration::from_millis(1),
993            true,
994        );
995        let ctx = ReplyCtx(serde_json::json!({ "chat_id": "oc_1" }));
996        let err = platform
997            .reply(&ctx, OutboundMessage::text("hi"))
998            .await
999            .expect_err("port 1 must refuse the connection");
1000        let text = format!("{err}");
1001        assert!(
1002            !text.contains(secret) && !text.contains("SECRET-APP-SECRET"),
1003            "app_secret leaked into error text: {text}"
1004        );
1005    }
1006
1007    // -- inbound mapping -------------------------------------------------
1008
1009    fn message_event(
1010        chat_type: &str,
1011        text_json: &str,
1012        sender_type: &str,
1013        mentions: serde_json::Value,
1014    ) -> MessageReceiveEvent {
1015        let value = serde_json::json!({
1016            "sender": { "sender_id": { "open_id": "ou_sender" }, "sender_type": sender_type },
1017            "message": {
1018                "message_id": "om_1",
1019                "chat_id": "oc_1",
1020                "chat_type": chat_type,
1021                "message_type": "text",
1022                "content": text_json,
1023                "create_time": "1700000000000",
1024                "mentions": mentions,
1025            }
1026        });
1027        serde_json::from_value(value).unwrap()
1028    }
1029
1030    #[test]
1031    fn map_message_event_maps_a_p2p_text_message() {
1032        let event = message_event("p2p", "{\"text\":\"hello\"}", "user", serde_json::json!([]));
1033        let message = map_message_event(&event, None).expect("maps");
1034        assert_eq!(message.platform, "feishu");
1035        assert_eq!(message.chat_id, "oc_1");
1036        assert_eq!(message.user_id, "ou_sender");
1037        assert_eq!(message.message_id, "om_1");
1038        assert_eq!(message.text, "hello");
1039        assert_eq!(
1040            message.reply_ctx.0,
1041            serde_json::json!({ "chat_id": "oc_1", "message_id": "om_1" })
1042        );
1043    }
1044
1045    #[test]
1046    fn map_message_event_drops_bot_senders() {
1047        let event = message_event("p2p", "{\"text\":\"hi\"}", "bot", serde_json::json!([]));
1048        assert!(map_message_event(&event, None).is_none());
1049    }
1050
1051    #[test]
1052    fn map_message_event_strips_other_mentions_and_drops_own_bot_mention() {
1053        let event = message_event(
1054            "group",
1055            "{\"text\":\"@_user_1 @_user_2 please review\"}",
1056            "user",
1057            serde_json::json!([
1058                { "key": "@_user_1", "id": { "open_id": "ou_bot" }, "name": "MyBot" },
1059                { "key": "@_user_2", "id": { "open_id": "ou_alice" }, "name": "Alice" }
1060            ]),
1061        );
1062        let message =
1063            map_message_event(&event, Some("ou_bot")).expect("group message w/ bot mention passes");
1064        assert_eq!(message.text, "@Alice please review");
1065    }
1066
1067    #[test]
1068    fn map_message_event_group_without_mention_or_bot_open_id_is_dropped() {
1069        let event = message_event(
1070            "group",
1071            "{\"text\":\"hello\"}",
1072            "user",
1073            serde_json::json!([]),
1074        );
1075        assert!(map_message_event(&event, Some("ou_bot")).is_none());
1076        assert!(map_message_event(&event, None).is_none());
1077    }
1078
1079    #[test]
1080    fn map_message_event_group_at_all_passes_even_with_empty_mentions() {
1081        let event = message_event(
1082            "group",
1083            "{\"text\":\"@_all please review\"}",
1084            "user",
1085            serde_json::json!([]),
1086        );
1087        let message =
1088            map_message_event(&event, Some("ou_bot")).expect("@_all passes the group gate");
1089        assert!(message.text.contains("please review"));
1090    }
1091
1092    #[test]
1093    fn map_message_event_drops_non_text_message_types() {
1094        let value = serde_json::json!({
1095            "sender": { "sender_id": { "open_id": "ou_sender" }, "sender_type": "user" },
1096            "message": {
1097                "message_id": "om_1", "chat_id": "oc_1", "chat_type": "p2p",
1098                "message_type": "image", "content": "{}", "create_time": "1700000000000",
1099                "mentions": []
1100            }
1101        });
1102        let event: MessageReceiveEvent = serde_json::from_value(value).unwrap();
1103        assert!(map_message_event(&event, None).is_none());
1104    }
1105
1106    #[test]
1107    fn map_card_action_event_extracts_cb_value_and_context() {
1108        let value = serde_json::json!({
1109            "operator": { "open_id": "ou_op" },
1110            "context": { "open_chat_id": "oc_5" },
1111            "action": { "value": { "cb": "nonce123:1" } }
1112        });
1113        let event: CardActionEvent = serde_json::from_value(value).unwrap();
1114        let callback = map_card_action_event("ev_1", &event).expect("maps");
1115        assert_eq!(callback.platform, "feishu");
1116        assert_eq!(callback.chat_id, "oc_5");
1117        assert_eq!(callback.user_id, "ou_op");
1118        assert_eq!(callback.callback_query_id, "ev_1");
1119        assert_eq!(callback.data, "nonce123:1");
1120        assert_eq!(
1121            callback.reply_ctx.0,
1122            serde_json::json!({ "chat_id": "oc_5" })
1123        );
1124    }
1125
1126    #[test]
1127    fn map_card_action_event_returns_none_without_cb_key() {
1128        let value = serde_json::json!({
1129            "operator": { "open_id": "ou_op" },
1130            "context": { "open_chat_id": "oc_5" },
1131            "action": { "value": { "other": "x" } }
1132        });
1133        let event: CardActionEvent = serde_json::from_value(value).unwrap();
1134        assert!(map_card_action_event("ev_1", &event).is_none());
1135    }
1136
1137    // -- pending-ack ------------------------------------------------------
1138
1139    fn card_action_payload(event_id: &str) -> Vec<u8> {
1140        serde_json::json!({
1141            "schema": "2.0",
1142            "header": { "event_id": event_id, "event_type": "card.action.trigger" },
1143            "event": {
1144                "operator": { "open_id": "ou_op" },
1145                "context": { "open_chat_id": "oc_5" },
1146                "action": { "value": { "cb": "nonce1:0" } }
1147            }
1148        })
1149        .to_string()
1150        .into_bytes()
1151    }
1152
1153    #[tokio::test]
1154    async fn card_callback_resolves_pending_ack_with_toast_when_answered() {
1155        let (tx, mut rx) = mpsc::channel(4);
1156        let sink = FeishuEventSink {
1157            bot_open_id: Arc::new(AsyncMutex::new(None)),
1158            pending_acks: Arc::new(AsyncMutex::new(HashMap::new())),
1159            inbound: tx,
1160            ack_soft_deadline: Duration::from_secs(5),
1161        };
1162        let pending_acks = sink.pending_acks.clone();
1163
1164        let handle = tokio::spawn(async move {
1165            use ws::EventSink;
1166            sink.handle_event(card_action_payload("ev_1")).await
1167        });
1168
1169        // Wait until the CallbackQuery is on the inbound channel (which
1170        // happens strictly AFTER the pending-ack oneshot is registered).
1171        let inbound = rx.recv().await.expect("callback delivered");
1172        match inbound {
1173            Inbound::Callback(callback) => assert_eq!(callback.callback_query_id, "ev_1"),
1174            Inbound::Message(_) => panic!("expected a callback"),
1175        }
1176
1177        // Resolve it, mirroring `FeishuPlatform::answer_callback`.
1178        let sender = pending_acks
1179            .lock()
1180            .await
1181            .remove("ev_1")
1182            .expect("pending ack present");
1183        sender
1184            .send(serde_json::json!({ "toast": { "type": "info", "content": "Approved" } }))
1185            .expect("resolve succeeds");
1186
1187        let ack_response = handle.await.expect("task completes");
1188        assert_eq!(
1189            ack_response,
1190            Some(serde_json::json!({ "toast": { "type": "info", "content": "Approved" } }))
1191        );
1192    }
1193
1194    #[tokio::test]
1195    async fn card_callback_auto_acks_after_the_soft_deadline_when_unanswered() {
1196        let (tx, mut rx) = mpsc::channel(4);
1197        let sink = FeishuEventSink {
1198            bot_open_id: Arc::new(AsyncMutex::new(None)),
1199            pending_acks: Arc::new(AsyncMutex::new(HashMap::new())),
1200            inbound: tx,
1201            ack_soft_deadline: Duration::from_millis(20),
1202        };
1203
1204        let handle = tokio::spawn(async move {
1205            use ws::EventSink;
1206            sink.handle_event(card_action_payload("ev_2")).await
1207        });
1208
1209        let _ = rx.recv().await.expect("callback delivered");
1210        // Never call answer_callback — the soft deadline (20ms) must fire.
1211        let ack_response = handle.await.expect("task completes");
1212        assert_eq!(
1213            ack_response, None,
1214            "unanswered callback auto-acks with no toast data"
1215        );
1216    }
1217
1218    #[tokio::test]
1219    async fn im_message_receive_event_reaches_the_inbound_channel() {
1220        let (tx, mut rx) = mpsc::channel(4);
1221        let sink = FeishuEventSink {
1222            bot_open_id: Arc::new(AsyncMutex::new(None)),
1223            pending_acks: Arc::new(AsyncMutex::new(HashMap::new())),
1224            inbound: tx,
1225            ack_soft_deadline: Duration::from_secs(5),
1226        };
1227        let payload = serde_json::json!({
1228            "schema": "2.0",
1229            "header": { "event_id": "ev_3", "event_type": "im.message.receive_v1" },
1230            "event": {
1231                "sender": { "sender_id": { "open_id": "ou_1" }, "sender_type": "user" },
1232                "message": {
1233                    "message_id": "om_5", "chat_id": "oc_1", "chat_type": "p2p",
1234                    "message_type": "text", "content": "{\"text\":\"hi there\"}",
1235                    "create_time": "1700000000000", "mentions": []
1236                }
1237            }
1238        })
1239        .to_string()
1240        .into_bytes();
1241
1242        use ws::EventSink;
1243        let ack = sink.handle_event(payload).await;
1244        assert_eq!(ack, None, "a plain message event acks with no toast data");
1245
1246        let inbound = rx.recv().await.expect("message delivered");
1247        match inbound {
1248            Inbound::Message(message) => assert_eq!(message.text, "hi there"),
1249            Inbound::Callback(_) => panic!("expected a message"),
1250        }
1251    }
1252}