gsm_translator/
lib.rs

1//! Helpers for translating platform-agnostic messages into provider specific payloads.
2//!
3//! The main entry point is the [`Translator`] trait, which is implemented for each supported
4//! outbound channel. Translators accept a [`gsm_core::OutMessage`] and emit one or more platform
5//! payloads ready to be dispatched.
6
7use anyhow::{Result, anyhow};
8use gsm_core::messaging_card::MessageCardEngine;
9use gsm_core::{CardAction, CardBlock, MessageCard, OutKind, OutMessage};
10use security::{
11    hash::state_hash_out,
12    jwt::{ActionClaims, JwtSigner},
13    links::{build_action_url, default_action_ttl},
14};
15use serde_json::{Value, json};
16use time::Duration;
17use unicode_segmentation::UnicodeSegmentation;
18use uuid::Uuid;
19
20use crate::telemetry::translate_with_span;
21use once_cell::sync::Lazy;
22use std::sync::RwLock;
23
24/// Converts a platform-agnostic [`OutMessage`](gsm_core::OutMessage) into a list of platform specific payloads.
25///
26/// Implementations should never mutate the original message and must return an error when required
27/// fields are missing for the requested conversion.
28pub trait Translator {
29    fn to_platform(&self, out: &OutMessage) -> Result<Vec<Value>>;
30}
31
32pub fn secure_action_url(out: &OutMessage, title: &str, url: &str) -> String {
33    if let Some(config) = load_action_config() {
34        let scope = format!("{}.{}", out.platform.as_str(), slugify(title));
35        let claims = ActionClaims::new(
36            out.chat_id.clone(),
37            out.tenant.clone(),
38            scope,
39            state_hash_out(out),
40            Some(url.to_string()),
41            config.ttl,
42        );
43        if let Ok(link) = build_action_url(&config.base, claims, &config.signer) {
44            return link;
45        }
46    }
47    url.to_string()
48}
49
50static CARD_ENGINE: Lazy<MessageCardEngine> = Lazy::new(MessageCardEngine::bootstrap);
51static ACTION_LINK_CONFIG: Lazy<RwLock<Option<ActionLinkConfig>>> = Lazy::new(|| RwLock::new(None));
52
53pub(crate) fn render_via_engine(out: &OutMessage, platform: &str) -> Option<Value> {
54    let card = out.adaptive_card.as_ref()?;
55    let spec = CARD_ENGINE.render_spec(card).ok()?;
56    CARD_ENGINE.render_spec_payload(platform, &spec)
57}
58
59#[derive(Clone)]
60pub struct ActionLinkConfig {
61    base: String,
62    signer: JwtSigner,
63    ttl: Duration,
64}
65
66impl ActionLinkConfig {
67    pub fn new(base: impl Into<String>, signer: JwtSigner, ttl: Duration) -> Self {
68        Self {
69            base: base.into(),
70            signer,
71            ttl,
72        }
73    }
74
75    pub fn with_default_ttl(base: impl Into<String>, signer: JwtSigner) -> Self {
76        Self::new(base, signer, default_action_ttl())
77    }
78}
79
80pub fn set_action_link_config(config: ActionLinkConfig) {
81    if let Ok(mut guard) = ACTION_LINK_CONFIG.write() {
82        *guard = Some(config);
83    }
84}
85
86pub fn clear_action_link_config() {
87    if let Ok(mut guard) = ACTION_LINK_CONFIG.write() {
88        *guard = None;
89    }
90}
91
92fn load_action_config() -> Option<ActionLinkConfig> {
93    ACTION_LINK_CONFIG
94        .read()
95        .ok()
96        .and_then(|guard| guard.clone())
97}
98
99fn slugify(input: &str) -> String {
100    let mut slug = String::new();
101    for ch in input.chars() {
102        if ch.is_ascii_alphanumeric() {
103            slug.push(ch.to_ascii_lowercase());
104        } else if (ch.is_whitespace() || ch == '-' || ch == '_') && !slug.ends_with('-') {
105            slug.push('-');
106        }
107    }
108    let trimmed = slug.trim_matches('-');
109    if trimmed.is_empty() {
110        format!("open-{}", Uuid::new_v4().simple())
111    } else {
112        trimmed.to_string()
113    }
114}
115
116pub mod slack;
117pub mod teams;
118mod telemetry;
119pub mod webex;
120
121/// Translator that produces Telegram specific API requests.
122///
123/// ```
124/// use gsm_translator::{TelegramTranslator, Translator};
125/// use gsm_core::{make_tenant_ctx, OutMessage, OutKind, Platform};
126/// use serde_json::json;
127///
128/// let mut message = OutMessage {
129///     ctx: make_tenant_ctx("acme".into(), None, None),
130///     tenant: "acme".into(),
131///     platform: Platform::Telegram,
132///     chat_id: "chat-1".into(),
133///     thread_id: None,
134///     kind: OutKind::Text,
135///     text: Some("Hello".into()),
136///     message_card: None,
137///     adaptive_card: None,
138///     meta: Default::default(),
139/// };
140/// let translator = TelegramTranslator::new();
141/// let payloads = translator.to_platform(&message).unwrap();
142/// assert_eq!(payloads, vec![json!({
143///   "method": "sendMessage",
144///   "parse_mode": "HTML",
145///   "text": "Hello"
146/// })]);
147/// ```
148pub struct TelegramTranslator;
149
150impl TelegramTranslator {
151    /// Creates a new instance of the Telegram translator.
152    pub fn new() -> Self {
153        Self
154    }
155
156    fn render_text(text: &str) -> Value {
157        json!({
158          "method": "sendMessage",
159          "parse_mode": "HTML",
160          "text": html_escape(text),
161        })
162    }
163
164    fn render_card(out: &OutMessage, card: &MessageCard) -> Vec<Value> {
165        let mut parts: Vec<String> = Vec::new();
166        if let Some(t) = &card.title {
167            parts.push(format!("<b>{}</b>", html_escape(t)));
168        }
169        for block in &card.body {
170            match block {
171                CardBlock::Text { text, .. } => parts.push(html_escape(text)),
172                CardBlock::Fact { label, value } => parts.push(format!(
173                    "• <b>{}</b>: {}",
174                    html_escape(label),
175                    html_escape(value)
176                )),
177                CardBlock::Image { url } => parts.push(url.clone()),
178            }
179        }
180
181        let mut payloads = vec![json!({
182          "method": "sendMessage",
183          "parse_mode": "HTML",
184          "text": parts.join("\n"),
185        })];
186
187        if !card.actions.is_empty() {
188            let mut keyboard: Vec<Vec<Value>> = Vec::new();
189            for action in &card.actions {
190                match action {
191                    CardAction::OpenUrl { title, url, .. } => {
192                        let href = secure_action_url(out, title, url);
193                        keyboard.push(vec![json!({ "text": title, "url": href })]);
194                    }
195                    CardAction::Postback { title, data } => {
196                        let data_str = serde_json::to_string(data).unwrap_or_else(|_| "{}".into());
197                        keyboard.push(vec![json!({ "text": title, "callback_data": data_str })]);
198                    }
199                }
200            }
201            payloads.push(json!({
202              "method": "sendMessage",
203              "parse_mode": "HTML",
204              "text": "Actions:",
205              "reply_markup": { "inline_keyboard": keyboard },
206            }));
207        }
208
209        payloads
210    }
211}
212
213impl Default for TelegramTranslator {
214    fn default() -> Self {
215        Self::new()
216    }
217}
218
219impl Translator for TelegramTranslator {
220    fn to_platform(&self, out: &OutMessage) -> Result<Vec<Value>> {
221        translate_with_span(out, "telegram", || {
222            if let Some(payload) = crate::render_via_engine(out, "telegram") {
223                return Ok(vec![payload]);
224            }
225
226            match out.kind {
227                OutKind::Text => {
228                    let text = out.text.as_deref().ok_or_else(|| anyhow!("missing text"))?;
229                    Ok(vec![Self::render_text(text)])
230                }
231                OutKind::Card => {
232                    let card = out
233                        .message_card
234                        .as_ref()
235                        .ok_or_else(|| anyhow!("missing card"))?;
236                    Ok(Self::render_card(out, card))
237                }
238            }
239        })
240    }
241}
242
243fn html_escape(text: &str) -> String {
244    let mut escaped = String::with_capacity(text.len());
245    for grapheme in UnicodeSegmentation::graphemes(text, true) {
246        escaped.push_str(match grapheme {
247            "&" => "&amp;",
248            "<" => "&lt;",
249            ">" => "&gt;",
250            _ => grapheme,
251        });
252    }
253    escaped
254}
255
256pub struct WebChatTranslator;
257
258impl WebChatTranslator {
259    /// Creates a new instance of the WebChat translator.
260    pub fn new() -> Self {
261        Self
262    }
263}
264
265impl Default for WebChatTranslator {
266    fn default() -> Self {
267        Self::new()
268    }
269}
270
271/// Translator that turns messages into WebChat payloads.
272///
273/// ```
274/// use gsm_translator::{WebChatTranslator, Translator};
275/// use gsm_core::{make_tenant_ctx, OutMessage, OutKind, Platform};
276/// use serde_json::json;
277///
278/// let mut message = OutMessage {
279///     ctx: make_tenant_ctx("acme".into(), None, None),
280///     tenant: "acme".into(),
281///     platform: Platform::WebChat,
282///     chat_id: "thread-42".into(),
283///     thread_id: None,
284///     kind: OutKind::Text,
285///     text: Some("Hello WebChat".into()),
286///     message_card: None,
287///     adaptive_card: None,
288///     meta: Default::default(),
289/// };
290///
291/// let translator = WebChatTranslator::new();
292/// let payloads = translator.to_platform(&message).unwrap();
293/// assert_eq!(payloads, vec![json!({
294///   "kind": "text",
295///   "text": "Hello WebChat"
296/// })]);
297/// ```
298impl Translator for WebChatTranslator {
299    fn to_platform(&self, out: &OutMessage) -> Result<Vec<Value>> {
300        translate_with_span(out, "webchat", || {
301            if let Some(payload) = crate::render_via_engine(out, "bf_webchat") {
302                return Ok(vec![payload]);
303            }
304
305            let payload = match out.kind {
306                OutKind::Text => json!({
307                  "kind": "text",
308                  "text": out.text.clone().unwrap_or_default(),
309                }),
310                OutKind::Card => {
311                    let mut card = out
312                        .message_card
313                        .clone()
314                        .ok_or_else(|| anyhow!("missing card"))?;
315                    for action in card.actions.iter_mut() {
316                        if let CardAction::OpenUrl { title, url, .. } = action {
317                            let signed = secure_action_url(out, title, url);
318                            *url = signed;
319                        }
320                    }
321                    json!({
322                      "kind": "card",
323                      "card": card,
324                    })
325                }
326            };
327            Ok(vec![payload])
328        })
329    }
330}
331
332/// Translator for Webex messages.
333pub struct WebexTranslator;
334
335impl WebexTranslator {
336    pub fn new() -> Self {
337        Self
338    }
339}
340
341impl Default for WebexTranslator {
342    fn default() -> Self {
343        Self::new()
344    }
345}
346
347impl Translator for WebexTranslator {
348    fn to_platform(&self, out: &OutMessage) -> Result<Vec<Value>> {
349        let payload = crate::webex::to_webex_payload(out)?;
350        Ok(vec![payload])
351    }
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357    use crate::teams::to_teams_adaptive;
358    use gsm_core::{
359        CardAction, CardBlock, MessageCard, OutKind, OutMessage, Platform, make_tenant_ctx,
360    };
361    use once_cell::sync::Lazy;
362    use security::jwt::{JwtConfig, JwtSigner};
363    use std::sync::Mutex;
364
365    static ACTION_LINK_TEST_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
366
367    fn sample_out_message(kind: OutKind) -> OutMessage {
368        OutMessage {
369            ctx: make_tenant_ctx("acme".into(), None, None),
370            tenant: "acme".into(),
371            platform: Platform::Telegram,
372            chat_id: "chat-1".into(),
373            thread_id: None,
374            kind,
375            text: None,
376            message_card: None,
377
378            adaptive_card: None,
379            meta: Default::default(),
380        }
381    }
382
383    #[test]
384    fn telegram_text_payload() {
385        let mut out = sample_out_message(OutKind::Text);
386        out.text = Some("Hello & <world>".into());
387
388        let translator = TelegramTranslator::new();
389        let payloads = translator.to_platform(&out).unwrap();
390
391        assert_eq!(
392            payloads,
393            vec![json!({
394              "method": "sendMessage",
395              "parse_mode": "HTML",
396              "text": "Hello &amp; &lt;world&gt;"
397            })]
398        );
399    }
400
401    #[test]
402    fn telegram_card_payloads() {
403        let mut out = sample_out_message(OutKind::Card);
404        let _guard = ACTION_LINK_TEST_LOCK.lock().expect("action link lock");
405        clear_action_link_config();
406        out.message_card = Some(MessageCard {
407            title: Some("Weather".into()),
408            body: vec![
409                CardBlock::Text {
410                    text: "Line 1".into(),
411                    markdown: false,
412                },
413                CardBlock::Fact {
414                    label: "High".into(),
415                    value: "20C".into(),
416                },
417            ],
418            actions: vec![
419                CardAction::OpenUrl {
420                    title: "View".into(),
421                    url: "https://example.com".into(),
422                    jwt: false,
423                },
424                CardAction::Postback {
425                    title: "Ack".into(),
426                    data: json!({"ok": true}),
427                },
428            ],
429        });
430
431        let translator = TelegramTranslator::new();
432        let payloads = translator.to_platform(&out).unwrap();
433
434        assert_eq!(payloads.len(), 2);
435        assert_eq!(
436            payloads[0],
437            json!({
438              "method": "sendMessage",
439              "parse_mode": "HTML",
440              "text": "<b>Weather</b>\nLine 1\n• <b>High</b>: 20C"
441            })
442        );
443        assert_eq!(
444            payloads[1],
445            json!({
446              "method": "sendMessage",
447              "parse_mode": "HTML",
448              "text": "Actions:",
449              "reply_markup": {
450                "inline_keyboard": [
451                  [{"text": "View", "url": "https://example.com"}],
452                  [{"text": "Ack", "callback_data": "{\"ok\":true}"}]
453                ]
454              }
455            })
456        );
457    }
458
459    #[test]
460    fn telegram_card_actions_are_signed_when_configured() {
461        let _guard = ACTION_LINK_TEST_LOCK.lock().expect("action link lock");
462        let signer = JwtSigner::from_config(JwtConfig::hs256("signing-secret")).expect("signer");
463        set_action_link_config(ActionLinkConfig::with_default_ttl(
464            "https://actions.test/a",
465            signer.clone(),
466        ));
467        let mut out = sample_out_message(OutKind::Card);
468        out.message_card = Some(MessageCard {
469            title: None,
470            body: vec![],
471            actions: vec![CardAction::OpenUrl {
472                title: "Open".into(),
473                url: "https://example.com/path".into(),
474                jwt: true,
475            }],
476        });
477
478        let translator = TelegramTranslator::new();
479        let payloads = translator.to_platform(&out).unwrap();
480
481        assert_eq!(payloads.len(), 2);
482        let keyboard = &payloads[1]["reply_markup"]["inline_keyboard"];
483        let signed_url = keyboard[0][0]["url"].as_str().unwrap();
484        assert!(signed_url.starts_with("https://actions.test/a?action="));
485
486        let token = signed_url.split("action=").nth(1).expect("token missing");
487        let decoded_token = urlencoding::decode(token).expect("decode token");
488        let claims = signer.verify(&decoded_token).expect("claims");
489        assert_eq!(claims.redirect.as_deref(), Some("https://example.com/path"));
490        assert_eq!(claims.tenant, out.tenant);
491        clear_action_link_config();
492    }
493
494    #[test]
495    fn webchat_text_payload() {
496        let mut out = sample_out_message(OutKind::Text);
497        out.platform = Platform::WebChat;
498        out.text = Some("Hello WebChat".into());
499
500        let translator = WebChatTranslator::new();
501        let payloads = translator.to_platform(&out).unwrap();
502
503        assert_eq!(
504            payloads,
505            vec![json!({
506              "kind": "text",
507              "text": "Hello WebChat"
508            })]
509        );
510    }
511
512    #[test]
513    fn webchat_card_payload() {
514        let mut out = sample_out_message(OutKind::Card);
515        out.message_card = Some(MessageCard {
516            title: Some("Title".into()),
517            body: vec![CardBlock::Text {
518                text: "Hello".into(),
519                markdown: true,
520            }],
521            actions: vec![],
522        });
523
524        out.platform = Platform::WebChat;
525        let expected_card = out.message_card.clone();
526
527        let translator = WebChatTranslator::new();
528        let payloads = translator.to_platform(&out).unwrap();
529
530        assert_eq!(
531            payloads,
532            vec![json!({
533              "kind": "card",
534              "card": expected_card
535            })]
536        );
537    }
538
539    #[test]
540    fn teams_card_payload() {
541        let signer = JwtSigner::from_config(JwtConfig::hs256("signing-secret")).expect("signer");
542        set_action_link_config(ActionLinkConfig::with_default_ttl(
543            "https://actions.test/a",
544            signer,
545        ));
546        let card = MessageCard {
547            title: Some("Weather".into()),
548            body: vec![
549                CardBlock::Text {
550                    text: "Line".into(),
551                    markdown: false,
552                },
553                CardBlock::Fact {
554                    label: "High".into(),
555                    value: "20C".into(),
556                },
557            ],
558            actions: vec![CardAction::OpenUrl {
559                title: "View".into(),
560                url: "https://example.com".into(),
561                jwt: false,
562            }],
563        };
564
565        let mut out = sample_out_message(OutKind::Card);
566        out.platform = Platform::Teams;
567        let adaptive = to_teams_adaptive(&card, &out).unwrap();
568        assert_eq!(adaptive["type"], "AdaptiveCard");
569        assert_eq!(adaptive["body"][0]["text"], "Weather");
570        assert_eq!(adaptive["actions"][0]["type"], "Action.OpenUrl");
571        let action_url = adaptive["actions"][0]["url"].as_str().unwrap();
572        assert!(action_url.starts_with("https://actions.test/a?action="));
573        clear_action_link_config();
574    }
575}