use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BridgeRouteConfig {
pub default_route: String,
#[serde(default)]
pub routes: Vec<RouteEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RouteEntry {
#[serde(rename = "match")]
pub match_: RouteMatch,
pub agent: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub fanout: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RouteMatch {
pub platform: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mention: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub chat_id: Option<String>,
}
#[derive(Debug, Clone)]
pub struct InboundMessage {
pub platform: String,
pub chat_id: String,
pub body: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Resolution {
primary: String,
fanout: Vec<String>,
}
impl Resolution {
pub fn recipients(&self) -> Vec<String> {
if self.fanout.is_empty() {
vec![self.primary.clone()]
} else {
self.fanout.clone()
}
}
}
impl BridgeRouteConfig {
pub fn resolve(&self, inbound: &InboundMessage) -> Resolution {
for entry in &self.routes {
if entry.match_.platform != inbound.platform {
continue;
}
if let Some(m) = &entry.match_.mention
&& inbound.body.contains(m.as_str())
{
return Resolution {
primary: entry.agent.clone(),
fanout: entry.fanout.clone(),
};
}
}
for entry in &self.routes {
if entry.match_.platform != inbound.platform {
continue;
}
if entry.match_.mention.is_some() {
continue;
}
if let Some(c) = &entry.match_.chat_id
&& c == &inbound.chat_id
{
return Resolution {
primary: entry.agent.clone(),
fanout: entry.fanout.clone(),
};
}
}
Resolution {
primary: self.default_route.clone(),
fanout: vec![],
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = r#"
default_route: coach
routes:
- match: { platform: telegram, mention: "@coach" }
agent: coach
- match: { platform: telegram, chat_id: "12345" }
agent: therapist
- match: { platform: telegram, chat_id: "67890" }
agent: coach
fanout: [coach, journal_agent]
"#;
#[test]
fn parses_full_example() {
let cfg: BridgeRouteConfig = serde_yaml_ng::from_str(SAMPLE).unwrap();
assert_eq!(cfg.default_route, "coach");
assert_eq!(cfg.routes.len(), 3);
}
#[test]
fn round_trip_preserves_fields() {
let cfg: BridgeRouteConfig = serde_yaml_ng::from_str(SAMPLE).unwrap();
let s = serde_yaml_ng::to_string(&cfg).unwrap();
assert_eq!(
serde_yaml_ng::from_str::<BridgeRouteConfig>(&s).unwrap(),
cfg
);
}
#[test]
fn resolve_falls_back_to_default() {
let cfg: BridgeRouteConfig = serde_yaml_ng::from_str(SAMPLE).unwrap();
let r = cfg.resolve(&InboundMessage {
platform: "telegram".into(),
chat_id: "99999".into(),
body: "hello".into(),
});
assert_eq!(r.recipients(), vec!["coach"]);
}
#[test]
fn mention_wins_over_chat_id() {
let cfg: BridgeRouteConfig = serde_yaml_ng::from_str(SAMPLE).unwrap();
let r = cfg.resolve(&InboundMessage {
platform: "telegram".into(),
chat_id: "12345".into(), body: "hey @coach help".into(), });
assert_eq!(r.recipients(), vec!["coach"]);
}
#[test]
fn chat_id_when_no_mention() {
let cfg: BridgeRouteConfig = serde_yaml_ng::from_str(SAMPLE).unwrap();
let r = cfg.resolve(&InboundMessage {
platform: "telegram".into(),
chat_id: "12345".into(),
body: "no mentions".into(),
});
assert_eq!(r.recipients(), vec!["therapist"]);
}
#[test]
fn fanout_returns_full_list() {
let cfg: BridgeRouteConfig = serde_yaml_ng::from_str(SAMPLE).unwrap();
let r = cfg.resolve(&InboundMessage {
platform: "telegram".into(),
chat_id: "67890".into(),
body: "ping".into(),
});
assert_eq!(r.recipients(), vec!["coach", "journal_agent"]);
}
#[test]
fn platform_mismatch_falls_through() {
let cfg: BridgeRouteConfig = serde_yaml_ng::from_str(SAMPLE).unwrap();
let r = cfg.resolve(&InboundMessage {
platform: "slack".into(),
chat_id: "12345".into(),
body: "ping".into(),
});
assert_eq!(r.recipients(), vec!["coach"]);
}
}