1use serde::{Deserialize, Serialize};
26use serde_json::Value;
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(tag = "kind", content = "to", rename_all = "lowercase")]
41pub enum Recipient {
42 Direct(String),
45 Channel(String),
47}
48
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51pub struct OutboundMessage {
52 pub channel: String,
55 pub to: Recipient,
57 pub body: String,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub idempotency_key: Option<String>,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71pub struct MessageReceipt {
72 pub channel: String,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub message_id: Option<String>,
78 pub deduplicated: bool,
83}
84
85impl MessageReceipt {
86 pub fn delivered(channel: impl Into<String>) -> Self {
88 Self {
89 channel: channel.into(),
90 message_id: None,
91 deduplicated: false,
92 }
93 }
94
95 pub fn with_message_id(mut self, id: impl Into<String>) -> Self {
97 self.message_id = Some(id.into());
98 self
99 }
100}
101
102#[async_trait::async_trait]
110pub trait MessageSink: Send + Sync {
111 async fn channels(&self) -> Vec<String>;
118
119 async fn send(&self, msg: &OutboundMessage) -> Result<MessageReceipt, String>;
125}
126
127impl OutboundMessage {
128 pub fn from_tool_params(params: &Value) -> Result<Self, String> {
141 let obj = params
142 .as_object()
143 .ok_or("messaging.send: parameters must be a JSON object")?;
144
145 let channel = required_str(obj.get("channel"), "channel")?;
146
147 let address = match obj.get("to") {
148 Some(v) => required_str(Some(v), "to")?,
149 None => {
150 return Err("messaging.send: missing required parameter 'to' — \
151 pass 'to' naming the handle or channel id"
152 .to_string())
153 }
154 };
155
156 let to = match obj.get("kind") {
159 None | Some(Value::Null) => Recipient::Direct(address),
160 Some(Value::String(k)) => match k.as_str() {
161 "direct" => Recipient::Direct(address),
162 "channel" => Recipient::Channel(address),
163 other => {
164 return Err(format!(
165 "messaging.send: unknown kind '{other}' — expected \
166 'direct' (a person) or 'channel' (a shared channel)"
167 ))
168 }
169 },
170 Some(other) => {
171 return Err(format!(
172 "messaging.send: 'kind' must be the string 'direct' or \
173 'channel', got {other}"
174 ))
175 }
176 };
177
178 let body = required_str(obj.get("body"), "body")?;
179
180 let idempotency_key = match obj.get("idempotency_key") {
181 None | Some(Value::Null) => None,
182 Some(Value::String(s)) if !s.trim().is_empty() => Some(s.clone()),
183 Some(Value::String(_)) => {
184 return Err("messaging.send: 'idempotency_key' must not be blank \
185 — omit it entirely to opt out of dedup"
186 .to_string())
187 }
188 Some(other) => {
189 return Err(format!(
190 "messaging.send: 'idempotency_key' must be a string, got {other}"
191 ))
192 }
193 };
194
195 Ok(Self {
196 channel,
197 to,
198 body,
199 idempotency_key,
200 })
201 }
202}
203
204fn required_str(value: Option<&Value>, field: &str) -> Result<String, String> {
208 match value {
209 None | Some(Value::Null) => Err(format!(
210 "messaging.send: missing required parameter '{field}'"
211 )),
212 Some(Value::String(s)) if !s.trim().is_empty() => Ok(s.clone()),
213 Some(Value::String(_)) => Err(format!("messaging.send: '{field}' must not be empty")),
214 Some(other) => Err(format!(
215 "messaging.send: '{field}' must be a string, got {other}"
216 )),
217 }
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223 use serde_json::json;
224
225 #[test]
226 fn parses_direct_by_default() {
227 let msg = OutboundMessage::from_tool_params(&json!({
228 "channel": "imessage",
229 "to": "+15551112222",
230 "body": "build is green",
231 }))
232 .unwrap();
233 assert_eq!(msg.channel, "imessage");
234 assert_eq!(msg.to, Recipient::Direct("+15551112222".into()));
235 assert_eq!(msg.body, "build is green");
236 assert!(msg.idempotency_key.is_none());
237 }
238
239 #[test]
240 fn parses_explicit_kinds() {
241 let direct = OutboundMessage::from_tool_params(&json!({
242 "channel": "imessage",
243 "to": "keenan@parslee.ai",
244 "kind": "direct",
245 "body": "hi",
246 "idempotency_key": "run-42",
247 }))
248 .unwrap();
249 assert_eq!(direct.to, Recipient::Direct("keenan@parslee.ai".into()));
250 assert_eq!(direct.idempotency_key.as_deref(), Some("run-42"));
251
252 let channel = OutboundMessage::from_tool_params(&json!({
253 "channel": "slack",
254 "to": "C012ABCDEF",
255 "kind": "channel",
256 "body": "deploy done",
257 }))
258 .unwrap();
259 assert_eq!(channel.to, Recipient::Channel("C012ABCDEF".into()));
260 }
261
262 #[test]
265 fn rejects_recipient_as_an_alias_for_to() {
266 let err = OutboundMessage::from_tool_params(&json!({
267 "channel": "imessage",
268 "recipient": "+15551112222",
269 "body": "hi",
270 }))
271 .unwrap_err();
272 assert!(err.contains("missing required parameter 'to'"), "{err}");
273 }
274
275 #[test]
276 fn rejects_non_object_params() {
277 let err = OutboundMessage::from_tool_params(&json!("just a string")).unwrap_err();
278 assert!(err.contains("must be a JSON object"), "{err}");
279 }
280
281 #[test]
282 fn rejects_missing_channel() {
283 let err = OutboundMessage::from_tool_params(&json!({
284 "to": "+15551112222",
285 "body": "hi",
286 }))
287 .unwrap_err();
288 assert!(
289 err.contains("missing required parameter 'channel'"),
290 "{err}"
291 );
292 }
293
294 #[test]
295 fn rejects_blank_channel() {
296 let err = OutboundMessage::from_tool_params(&json!({
297 "channel": " ",
298 "to": "+15551112222",
299 "body": "hi",
300 }))
301 .unwrap_err();
302 assert!(err.contains("'channel' must not be empty"), "{err}");
303 }
304
305 #[test]
306 fn rejects_missing_recipient() {
307 let err = OutboundMessage::from_tool_params(&json!({
308 "channel": "imessage",
309 "body": "hi",
310 }))
311 .unwrap_err();
312 assert!(err.contains("missing required parameter 'to'"), "{err}");
313 }
314
315 #[test]
316 fn rejects_missing_body() {
317 let err = OutboundMessage::from_tool_params(&json!({
318 "channel": "imessage",
319 "to": "+15551112222",
320 }))
321 .unwrap_err();
322 assert!(err.contains("missing required parameter 'body'"), "{err}");
323 }
324
325 #[test]
326 fn rejects_non_string_body() {
327 let err = OutboundMessage::from_tool_params(&json!({
328 "channel": "imessage",
329 "to": "+15551112222",
330 "body": 42,
331 }))
332 .unwrap_err();
333 assert!(err.contains("'body' must be a string"), "{err}");
334 }
335
336 #[test]
337 fn rejects_unknown_kind() {
338 let err = OutboundMessage::from_tool_params(&json!({
339 "channel": "imessage",
340 "to": "+15551112222",
341 "kind": "broadcast",
342 "body": "hi",
343 }))
344 .unwrap_err();
345 assert!(err.contains("unknown kind 'broadcast'"), "{err}");
346 assert!(err.contains("'direct'"), "{err}");
347 }
348
349 #[test]
350 fn rejects_non_string_kind() {
351 let err = OutboundMessage::from_tool_params(&json!({
352 "channel": "imessage",
353 "to": "+15551112222",
354 "kind": true,
355 "body": "hi",
356 }))
357 .unwrap_err();
358 assert!(err.contains("'kind' must be the string"), "{err}");
359 }
360
361 #[test]
362 fn rejects_blank_idempotency_key() {
363 let err = OutboundMessage::from_tool_params(&json!({
364 "channel": "imessage",
365 "to": "+15551112222",
366 "body": "hi",
367 "idempotency_key": "",
368 }))
369 .unwrap_err();
370 assert!(err.contains("'idempotency_key' must not be blank"), "{err}");
371 }
372
373 #[test]
374 fn recipient_serializes_with_the_tool_vocabulary() {
375 let json = serde_json::to_value(Recipient::Direct("+15551112222".into())).unwrap();
378 assert_eq!(json, json!({ "kind": "direct", "to": "+15551112222" }));
379 let round: Recipient = serde_json::from_value(json).unwrap();
380 assert_eq!(round, Recipient::Direct("+15551112222".into()));
381
382 let json = serde_json::to_value(Recipient::Channel("C1".into())).unwrap();
383 assert_eq!(json, json!({ "kind": "channel", "to": "C1" }));
384 }
385
386 #[test]
387 fn receipt_round_trips() {
388 let receipt = MessageReceipt::delivered("imessage").with_message_id("m-1");
389 let json = serde_json::to_value(&receipt).unwrap();
390 assert_eq!(
391 json,
392 json!({ "channel": "imessage", "message_id": "m-1", "deduplicated": false })
393 );
394 let round: MessageReceipt = serde_json::from_value(json).unwrap();
395 assert_eq!(round, receipt);
396 }
397}