1use anyhow::{Context, Result, anyhow};
2use serde_json::{Map, Value};
3
4use crate::capabilities::CAP_OAUTH_CARD_V1;
5
6#[derive(Clone, Default)]
8pub struct CardRenderer;
9
10pub struct RenderOutcome {
12 pub bytes: Vec<u8>,
13}
14
15impl CardRenderer {
16 pub fn new() -> Self {
18 Self
19 }
20
21 pub fn render_if_needed<F>(
24 &self,
25 provider_type: &str,
26 payload_bytes: &[u8],
27 mut resolve_capability: F,
28 ) -> Result<RenderOutcome>
29 where
30 F: FnMut(&str, &str, &[u8]) -> Result<Value>,
31 {
32 let mut payload_json: Value = match serde_json::from_slice(payload_bytes) {
33 Ok(value) => value,
34 Err(_) => {
35 return Ok(RenderOutcome {
36 bytes: payload_bytes.to_vec(),
37 });
38 }
39 };
40 let Some(_) = payload_json.as_object() else {
41 return Ok(RenderOutcome {
42 bytes: payload_bytes.to_vec(),
43 });
44 };
45 let Some(adaptive_card_raw) = payload_json
46 .pointer("/metadata/adaptive_card")
47 .and_then(Value::as_str)
48 .map(str::to_string)
49 else {
50 return Ok(RenderOutcome {
51 bytes: payload_bytes.to_vec(),
52 });
53 };
54 let mut adaptive_card: Value = serde_json::from_str(&adaptive_card_raw)
55 .with_context(|| "invalid metadata.adaptive_card JSON")?;
56 let had_teams_placeholder = adaptive_card_raw.contains("{{oauth.teams.connectionName}}");
57 let teams_native_platform = is_teams_provider(provider_type);
58 let has_placeholder = adaptive_card_raw.contains("{{oauth.start_url}}")
59 || adaptive_card_raw.contains("{{oauth.teams.connectionName}}")
60 || contains_oauth_start_marker(&adaptive_card);
61 let request_seed = payload_json
62 .pointer("/metadata/oauth_card_request")
63 .and_then(Value::as_object)
64 .cloned();
65 if !has_placeholder && request_seed.is_none() {
66 return Ok(RenderOutcome {
67 bytes: payload_bytes.to_vec(),
68 });
69 }
70 let request_payload =
71 build_card_resolve_request(request_seed, &payload_json, provider_type);
72 let resolve_result = resolve_capability(
73 CAP_OAUTH_CARD_V1,
74 "oauth.card.resolve",
75 &serde_json::to_vec(&request_payload)?,
76 )?;
77 let start_url = resolve_result
78 .get("start_url")
79 .and_then(Value::as_str)
80 .ok_or_else(|| anyhow!("oauth.card.resolve output missing start_url"))?;
81 let teams_connection_name = resolve_result
82 .pointer("/teams/connectionName")
83 .or_else(|| resolve_result.pointer("/teams/connection_name"))
84 .and_then(Value::as_str)
85 .map(str::to_string);
86 rewrite_oauth_fields(
87 &mut adaptive_card,
88 start_url,
89 teams_connection_name.as_deref(),
90 teams_native_platform,
91 );
92 if let Some(metadata) = payload_json
93 .pointer_mut("/metadata")
94 .and_then(Value::as_object_mut)
95 {
96 metadata.insert(
97 "adaptive_card".to_string(),
98 Value::String(serde_json::to_string(&adaptive_card)?),
99 );
100 metadata.insert("oauth_card_resolved".to_string(), resolve_result);
101 if had_teams_placeholder && (!teams_native_platform || teams_connection_name.is_none())
102 {
103 metadata.insert(
104 "oauth_card_downgrade".to_string(),
105 Value::Object(Map::from_iter([
106 (
107 "mode".to_string(),
108 Value::String("non_native_fallback".to_string()),
109 ),
110 (
111 "reason".to_string(),
112 Value::String("teams_connection_name_unavailable".to_string()),
113 ),
114 ])),
115 );
116 }
117 }
118 let rendered = serde_json::to_vec(&payload_json)?;
119 Ok(RenderOutcome { bytes: rendered })
120 }
121}
122
123fn build_card_resolve_request(
124 seed: Option<Map<String, Value>>,
125 payload: &Value,
126 provider_type: &str,
127) -> Value {
128 let mut request = seed.unwrap_or_default();
129 if !request.contains_key("provider_type") {
130 request.insert(
131 "provider_type".to_string(),
132 Value::String(provider_type.to_string()),
133 );
134 }
135 if !request.contains_key("tenant")
136 && let Some(tenant) = payload
137 .pointer("/tenant/tenant_id")
138 .or_else(|| payload.pointer("/tenant/tenant"))
139 .and_then(Value::as_str)
140 {
141 request.insert("tenant".to_string(), Value::String(tenant.to_string()));
142 }
143 if !request.contains_key("team")
144 && let Some(team) = payload
145 .pointer("/tenant/team_id")
146 .or_else(|| payload.pointer("/tenant/team"))
147 .and_then(Value::as_str)
148 {
149 request.insert("team".to_string(), Value::String(team.to_string()));
150 }
151 if !request.contains_key("provider_id")
152 && let Some(provider_id) = payload
153 .pointer("/metadata/oauth_provider_id")
154 .or_else(|| payload.pointer("/metadata/provider_id"))
155 .and_then(Value::as_str)
156 {
157 request.insert(
158 "provider_id".to_string(),
159 Value::String(provider_id.to_string()),
160 );
161 }
162 Value::Object(request)
163}
164
165fn contains_oauth_start_marker(value: &Value) -> bool {
166 match value {
167 Value::Object(map) => {
168 let is_open_url = map
169 .get("type")
170 .and_then(Value::as_str)
171 .is_some_and(|value| value.eq_ignore_ascii_case("Action.OpenUrl"));
172 if is_open_url
173 && map
174 .get("url")
175 .and_then(Value::as_str)
176 .is_some_and(|url| url == "oauth://start")
177 {
178 return true;
179 }
180 map.values().any(contains_oauth_start_marker)
181 }
182 Value::Array(values) => values.iter().any(contains_oauth_start_marker),
183 _ => false,
184 }
185}
186
187fn rewrite_oauth_fields(
188 value: &mut Value,
189 start_url: &str,
190 teams_connection_name: Option<&str>,
191 teams_native_platform: bool,
192) {
193 match value {
194 Value::Object(map) => {
195 let is_open_url = map
196 .get("type")
197 .and_then(Value::as_str)
198 .is_some_and(|value| value.eq_ignore_ascii_case("Action.OpenUrl"));
199 let has_oauth_url_marker =
200 map.get("url").and_then(Value::as_str) == Some("oauth://start");
201 if is_open_url && has_oauth_url_marker {
202 map.insert("url".to_string(), Value::String(start_url.to_string()));
203 }
204 if map.get("connectionName").and_then(Value::as_str)
205 == Some("{{oauth.teams.connectionName}}")
206 {
207 if teams_native_platform && let Some(name) = teams_connection_name {
208 map.insert(
209 "connectionName".to_string(),
210 Value::String(name.to_string()),
211 );
212 } else {
213 map.remove("connectionName");
214 }
215 }
216 for entry in map.values_mut() {
217 rewrite_oauth_fields(
218 entry,
219 start_url,
220 teams_connection_name,
221 teams_native_platform,
222 );
223 }
224 }
225 Value::Array(values) => {
226 for entry in values {
227 rewrite_oauth_fields(
228 entry,
229 start_url,
230 teams_connection_name,
231 teams_native_platform,
232 );
233 }
234 }
235 Value::String(text) => {
236 *text = text.replace("{{oauth.start_url}}", start_url);
237 if teams_native_platform {
238 if let Some(name) = teams_connection_name {
239 *text = text.replace("{{oauth.teams.connectionName}}", name);
240 } else {
241 *text = text.replace("{{oauth.teams.connectionName}}", "");
242 }
243 } else {
244 *text = text.replace("{{oauth.teams.connectionName}}", "");
245 }
246 }
247 _ => {}
248 }
249}
250
251fn is_teams_provider(provider_type: &str) -> bool {
252 provider_type.to_ascii_lowercase().contains("teams")
253}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258 use serde_json::json;
259
260 #[test]
261 fn oauth_card_resolve_rewrites_adaptive_card() {
262 let renderer = CardRenderer::new();
263 let payload = json!({
264 "tenant": { "tenant_id": "demo", "team_id": "default" },
265 "metadata": {
266 "adaptive_card": "{\"type\":\"AdaptiveCard\",\"actions\":[{\"type\":\"Action.OpenUrl\",\"title\":\"Connect\",\"url\":\"oauth://start\"}],\"connectionName\":\"{{oauth.teams.connectionName}}\"}",
267 "oauth_provider_id": "google"
268 }
269 });
270 let bytes = serde_json::to_vec(&payload).unwrap();
271 let output = renderer
272 .render_if_needed("messaging.teams", &bytes, |cap_id, op, _input| {
273 assert_eq!(cap_id, CAP_OAUTH_CARD_V1);
274 assert_eq!(op, "oauth.card.resolve");
275 Ok(json!({
276 "start_url": "https://oauth.example/start/session",
277 "teams": { "connectionName": "greentic-oauth" }
278 }))
279 })
280 .expect("render");
281 let rendered: Value = serde_json::from_slice(&output.bytes).expect("json");
282 let card_raw = rendered
283 .pointer("/metadata/adaptive_card")
284 .and_then(Value::as_str)
285 .expect("adaptive_card");
286 let card_json: Value = serde_json::from_str(card_raw).expect("card json");
287 assert_eq!(
288 card_json.pointer("/actions/0/url").and_then(Value::as_str),
289 Some("https://oauth.example/start/session")
290 );
291 assert_eq!(
292 card_json.get("connectionName").and_then(Value::as_str),
293 Some("greentic-oauth")
294 );
295 assert_eq!(
296 rendered
297 .pointer("/metadata/oauth_card_resolved/start_url")
298 .and_then(Value::as_str),
299 Some("https://oauth.example/start/session")
300 );
301 }
302
303 #[test]
304 fn oauth_card_non_teams_downgrades_connection_name() {
305 let renderer = CardRenderer::new();
306 let payload = json!({
307 "tenant": { "tenant_id": "demo", "team_id": "default" },
308 "metadata": {
309 "adaptive_card": "{\"type\":\"AdaptiveCard\",\"actions\":[{\"type\":\"Action.OpenUrl\",\"title\":\"Connect\",\"url\":\"oauth://start\"}],\"connectionName\":\"{{oauth.teams.connectionName}}\"}",
310 "oauth_provider_id": "google"
311 }
312 });
313 let bytes = serde_json::to_vec(&payload).unwrap();
314 let output = renderer
315 .render_if_needed("messaging.telegram", &bytes, |_cap_id, _op, _input| {
316 Ok(json!({
317 "start_url": "https://oauth.example/start/session",
318 "teams": { "connectionName": "greentic-oauth" }
319 }))
320 })
321 .expect("render");
322 let rendered: Value = serde_json::from_slice(&output.bytes).expect("json");
323 let card_raw = rendered
324 .pointer("/metadata/adaptive_card")
325 .and_then(Value::as_str)
326 .expect("adaptive_card");
327 let card_json: Value = serde_json::from_str(card_raw).expect("card json");
328 assert_eq!(
329 card_json.pointer("/actions/0/url").and_then(Value::as_str),
330 Some("https://oauth.example/start/session")
331 );
332 assert!(
333 card_json.get("connectionName").is_none(),
334 "non-teams provider should drop teams-only placeholder"
335 );
336 assert_eq!(
337 rendered
338 .pointer("/metadata/oauth_card_downgrade/mode")
339 .and_then(Value::as_str),
340 Some("non_native_fallback")
341 );
342 }
343}