Skip to main content

greentic_operator/
cards.rs

1use anyhow::{Context, Result, anyhow};
2use serde_json::{Map, Value};
3
4use crate::capabilities::CAP_OAUTH_CARD_V1;
5
6/// Lightweight renderer that used to downgrade message_card payloads.
7#[derive(Clone, Default)]
8pub struct CardRenderer;
9
10/// Outcome from attempting to render a provider card.
11pub struct RenderOutcome {
12    pub bytes: Vec<u8>,
13}
14
15impl CardRenderer {
16    /// Create a no-op renderer.
17    pub fn new() -> Self {
18        Self
19    }
20
21    /// Resolve OAuth card placeholders via `greentic.cap.oauth.card.v1/oauth.card.resolve`
22    /// when present; otherwise return the original payload unchanged.
23    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}