diff --git a/crates/ironclaw_common/src/event.rs b/crates/ironclaw_common/src/event.rs index b86daf9d624..557ae91e19e 100644 --- a/crates/ironclaw_common/src/event.rs +++ b/crates/ironclaw_common/src/event.rs @@ -54,6 +54,37 @@ pub enum OnboardingStateDto { Failed, } +impl OnboardingStateDto { + /// Build the canonical `AppEvent::OnboardingState` for a + /// pairing-required transition. + /// + /// `auth_url` and `setup_url` are always `None` for pairing — + /// forcing construction through this function prevents the three + /// emit sites (auth-token submit, setup-handler submit, activation + /// post-pairing) from silently disagreeing when new fields land on + /// `AppEvent::OnboardingState`. + pub fn pairing_required( + extension_name: impl Into, + request_id: Option, + thread_id: Option, + message: Option, + instructions: Option, + onboarding: Option, + ) -> AppEvent { + AppEvent::OnboardingState { + extension_name: extension_name.into(), + state: Self::PairingRequired, + request_id, + message, + instructions, + auth_url: None, + setup_url: None, + onboarding, + thread_id, + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type")] pub enum AppEvent { @@ -542,6 +573,57 @@ mod tests { } } + #[test] + fn pairing_required_constructor_sets_invariant_fields() { + let event = OnboardingStateDto::pairing_required( + "telegram", + Some("req-1".to_string()), + Some("thread-1".to_string()), + Some("Paired!".to_string()), + Some("Send /start to the bot.".to_string()), + Some(serde_json::json!({ "pairing_code": "ABC123" })), + ); + + match event { + AppEvent::OnboardingState { + extension_name, + state, + request_id, + message, + instructions, + auth_url, + setup_url, + onboarding, + thread_id, + } => { + assert_eq!(extension_name, "telegram"); + assert_eq!(state, OnboardingStateDto::PairingRequired); + assert_eq!(request_id.as_deref(), Some("req-1")); + assert_eq!(thread_id.as_deref(), Some("thread-1")); + assert_eq!(message.as_deref(), Some("Paired!")); + assert_eq!(instructions.as_deref(), Some("Send /start to the bot.")); + assert!(auth_url.is_none(), "auth_url must be None for pairing"); + assert!(setup_url.is_none(), "setup_url must be None for pairing"); + assert_eq!( + onboarding, + Some(serde_json::json!({ "pairing_code": "ABC123" })) + ); + } + other => panic!("expected OnboardingState, got {other:?}"), + } + } + + #[test] + fn pairing_required_constructor_serializes_to_onboarding_state_event() { + let event = OnboardingStateDto::pairing_required("telegram", None, None, None, None, None); + let json = serde_json::to_value(&event).unwrap(); + assert_eq!(json["type"], "onboarding_state"); + assert_eq!(json["state"], "pairing_required"); + assert_eq!(json["extension_name"], "telegram"); + assert!(json.get("auth_url").is_none()); + assert!(json.get("setup_url").is_none()); + } + #[test] fn round_trip_deserialize() { let original = AppEvent::Response { diff --git a/src/bridge/router.rs b/src/bridge/router.rs index 30146aa285a..4c1d297d79a 100644 --- a/src/bridge/router.rs +++ b/src/bridge/router.rs @@ -2077,20 +2077,17 @@ pub async fn resolve_gate( if let Some(ref sse) = state.sse { sse.broadcast_for_user( &message.user_id, - AppEvent::OnboardingState { - extension_name: display_name.clone(), - state: ironclaw_common::OnboardingStateDto::PairingRequired, - request_id: Some(next_pending.request_id.to_string()), - message: Some(result.message.clone()), - instructions, - auth_url: None, - setup_url: None, - onboarding, - thread_id: pending + ironclaw_common::OnboardingStateDto::pairing_required( + display_name.clone(), + Some(next_pending.request_id.to_string()), + pending .scope_thread_id .clone() .or_else(|| Some(pending.thread_id.to_string())), - }, + Some(result.message.clone()), + instructions, + onboarding, + ), ); } return Ok(BridgeOutcome::Pending); diff --git a/src/channels/web/server.rs b/src/channels/web/server.rs index e78813b7518..55ef4f12203 100644 --- a/src/channels/web/server.rs +++ b/src/channels/web/server.rs @@ -4007,18 +4007,15 @@ async fn extensions_setup_submit_handler( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? { - onboarding_event = AppEvent::OnboardingState { - extension_name: name.clone(), - state: - crate::channels::web::types::OnboardingStateDto::PairingRequired, - request_id: Some(next_request_id), - message: Some(result.message.clone()), - instructions, - auth_url: None, - setup_url: None, - onboarding, - thread_id: Some(thread_id.to_string()), - }; + onboarding_event = + crate::channels::web::types::OnboardingStateDto::pairing_required( + name.clone(), + Some(next_request_id), + Some(thread_id.to_string()), + Some(result.message.clone()), + instructions, + onboarding, + ); } } crate::channels::web::onboarding::ConfigureFlowOutcome::Ready => { diff --git a/src/extensions/manager.rs b/src/extensions/manager.rs index f45cd8812c2..b5482322b51 100644 --- a/src/extensions/manager.rs +++ b/src/extensions/manager.rs @@ -7062,23 +7062,20 @@ impl ExtensionManager { ); sse.broadcast_for_user( user_id, - ironclaw_common::AppEvent::OnboardingState { - extension_name: name.clone(), - state: ironclaw_common::OnboardingStateDto::PairingRequired, - request_id: None, - message: None, - instructions: Some(format!( + ironclaw_common::OnboardingStateDto::pairing_required( + name.clone(), + None, + None, + None, + Some(format!( "Send a message to your {} bot, then paste the pairing code here.", name )), - auth_url: None, - setup_url: None, - onboarding: onboarding + onboarding .1 .as_ref() .and_then(|o| serde_json::to_value(o).ok()), - thread_id: None, - }, + ), ); }