gsm_core/messaging_card/
mod.rs

1use std::sync::Arc;
2
3use anyhow::{Result, anyhow};
4use serde_json::Value;
5
6use crate::messaging_card::renderers::RenderOutput;
7
8pub mod adaptive;
9pub mod downgrade;
10pub mod ir;
11pub mod oauth_support;
12pub mod renderers;
13pub mod spec;
14pub mod telemetry;
15pub mod tier;
16pub mod types;
17
18pub use adaptive::{
19    AdaptiveCardPayload, AdaptiveCardVersion, normalizer,
20    validator::{ValidateError, validate_ac_json},
21};
22pub use downgrade::{CapabilityProfile, DowngradeContext, DowngradeEngine, PolicyDowngradeEngine};
23pub use ir::{MessageCardIr, MessageCardIrBuilder};
24pub use oauth_support::ensure_oauth_start_url;
25pub use renderers::{
26    NullRenderer, PlatformRenderer, RendererRegistry, SlackRenderer, TeamsRenderer,
27    TelegramRenderer, WebChatRenderer, WebexRenderer, WhatsAppRenderer,
28};
29pub use spec::{AuthRenderSpec, FallbackButton, RenderIntent, RenderSpec};
30pub use telemetry::{CardTelemetry, NullTelemetry, TelemetryEvent, TelemetryHook};
31pub use tier::Tier;
32pub use types::{
33    Action, ImageRef, MessageCard, MessageCardKind, OauthCard, OauthPrompt, OauthProvider,
34};
35
36/// Entry point for migrating MessageCard payloads to the Adaptive pipeline.
37pub struct MessageCardEngine {
38    renderer_registry: RendererRegistry,
39    downgrade: PolicyDowngradeEngine,
40    telemetry: Arc<dyn TelemetryHook>,
41}
42
43impl Default for MessageCardEngine {
44    fn default() -> Self {
45        let mut registry = RendererRegistry::default();
46        registry.register(TeamsRenderer);
47        registry.register(WebChatRenderer);
48        registry.register(SlackRenderer);
49        registry.register(WebexRenderer);
50        registry.register(TelegramRenderer);
51        registry.register(WhatsAppRenderer);
52        Self {
53            renderer_registry: registry,
54            downgrade: PolicyDowngradeEngine,
55            telemetry: Arc::new(NullTelemetry),
56        }
57    }
58}
59
60impl MessageCardEngine {
61    pub fn new(renderer_registry: RendererRegistry) -> Self {
62        Self {
63            renderer_registry,
64            downgrade: PolicyDowngradeEngine,
65            telemetry: Arc::new(NullTelemetry),
66        }
67    }
68
69    /// Builds an engine with an empty renderer registry. Individual renderers are expected to be
70    /// registered by the caller.
71    pub fn bootstrap() -> Self {
72        Self::default()
73    }
74
75    pub fn with_telemetry<T: TelemetryHook + 'static>(mut self, hook: T) -> Self {
76        self.telemetry = Arc::new(hook);
77        self
78    }
79
80    pub fn registry(&self) -> &RendererRegistry {
81        &self.renderer_registry
82    }
83
84    pub fn register_renderer<R>(&mut self, renderer: R)
85    where
86        R: PlatformRenderer + 'static,
87    {
88        self.renderer_registry.register(renderer);
89    }
90
91    /// Converts user-authored MessageCards into the internal IR.
92    pub fn normalize(&self, card: &MessageCard) -> Result<MessageCardIr> {
93        if !matches!(card.kind, MessageCardKind::Standard) {
94            return Err(anyhow!(
95                "card kind {:?} requires render_spec() pipeline",
96                card.kind
97            ));
98        }
99        self.normalize_ir(card)
100    }
101
102    /// Produces a normalized render specification for downstream renderers.
103    pub fn render_spec(&self, card: &MessageCard) -> Result<RenderSpec> {
104        match card.kind {
105            MessageCardKind::Standard => {
106                let ir = self.normalize_ir(card)?;
107                Ok(RenderSpec::Card(Box::new(ir)))
108            }
109            MessageCardKind::Oauth => {
110                let oauth = card
111                    .oauth
112                    .as_ref()
113                    .ok_or_else(|| anyhow!("oauth card missing oauth block"))?;
114                Ok(RenderSpec::Auth(AuthRenderSpec::from_card(card, oauth)))
115            }
116        }
117    }
118
119    pub fn render(&self, platform: &str, ir: &MessageCardIr) -> Option<Value> {
120        let snapshot = self.render_card_snapshot(platform, ir)?;
121        self.record_render_event(
122            platform,
123            snapshot.tier,
124            snapshot.warning_count(),
125            &snapshot.output,
126            snapshot.downgraded,
127        );
128        Some(snapshot.output.payload)
129    }
130
131    pub fn render_spec_payload(&self, platform: &str, spec: &RenderSpec) -> Option<Value> {
132        self.render_snapshot_tracked(platform, spec)
133            .map(|snapshot| snapshot.output.payload)
134    }
135
136    pub fn render_snapshot_tracked(
137        &self,
138        platform: &str,
139        spec: &RenderSpec,
140    ) -> Option<RenderSnapshot> {
141        if let Some(snapshot) = self.render_snapshot(platform, spec) {
142            self.record_render_event(
143                platform,
144                snapshot.tier,
145                snapshot.warning_count(),
146                &snapshot.output,
147                snapshot.downgraded,
148            );
149            Some(snapshot)
150        } else {
151            None
152        }
153    }
154
155    pub fn render_snapshot(&self, platform: &str, spec: &RenderSpec) -> Option<RenderSnapshot> {
156        match spec {
157            RenderSpec::Card(ir) => self.render_card_snapshot(platform, ir.as_ref()),
158            RenderSpec::Auth(auth) => self.render_auth_snapshot(platform, auth),
159        }
160    }
161
162    pub fn render_card_snapshot(
163        &self,
164        platform: &str,
165        ir: &MessageCardIr,
166    ) -> Option<RenderSnapshot> {
167        let renderer = self.renderer_registry.get(platform)?;
168        let target_tier = renderer.target_tier();
169        let downgraded = ir.tier > target_tier;
170        let mut render_ir = if downgraded {
171            self.downgrade_for_platform(ir, platform, target_tier)
172        } else {
173            ir.clone()
174        };
175        let rendered = renderer.render(&render_ir);
176        if !rendered.warnings.is_empty() {
177            render_ir
178                .meta
179                .warnings
180                .extend(rendered.warnings.iter().cloned());
181        }
182        let tier = render_ir.tier;
183        Some(RenderSnapshot {
184            output: rendered,
185            ir: Some(render_ir),
186            tier,
187            target_tier,
188            downgraded,
189        })
190    }
191
192    fn render_auth_snapshot(
193        &self,
194        platform: &str,
195        auth: &AuthRenderSpec,
196    ) -> Option<RenderSnapshot> {
197        let renderer = self.renderer_registry.get(platform)?;
198        if let Some(rendered) = renderer.render_auth(auth) {
199            return Some(RenderSnapshot {
200                output: rendered,
201                ir: None,
202                tier: Tier::Premium,
203                target_tier: renderer.target_tier(),
204                downgraded: false,
205            });
206        }
207
208        let reason = if renderer.platform() == "teams" || renderer.platform() == "bf_webchat" {
209            if auth.connection_name.is_none() {
210                "missing connection name"
211            } else {
212                "native OAuth not supported"
213            }
214        } else {
215            "native OAuth not supported"
216        };
217
218        let fallback_ir = self.oauth_fallback_ir(auth, platform, reason);
219        self.render_card_snapshot(platform, &fallback_ir)
220    }
221
222    pub fn downgrade(&self, ir: &MessageCardIr, target_tier: Tier) -> MessageCardIr {
223        let ctx = DowngradeContext::new(ir.tier, target_tier);
224        self.downgrade_with_ctx(ir, ctx)
225    }
226
227    pub fn downgrade_for_platform(
228        &self,
229        ir: &MessageCardIr,
230        platform: &str,
231        target_tier: Tier,
232    ) -> MessageCardIr {
233        let ctx = DowngradeContext::new(ir.tier, target_tier).with_platform(platform);
234        self.downgrade_with_ctx(ir, ctx)
235    }
236
237    fn downgrade_with_ctx(&self, ir: &MessageCardIr, ctx: DowngradeContext) -> MessageCardIr {
238        if ir.tier <= ctx.target {
239            return ir.clone();
240        }
241
242        let telemetry = CardTelemetry::new(self.telemetry.as_ref());
243        telemetry.downgrading(ir.tier, ctx.target);
244        self.downgrade.downgrade(ir, ctx)
245    }
246    fn record_render_event(
247        &self,
248        platform: &str,
249        tier: Tier,
250        warning_count: usize,
251        rendered: &RenderOutput,
252        downgraded: bool,
253    ) {
254        let telemetry = CardTelemetry::new(self.telemetry.as_ref());
255        telemetry.rendered(
256            platform,
257            tier,
258            warning_count,
259            rendered.used_modal,
260            rendered.limit_exceeded,
261            rendered.sanitized_count,
262            rendered.url_blocked_count,
263            downgraded,
264        );
265    }
266
267    fn oauth_fallback_ir(
268        &self,
269        auth: &AuthRenderSpec,
270        platform: &str,
271        reason: &str,
272    ) -> MessageCardIr {
273        let mut builder = MessageCardIrBuilder::default()
274            .tier(Tier::Basic)
275            .title(&auth.fallback_button.title);
276        let description = format!("Sign in with {} to continue.", auth.provider.display_name());
277        builder = builder.primary_text(&description, false);
278        if let Some(url) = auth.fallback_button.url.as_deref() {
279            builder = builder.open_url(&auth.fallback_button.title, url);
280        }
281        let mut ir = builder.build();
282        ir.meta.source = Some("oauth-fallback".into());
283        ir.meta
284            .warn(format!("oauth card downgraded for {platform}: {reason}"));
285        if auth.fallback_button.url.is_none() {
286            ir.meta
287                .warn("oauth fallback rendered without an action URL");
288        }
289        ir
290    }
291}
292
293pub struct RenderSnapshot {
294    pub output: RenderOutput,
295    pub ir: Option<MessageCardIr>,
296    pub tier: Tier,
297    pub target_tier: Tier,
298    pub downgraded: bool,
299}
300
301impl RenderSnapshot {
302    pub fn warning_count(&self) -> usize {
303        if let Some(ir) = &self.ir {
304            ir.meta.warnings.len()
305        } else {
306            self.output.warnings.len()
307        }
308    }
309}
310
311impl MessageCardEngine {
312    fn normalize_ir(&self, card: &MessageCard) -> Result<MessageCardIr> {
313        #[cfg(feature = "adaptive-cards")]
314        if let Some(ac) = &card.adaptive {
315            validate_ac_json(ac)?;
316            let mut ir = normalizer::ac_to_ir(ac)?;
317            ir.auto_tier();
318            ir.meta.source = Some("adaptive".into());
319            ir.meta.adaptive_payload = Some(ac.clone());
320            return Ok(ir);
321        }
322
323        let mut ir = MessageCardIr::from_plain(card);
324        ir.meta.source = Some("plain".into());
325        Ok(ir)
326    }
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332    use serde_json::json;
333
334    fn base_card() -> MessageCard {
335        MessageCard {
336            title: Some("Bootstrap".into()),
337            text: Some("Hello".into()),
338            ..Default::default()
339        }
340    }
341
342    #[test]
343    fn normalize_plain_card() {
344        let engine = MessageCardEngine::bootstrap();
345        let card = base_card();
346        let ir = engine.normalize(&card).expect("normalization succeeds");
347        assert_eq!(ir.head.title, Some("Bootstrap".into()));
348        assert_eq!(ir.elements.len(), 1);
349        assert_eq!(ir.tier, Tier::Basic);
350        assert_eq!(ir.meta.source.as_deref(), Some("plain"));
351    }
352
353    #[test]
354    fn normalize_adaptive_card() {
355        let engine = MessageCardEngine::bootstrap();
356        let mut card = base_card();
357        card.adaptive = Some(json!({
358            "type": "AdaptiveCard",
359            "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
360            "version": "1.6",
361            "body": [
362                {
363                    "type": "TextBlock",
364                    "text": "Adaptive hello"
365                }
366            ]
367        }));
368
369        let ir = engine
370            .normalize(&card)
371            .expect("adaptive normalization succeeds");
372        assert_eq!(ir.elements.len(), 1);
373        assert_eq!(ir.meta.source.as_deref(), Some("adaptive"));
374    }
375
376    #[test]
377    fn downgrade_respects_target_tier() {
378        let engine = MessageCardEngine::bootstrap();
379        let card = base_card();
380        let ir = engine.normalize(&card).unwrap();
381        let downgraded = engine.downgrade(&ir, Tier::Basic);
382        assert_eq!(downgraded.tier, Tier::Basic);
383    }
384
385    #[test]
386    fn normalize_rejects_oauth_cards() {
387        let engine = MessageCardEngine::bootstrap();
388        let mut card = base_card();
389        card.kind = MessageCardKind::Oauth;
390        card.oauth = Some(OauthCard {
391            provider: OauthProvider::Microsoft,
392            scopes: vec!["User.Read".into()],
393            resource: None,
394            prompt: None,
395            start_url: Some("https://oauth/start".into()),
396            connection_name: Some("graph".into()),
397            metadata: None,
398        });
399        let err = engine.normalize(&card).unwrap_err();
400        assert!(
401            err.to_string().contains("requires render_spec"),
402            "unexpected error: {err}"
403        );
404    }
405
406    #[test]
407    fn render_spec_returns_auth_for_oauth_kind() {
408        let engine = MessageCardEngine::bootstrap();
409        let mut card = base_card();
410        card.kind = MessageCardKind::Oauth;
411        card.oauth = Some(OauthCard {
412            provider: OauthProvider::Google,
413            scopes: vec!["email".into()],
414            resource: Some("https://www.googleapis.com/auth/userinfo.email".into()),
415            prompt: Some(OauthPrompt::Consent),
416            start_url: Some("https://oauth/google/start".into()),
417            connection_name: Some("google-conn".into()),
418            metadata: Some(json!({"tenant":"acme"})),
419        });
420
421        let spec = engine.render_spec(&card).expect("spec");
422        assert!(matches!(spec.intent(), RenderIntent::Auth));
423        let auth = spec.as_auth().expect("auth spec");
424        assert_eq!(auth.provider, OauthProvider::Google);
425        assert_eq!(auth.connection_name.as_deref(), Some("google-conn"));
426        assert_eq!(
427            auth.start_url.as_deref(),
428            Some("https://oauth/google/start")
429        );
430        assert_eq!(auth.fallback_button.title, "Bootstrap");
431        assert_eq!(
432            auth.metadata
433                .as_ref()
434                .and_then(|m| m.get("tenant").and_then(|v| v.as_str())),
435            Some("acme")
436        );
437    }
438}