use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", content = "to", rename_all = "lowercase")]
pub enum Recipient {
Direct(String),
Channel(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OutboundMessage {
pub channel: String,
pub to: Recipient,
pub body: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub idempotency_key: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MessageReceipt {
pub channel: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message_id: Option<String>,
pub deduplicated: bool,
}
impl MessageReceipt {
pub fn delivered(channel: impl Into<String>) -> Self {
Self {
channel: channel.into(),
message_id: None,
deduplicated: false,
}
}
pub fn with_message_id(mut self, id: impl Into<String>) -> Self {
self.message_id = Some(id.into());
self
}
}
#[async_trait::async_trait]
pub trait MessageSink: Send + Sync {
async fn channels(&self) -> Vec<String>;
async fn send(&self, msg: &OutboundMessage) -> Result<MessageReceipt, String>;
}
impl OutboundMessage {
pub fn from_tool_params(params: &Value) -> Result<Self, String> {
let obj = params
.as_object()
.ok_or("messaging.send: parameters must be a JSON object")?;
let channel = required_str(obj.get("channel"), "channel")?;
let address = match obj.get("to") {
Some(v) => required_str(Some(v), "to")?,
None => {
return Err("messaging.send: missing required parameter 'to' — \
pass 'to' naming the handle or channel id"
.to_string())
}
};
let to = match obj.get("kind") {
None | Some(Value::Null) => Recipient::Direct(address),
Some(Value::String(k)) => match k.as_str() {
"direct" => Recipient::Direct(address),
"channel" => Recipient::Channel(address),
other => {
return Err(format!(
"messaging.send: unknown kind '{other}' — expected \
'direct' (a person) or 'channel' (a shared channel)"
))
}
},
Some(other) => {
return Err(format!(
"messaging.send: 'kind' must be the string 'direct' or \
'channel', got {other}"
))
}
};
let body = required_str(obj.get("body"), "body")?;
let idempotency_key = match obj.get("idempotency_key") {
None | Some(Value::Null) => None,
Some(Value::String(s)) if !s.trim().is_empty() => Some(s.clone()),
Some(Value::String(_)) => {
return Err("messaging.send: 'idempotency_key' must not be blank \
— omit it entirely to opt out of dedup"
.to_string())
}
Some(other) => {
return Err(format!(
"messaging.send: 'idempotency_key' must be a string, got {other}"
))
}
};
Ok(Self {
channel,
to,
body,
idempotency_key,
})
}
}
fn required_str(value: Option<&Value>, field: &str) -> Result<String, String> {
match value {
None | Some(Value::Null) => Err(format!(
"messaging.send: missing required parameter '{field}'"
)),
Some(Value::String(s)) if !s.trim().is_empty() => Ok(s.clone()),
Some(Value::String(_)) => Err(format!("messaging.send: '{field}' must not be empty")),
Some(other) => Err(format!(
"messaging.send: '{field}' must be a string, got {other}"
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn parses_direct_by_default() {
let msg = OutboundMessage::from_tool_params(&json!({
"channel": "imessage",
"to": "+15551112222",
"body": "build is green",
}))
.unwrap();
assert_eq!(msg.channel, "imessage");
assert_eq!(msg.to, Recipient::Direct("+15551112222".into()));
assert_eq!(msg.body, "build is green");
assert!(msg.idempotency_key.is_none());
}
#[test]
fn parses_explicit_kinds() {
let direct = OutboundMessage::from_tool_params(&json!({
"channel": "imessage",
"to": "keenan@parslee.ai",
"kind": "direct",
"body": "hi",
"idempotency_key": "run-42",
}))
.unwrap();
assert_eq!(direct.to, Recipient::Direct("keenan@parslee.ai".into()));
assert_eq!(direct.idempotency_key.as_deref(), Some("run-42"));
let channel = OutboundMessage::from_tool_params(&json!({
"channel": "slack",
"to": "C012ABCDEF",
"kind": "channel",
"body": "deploy done",
}))
.unwrap();
assert_eq!(channel.to, Recipient::Channel("C012ABCDEF".into()));
}
#[test]
fn rejects_recipient_as_an_alias_for_to() {
let err = OutboundMessage::from_tool_params(&json!({
"channel": "imessage",
"recipient": "+15551112222",
"body": "hi",
}))
.unwrap_err();
assert!(err.contains("missing required parameter 'to'"), "{err}");
}
#[test]
fn rejects_non_object_params() {
let err = OutboundMessage::from_tool_params(&json!("just a string")).unwrap_err();
assert!(err.contains("must be a JSON object"), "{err}");
}
#[test]
fn rejects_missing_channel() {
let err = OutboundMessage::from_tool_params(&json!({
"to": "+15551112222",
"body": "hi",
}))
.unwrap_err();
assert!(
err.contains("missing required parameter 'channel'"),
"{err}"
);
}
#[test]
fn rejects_blank_channel() {
let err = OutboundMessage::from_tool_params(&json!({
"channel": " ",
"to": "+15551112222",
"body": "hi",
}))
.unwrap_err();
assert!(err.contains("'channel' must not be empty"), "{err}");
}
#[test]
fn rejects_missing_recipient() {
let err = OutboundMessage::from_tool_params(&json!({
"channel": "imessage",
"body": "hi",
}))
.unwrap_err();
assert!(err.contains("missing required parameter 'to'"), "{err}");
}
#[test]
fn rejects_missing_body() {
let err = OutboundMessage::from_tool_params(&json!({
"channel": "imessage",
"to": "+15551112222",
}))
.unwrap_err();
assert!(err.contains("missing required parameter 'body'"), "{err}");
}
#[test]
fn rejects_non_string_body() {
let err = OutboundMessage::from_tool_params(&json!({
"channel": "imessage",
"to": "+15551112222",
"body": 42,
}))
.unwrap_err();
assert!(err.contains("'body' must be a string"), "{err}");
}
#[test]
fn rejects_unknown_kind() {
let err = OutboundMessage::from_tool_params(&json!({
"channel": "imessage",
"to": "+15551112222",
"kind": "broadcast",
"body": "hi",
}))
.unwrap_err();
assert!(err.contains("unknown kind 'broadcast'"), "{err}");
assert!(err.contains("'direct'"), "{err}");
}
#[test]
fn rejects_non_string_kind() {
let err = OutboundMessage::from_tool_params(&json!({
"channel": "imessage",
"to": "+15551112222",
"kind": true,
"body": "hi",
}))
.unwrap_err();
assert!(err.contains("'kind' must be the string"), "{err}");
}
#[test]
fn rejects_blank_idempotency_key() {
let err = OutboundMessage::from_tool_params(&json!({
"channel": "imessage",
"to": "+15551112222",
"body": "hi",
"idempotency_key": "",
}))
.unwrap_err();
assert!(err.contains("'idempotency_key' must not be blank"), "{err}");
}
#[test]
fn recipient_serializes_with_the_tool_vocabulary() {
let json = serde_json::to_value(Recipient::Direct("+15551112222".into())).unwrap();
assert_eq!(json, json!({ "kind": "direct", "to": "+15551112222" }));
let round: Recipient = serde_json::from_value(json).unwrap();
assert_eq!(round, Recipient::Direct("+15551112222".into()));
let json = serde_json::to_value(Recipient::Channel("C1".into())).unwrap();
assert_eq!(json, json!({ "kind": "channel", "to": "C1" }));
}
#[test]
fn receipt_round_trips() {
let receipt = MessageReceipt::delivered("imessage").with_message_id("m-1");
let json = serde_json::to_value(&receipt).unwrap();
assert_eq!(
json,
json!({ "channel": "imessage", "message_id": "m-1", "deduplicated": false })
);
let round: MessageReceipt = serde_json::from_value(json).unwrap();
assert_eq!(round, receipt);
}
}