diff --git a/crates/aionui-channel/src/plugins/lark/frame.rs b/crates/aionui-channel/src/plugins/lark/frame.rs index 487f995ec..5302495b6 100644 --- a/crates/aionui-channel/src/plugins/lark/frame.rs +++ b/crates/aionui-channel/src/plugins/lark/frame.rs @@ -62,7 +62,13 @@ pub fn build_ping_frame(service_id: i32) -> PbFrame { } } -pub fn build_ack_frame(original: &PbFrame) -> PbFrame { +/// Build the ACK frame for a received DATA frame. +/// +/// `response_payload` is the body echoed back to the platform. For plain +/// events this is `{"code":200}`; for a `card.action.trigger` callback it must +/// be the card-callback response (`{}` = keep card unchanged, or a `toast` / +/// `card` object) so the platform does not report a callback timeout. +pub fn build_ack_frame(original: &PbFrame, response_payload: &[u8]) -> PbFrame { let mut ack_headers = Vec::new(); for h in &original.headers { if h.key == "type" || h.key == "message_id" || h.key == "trace_id" { @@ -82,7 +88,7 @@ pub fn build_ack_frame(original: &PbFrame) -> PbFrame { headers: ack_headers, payload_encoding: String::new(), payload_type: String::new(), - payload: br#"{"code":200}"#.to_vec(), + payload: response_payload.to_vec(), log_id_new: original.log_id_new.clone(), } } @@ -190,7 +196,7 @@ mod tests { payload: Vec::new(), log_id_new: String::new(), }; - let ack = build_ack_frame(&original); + let ack = build_ack_frame(&original, br#"{"code":200}"#); assert_eq!(ack.method, METHOD_DATA); let payload_str = String::from_utf8(ack.payload.clone()).unwrap(); assert!(payload_str.contains("200")); @@ -247,7 +253,7 @@ mod tests { payload: Vec::new(), log_id_new: String::new(), }; - let ack = build_ack_frame(&original); + let ack = build_ack_frame(&original, br#"{}"#); let keys: Vec<&str> = ack.headers.iter().map(|h| h.key.as_str()).collect(); assert!(keys.contains(&"type")); assert!(keys.contains(&"message_id")); @@ -270,7 +276,7 @@ mod tests { payload: Vec::new(), log_id_new: "new_log_xyz".into(), }; - let ack = build_ack_frame(&original); + let ack = build_ack_frame(&original, br#"{}"#); assert_eq!(ack.seq_id, 77); assert_eq!(ack.log_id, 88); assert_eq!(ack.service, 3); diff --git a/crates/aionui-channel/src/plugins/lark/plugin.rs b/crates/aionui-channel/src/plugins/lark/plugin.rs index d21e6dc2d..cf30fb230 100644 --- a/crates/aionui-channel/src/plugins/lark/plugin.rs +++ b/crates/aionui-channel/src/plugins/lark/plugin.rs @@ -381,7 +381,16 @@ async fn connect_and_listen( } } METHOD_DATA => { - let ack = build_ack_frame(&frame); + // A card.action.trigger callback must be answered with a + // card-callback response body (`{}` = keep card unchanged), + // not the generic event ack, or the platform reports a + // callback timeout on the clicked button. + let ack_payload: &[u8] = if is_card_action_trigger(&frame.payload) { + b"{}" + } else { + br#"{"code":200}"# + }; + let ack = build_ack_frame(&frame, ack_payload); let ack_bytes = encode_frame(&ack); if let Err(e) = write.send(WsMessage::Binary(ack_bytes.into())).await { warn!(error = %e, "Failed to send Lark ack frame"); @@ -473,6 +482,21 @@ fn handle_control_frame(frame: &super::frame::PbFrame) -> Option { } } +/// Peek a raw frame payload to detect a `card.action.trigger` callback, whose +/// ack must carry a card-callback response body instead of the generic event ack. +fn is_card_action_trigger(payload: &[u8]) -> bool { + serde_json::from_slice::(payload) + .ok() + .and_then(|v| { + v.get("header") + .and_then(|h| h.get("event_type")) + .and_then(|e| e.as_str()) + .map(str::to_owned) + }) + .as_deref() + == Some("card.action.trigger") +} + /// Handle a decoded and reassembled Lark event payload. async fn handle_ws_text( text: &str, @@ -519,6 +543,13 @@ async fn handle_ws_text( handle_bot_menu_event(event_data, message_tx).await; } } + "card.action.trigger" => { + // Interactive card button click delivered over the long connection + // (webhook mode delivers this as a `card` frame instead). + if let Some(event_data) = envelope.get("event").cloned() { + handle_card_action(event_data, message_tx, confirm_tx).await; + } + } _ => { debug!(event_type, "Lark unhandled event type"); } @@ -631,9 +662,19 @@ async fn handle_card_action( } } - let chat_id = evt.open_chat_id.as_deref().unwrap_or("").to_string(); + // Long-connection event nests ids under `context`; legacy webhook has them top-level. + let chat_id = evt + .context + .as_ref() + .and_then(|c| c.open_chat_id.clone()) + .or_else(|| evt.open_chat_id.clone()) + .unwrap_or_default(); - let message_id = evt.open_message_id.clone(); + let message_id = evt + .context + .as_ref() + .and_then(|c| c.open_message_id.clone()) + .or_else(|| evt.open_message_id.clone()); let user = UnifiedUser { id: evt.operator.open_id.clone(), @@ -1267,4 +1308,13 @@ mod tests { assert_eq!(plugin.plugin_type(), PluginType::Lark); assert_eq!(plugin.active_user_count(), 0); } + + #[test] + fn detects_card_action_trigger_frame() { + let card = br#"{"header":{"event_type":"card.action.trigger"},"event":{}}"#; + let msg = br#"{"header":{"event_type":"im.message.receive_v1"},"event":{}}"#; + assert!(is_card_action_trigger(card)); + assert!(!is_card_action_trigger(msg)); + assert!(!is_card_action_trigger(b"not json")); + } } diff --git a/crates/aionui-channel/src/plugins/lark/types.rs b/crates/aionui-channel/src/plugins/lark/types.rs index 3bc4bb110..142175142 100644 --- a/crates/aionui-channel/src/plugins/lark/types.rs +++ b/crates/aionui-channel/src/plugins/lark/types.rs @@ -214,7 +214,22 @@ pub(crate) struct CardActionEvent { pub action: CardAction, #[serde(default)] pub token: Option, + // Legacy webhook payload places these at the top level. + #[serde(default)] + pub open_message_id: Option, + #[serde(default)] + pub open_chat_id: Option, + // Long-connection `card.action.trigger` event nests them under `context`. + #[serde(default)] + pub context: Option, +} + +/// Context of a `card.action.trigger` event (long-connection payload). +#[derive(Debug, Clone, Deserialize)] +pub(crate) struct CardActionContext { + #[serde(default)] pub open_message_id: Option, + #[serde(default)] pub open_chat_id: Option, } @@ -766,4 +781,26 @@ mod tests { assert_eq!(json["app_id"], "cli_123"); assert_eq!(json["app_secret"], "secret_456"); } + + // -- CardActionEvent parses the long-connection (context) payload ------- + + #[test] + fn card_action_event_parses_long_connection_context() { + // `event` payload of a card.action.trigger event (ids nested in context). + let raw = serde_json::json!({ + "operator": { "open_id": "ou_123" }, + "token": "c-xxx", + "action": { "tag": "button", "value": { "action": "system:pairing.check" } }, + "context": { "open_message_id": "om_1", "open_chat_id": "oc_1" } + }); + let evt: CardActionEvent = serde_json::from_value(raw).unwrap(); + assert_eq!(evt.operator.open_id, "ou_123"); + let ctx = evt.context.expect("context present"); + assert_eq!(ctx.open_chat_id.as_deref(), Some("oc_1")); + assert_eq!(ctx.open_message_id.as_deref(), Some("om_1")); + assert_eq!( + evt.action.value.and_then(|v| v.get("action").and_then(|a| a.as_str().map(String::from))), + Some("system:pairing.check".into()) + ); + } }