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")]
61 pub message_kind: Option<String>,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub deadline_ms: Option<u64>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub idempotency_key: Option<String>,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77pub struct MessageReceipt {
78 pub channel: String,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub message_id: Option<String>,
84 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub topic_id: Option<String>,
87 pub deduplicated: bool,
92 #[serde(default)]
94 pub delivered: bool,
95 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub reason: Option<String>,
98 #[serde(default)]
100 pub queued: bool,
101}
102
103impl MessageReceipt {
104 pub fn delivered(channel: impl Into<String>) -> Self {
106 Self {
107 channel: channel.into(),
108 message_id: None,
109 topic_id: None,
110 deduplicated: false,
111 queued: false,
112 delivered: true,
113 reason: None,
114 }
115 }
116
117 pub fn with_message_id(mut self, id: impl Into<String>) -> Self {
119 self.message_id = Some(id.into());
120 self
121 }
122}
123
124#[async_trait::async_trait]
132pub trait MessageSink: Send + Sync {
133 async fn channels(&self) -> Vec<String>;
140
141 async fn send(&self, msg: &OutboundMessage) -> Result<MessageReceipt, String>;
147}
148
149impl OutboundMessage {
150 pub fn from_tool_params(params: &Value) -> Result<Self, String> {
163 let obj = params
164 .as_object()
165 .ok_or("messaging.send: parameters must be a JSON object")?;
166
167 let channel = required_str(obj.get("channel"), "channel")?;
168
169 let address = match obj.get("to") {
170 Some(v) => required_str(Some(v), "to")?,
171 None => {
172 return Err("messaging.send: missing required parameter 'to' — \
173 pass 'to' naming the handle or channel id"
174 .to_string())
175 }
176 };
177
178 let to = match obj.get("kind") {
181 None | Some(Value::Null) => Recipient::Direct(address),
182 Some(Value::String(k)) => match k.as_str() {
183 "direct" => Recipient::Direct(address),
184 "channel" => Recipient::Channel(address),
185 other => {
186 return Err(format!(
187 "messaging.send: unknown kind '{other}' — expected \
188 'direct' (a person) or 'channel' (a shared channel)"
189 ))
190 }
191 },
192 Some(other) => {
193 return Err(format!(
194 "messaging.send: 'kind' must be the string 'direct' or \
195 'channel', got {other}"
196 ))
197 }
198 };
199
200 let body = required_str(obj.get("body"), "body")?;
201
202 let idempotency_key = match obj.get("idempotency_key") {
203 None | Some(Value::Null) => None,
204 Some(Value::String(s)) if !s.trim().is_empty() => Some(s.clone()),
205 Some(Value::String(_)) => {
206 return Err("messaging.send: 'idempotency_key' must not be blank \
207 — omit it entirely to opt out of dedup"
208 .to_string())
209 }
210 Some(other) => {
211 return Err(format!(
212 "messaging.send: 'idempotency_key' must be a string, got {other}"
213 ))
214 }
215 };
216
217 let deadline_ms = obj
218 .get("deadline_ms")
219 .filter(|v| !v.is_null())
220 .map(|v| {
221 v.as_u64().ok_or_else(|| {
222 "messaging.send: 'deadline_ms' must be a Unix-millisecond unsigned integer"
223 .to_string()
224 })
225 })
226 .transpose()?;
227 Ok(Self {
228 channel,
229 to,
230 body,
231 deadline_ms,
232 message_kind: obj
233 .get("message_kind")
234 .filter(|v| !v.is_null())
235 .map(|v| required_str(Some(v), "message_kind"))
236 .transpose()?,
237 idempotency_key,
238 })
239 }
240}
241
242fn required_str(value: Option<&Value>, field: &str) -> Result<String, String> {
246 match value {
247 None | Some(Value::Null) => Err(format!(
248 "messaging.send: missing required parameter '{field}'"
249 )),
250 Some(Value::String(s)) if !s.trim().is_empty() => Ok(s.clone()),
251 Some(Value::String(_)) => Err(format!("messaging.send: '{field}' must not be empty")),
252 Some(other) => Err(format!(
253 "messaging.send: '{field}' must be a string, got {other}"
254 )),
255 }
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261 use serde_json::json;
262
263 #[test]
264 fn parses_direct_by_default() {
265 let msg = OutboundMessage::from_tool_params(&json!({
266 "channel": "imessage",
267 "to": "+15551112222",
268 "body": "build is green",
269 }))
270 .unwrap();
271 assert_eq!(msg.channel, "imessage");
272 assert_eq!(msg.to, Recipient::Direct("+15551112222".into()));
273 assert_eq!(msg.body, "build is green");
274 assert!(msg.idempotency_key.is_none());
275 }
276
277 #[test]
278 fn parses_explicit_kinds() {
279 let direct = OutboundMessage::from_tool_params(&json!({
280 "channel": "imessage",
281 "to": "keenan@parslee.ai",
282 "kind": "direct",
283 "body": "hi",
284 "idempotency_key": "run-42",
285 }))
286 .unwrap();
287 assert_eq!(direct.to, Recipient::Direct("keenan@parslee.ai".into()));
288 assert_eq!(direct.idempotency_key.as_deref(), Some("run-42"));
289
290 let channel = OutboundMessage::from_tool_params(&json!({
291 "channel": "slack",
292 "to": "C012ABCDEF",
293 "kind": "channel",
294 "body": "deploy done",
295 }))
296 .unwrap();
297 assert_eq!(channel.to, Recipient::Channel("C012ABCDEF".into()));
298 }
299
300 #[test]
303 fn rejects_recipient_as_an_alias_for_to() {
304 let err = OutboundMessage::from_tool_params(&json!({
305 "channel": "imessage",
306 "recipient": "+15551112222",
307 "body": "hi",
308 }))
309 .unwrap_err();
310 assert!(err.contains("missing required parameter 'to'"), "{err}");
311 }
312
313 #[test]
314 fn rejects_non_object_params() {
315 let err = OutboundMessage::from_tool_params(&json!("just a string")).unwrap_err();
316 assert!(err.contains("must be a JSON object"), "{err}");
317 }
318
319 #[test]
320 fn rejects_missing_channel() {
321 let err = OutboundMessage::from_tool_params(&json!({
322 "to": "+15551112222",
323 "body": "hi",
324 }))
325 .unwrap_err();
326 assert!(
327 err.contains("missing required parameter 'channel'"),
328 "{err}"
329 );
330 }
331
332 #[test]
333 fn rejects_blank_channel() {
334 let err = OutboundMessage::from_tool_params(&json!({
335 "channel": " ",
336 "to": "+15551112222",
337 "body": "hi",
338 }))
339 .unwrap_err();
340 assert!(err.contains("'channel' must not be empty"), "{err}");
341 }
342
343 #[test]
344 fn rejects_missing_recipient() {
345 let err = OutboundMessage::from_tool_params(&json!({
346 "channel": "imessage",
347 "body": "hi",
348 }))
349 .unwrap_err();
350 assert!(err.contains("missing required parameter 'to'"), "{err}");
351 }
352
353 #[test]
354 fn rejects_missing_body() {
355 let err = OutboundMessage::from_tool_params(&json!({
356 "channel": "imessage",
357 "to": "+15551112222",
358 }))
359 .unwrap_err();
360 assert!(err.contains("missing required parameter 'body'"), "{err}");
361 }
362
363 #[test]
364 fn rejects_non_string_body() {
365 let err = OutboundMessage::from_tool_params(&json!({
366 "channel": "imessage",
367 "to": "+15551112222",
368 "body": 42,
369 }))
370 .unwrap_err();
371 assert!(err.contains("'body' must be a string"), "{err}");
372 }
373
374 #[test]
375 fn rejects_unknown_kind() {
376 let err = OutboundMessage::from_tool_params(&json!({
377 "channel": "imessage",
378 "to": "+15551112222",
379 "kind": "broadcast",
380 "body": "hi",
381 }))
382 .unwrap_err();
383 assert!(err.contains("unknown kind 'broadcast'"), "{err}");
384 assert!(err.contains("'direct'"), "{err}");
385 }
386
387 #[test]
388 fn rejects_non_string_kind() {
389 let err = OutboundMessage::from_tool_params(&json!({
390 "channel": "imessage",
391 "to": "+15551112222",
392 "kind": true,
393 "body": "hi",
394 }))
395 .unwrap_err();
396 assert!(err.contains("'kind' must be the string"), "{err}");
397 }
398
399 #[test]
400 fn rejects_blank_idempotency_key() {
401 let err = OutboundMessage::from_tool_params(&json!({
402 "channel": "imessage",
403 "to": "+15551112222",
404 "body": "hi",
405 "idempotency_key": "",
406 }))
407 .unwrap_err();
408 assert!(err.contains("'idempotency_key' must not be blank"), "{err}");
409 }
410
411 #[test]
412 fn recipient_serializes_with_the_tool_vocabulary() {
413 let json = serde_json::to_value(Recipient::Direct("+15551112222".into())).unwrap();
416 assert_eq!(json, json!({ "kind": "direct", "to": "+15551112222" }));
417 let round: Recipient = serde_json::from_value(json).unwrap();
418 assert_eq!(round, Recipient::Direct("+15551112222".into()));
419
420 let json = serde_json::to_value(Recipient::Channel("C1".into())).unwrap();
421 assert_eq!(json, json!({ "kind": "channel", "to": "C1" }));
422 }
423
424 #[test]
425 fn receipt_round_trips() {
426 let receipt = MessageReceipt::delivered("imessage").with_message_id("m-1");
427 let json = serde_json::to_value(&receipt).unwrap();
428 assert_eq!(
429 json,
430 json!({ "channel": "imessage", "message_id": "m-1", "deduplicated": false, "queued": false, "delivered": true })
431 );
432 let round: MessageReceipt = serde_json::from_value(json).unwrap();
433 assert_eq!(round, receipt);
434 }
435}