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
36pub 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 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 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 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() == "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}