mur_common/bridge/
routes.rs1use serde::{Deserialize, Serialize};
11
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13pub struct BridgeRouteConfig {
14 pub default_route: String,
15 #[serde(default)]
16 pub routes: Vec<RouteEntry>,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct RouteEntry {
21 #[serde(rename = "match")]
22 pub match_: RouteMatch,
23 pub agent: String,
24 #[serde(default, skip_serializing_if = "Vec::is_empty")]
25 pub fanout: Vec<String>,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct RouteMatch {
30 pub platform: String,
31 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub mention: Option<String>,
33 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub chat_id: Option<String>,
35}
36
37#[derive(Debug, Clone)]
38pub struct InboundMessage {
39 pub platform: String,
40 pub chat_id: String,
41 pub body: String,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct Resolution {
46 primary: String,
47 fanout: Vec<String>,
48}
49
50impl Resolution {
51 pub fn recipients(&self) -> Vec<String> {
52 if self.fanout.is_empty() {
53 vec![self.primary.clone()]
54 } else {
55 self.fanout.clone()
56 }
57 }
58}
59
60impl BridgeRouteConfig {
61 pub fn resolve(&self, inbound: &InboundMessage) -> Resolution {
62 for entry in &self.routes {
64 if entry.match_.platform != inbound.platform {
65 continue;
66 }
67 if let Some(m) = &entry.match_.mention
68 && inbound.body.contains(m.as_str())
69 {
70 return Resolution {
71 primary: entry.agent.clone(),
72 fanout: entry.fanout.clone(),
73 };
74 }
75 }
76 for entry in &self.routes {
78 if entry.match_.platform != inbound.platform {
79 continue;
80 }
81 if entry.match_.mention.is_some() {
82 continue;
83 }
84 if let Some(c) = &entry.match_.chat_id
85 && c == &inbound.chat_id
86 {
87 return Resolution {
88 primary: entry.agent.clone(),
89 fanout: entry.fanout.clone(),
90 };
91 }
92 }
93 Resolution {
95 primary: self.default_route.clone(),
96 fanout: vec![],
97 }
98 }
99}
100
101#[cfg(test)]
102mod tests {
103 use super::*;
104 const SAMPLE: &str = r#"
105default_route: coach
106routes:
107 - match: { platform: telegram, mention: "@coach" }
108 agent: coach
109 - match: { platform: telegram, chat_id: "12345" }
110 agent: therapist
111 - match: { platform: telegram, chat_id: "67890" }
112 agent: coach
113 fanout: [coach, journal_agent]
114"#;
115 #[test]
116 fn parses_full_example() {
117 let cfg: BridgeRouteConfig = serde_yaml_ng::from_str(SAMPLE).unwrap();
118 assert_eq!(cfg.default_route, "coach");
119 assert_eq!(cfg.routes.len(), 3);
120 }
121 #[test]
122 fn round_trip_preserves_fields() {
123 let cfg: BridgeRouteConfig = serde_yaml_ng::from_str(SAMPLE).unwrap();
124 let s = serde_yaml_ng::to_string(&cfg).unwrap();
125 assert_eq!(
126 serde_yaml_ng::from_str::<BridgeRouteConfig>(&s).unwrap(),
127 cfg
128 );
129 }
130
131 #[test]
132 fn resolve_falls_back_to_default() {
133 let cfg: BridgeRouteConfig = serde_yaml_ng::from_str(SAMPLE).unwrap();
134 let r = cfg.resolve(&InboundMessage {
135 platform: "telegram".into(),
136 chat_id: "99999".into(),
137 body: "hello".into(),
138 });
139 assert_eq!(r.recipients(), vec!["coach"]);
140 }
141
142 #[test]
143 fn mention_wins_over_chat_id() {
144 let cfg: BridgeRouteConfig = serde_yaml_ng::from_str(SAMPLE).unwrap();
145 let r = cfg.resolve(&InboundMessage {
146 platform: "telegram".into(),
147 chat_id: "12345".into(), body: "hey @coach help".into(), });
150 assert_eq!(r.recipients(), vec!["coach"]);
151 }
152
153 #[test]
154 fn chat_id_when_no_mention() {
155 let cfg: BridgeRouteConfig = serde_yaml_ng::from_str(SAMPLE).unwrap();
156 let r = cfg.resolve(&InboundMessage {
157 platform: "telegram".into(),
158 chat_id: "12345".into(),
159 body: "no mentions".into(),
160 });
161 assert_eq!(r.recipients(), vec!["therapist"]);
162 }
163
164 #[test]
165 fn fanout_returns_full_list() {
166 let cfg: BridgeRouteConfig = serde_yaml_ng::from_str(SAMPLE).unwrap();
167 let r = cfg.resolve(&InboundMessage {
168 platform: "telegram".into(),
169 chat_id: "67890".into(),
170 body: "ping".into(),
171 });
172 assert_eq!(r.recipients(), vec!["coach", "journal_agent"]);
173 }
174
175 #[test]
176 fn platform_mismatch_falls_through() {
177 let cfg: BridgeRouteConfig = serde_yaml_ng::from_str(SAMPLE).unwrap();
178 let r = cfg.resolve(&InboundMessage {
179 platform: "slack".into(),
180 chat_id: "12345".into(),
181 body: "ping".into(),
182 });
183 assert_eq!(r.recipients(), vec!["coach"]);
184 }
185}