1use 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
24pub 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
121pub struct TelegramTranslator;
149
150impl TelegramTranslator {
151 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 "&" => "&",
248 "<" => "<",
249 ">" => ">",
250 _ => grapheme,
251 });
252 }
253 escaped
254}
255
256pub struct WebChatTranslator;
257
258impl WebChatTranslator {
259 pub fn new() -> Self {
261 Self
262 }
263}
264
265impl Default for WebChatTranslator {
266 fn default() -> Self {
267 Self::new()
268 }
269}
270
271impl 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
332pub 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 & <world>"
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}