1mod 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
36const DEFAULT_RATE_LIMIT_INTERVAL: Duration = Duration::from_secs(1);
41const ACK_SOFT_DEADLINE: Duration = Duration::from_millis(2500);
46const 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 bot_open_id: Arc<AsyncMutex<Option<String>>>,
63 pending_acks: Arc<AsyncMutex<HashMap<String, oneshot::Sender<serde_json::Value>>>>,
67 ws_disabled: bool,
71}
72
73impl FeishuPlatform {
74 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 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 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 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 let _ = sender.send(value);
253 }
254 Ok(())
255 }
256
257 async fn stop(&self) -> PlatformResult<()> {
258 Ok(())
263 }
264}
265
266fn 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
307fn 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 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 "body": { "elements": elements },
359 })
360}
361
362#[derive(Debug, serde::Deserialize)]
367struct EventEnvelope {
368 #[serde(default)]
369 #[allow(dead_code)]
370 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
448fn 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
468fn 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
498fn 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
555fn 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
575struct 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 let _ = self.inbound.send(Inbound::Message(message)).await;
605 }
606
607 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, 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); 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 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 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 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 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 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 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 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}