Skip to main content

nexo_tool_meta/
inbound.rs

1//! [`InboundMessageMeta`] — per-turn metadata about the inbound
2//! message that triggered an agent turn. Stamped under
3//! `_meta.nexo.inbound` (peer of `_meta.nexo.binding`).
4//!
5//! Distinct from [`crate::BindingContext`]: the binding identifies
6//! *which tenant* the call belongs to (stable across turns within
7//! the same binding), the inbound meta describes *which message*
8//! triggered this turn (varies per turn).
9//!
10//! Provider-agnostic by construction. The same shape is produced
11//! by every inbound path:
12//! - native channel plugins (whatsapp, future telegram/email/…)
13//! - event-subscriber bindings (NATS subject → agent turn)
14//! - webhook receiver
15//! - MCP-channel server inbounds
16//! - inter-agent delegation receives
17//! - heartbeat / scheduler ticks
18
19use chrono::{DateTime, Utc};
20use serde::{Deserialize, Serialize};
21use uuid::Uuid;
22
23/// 3-way discriminator for the origin of an agent turn.
24///
25/// Mirrors the trichotomy used by inbound provenance tracking
26/// (external_user / inter_session / internal_system) so microapps
27/// can branch handlers on the message origin without re-deriving
28/// it from sender_id presence alone.
29#[non_exhaustive]
30#[cfg_attr(feature = "ts-export", derive(ts_rs::TS), ts(export))]
31#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "snake_case")]
33pub enum InboundKind {
34    /// External end-user message via a channel plugin (whatsapp,
35    /// telegram, email, …), webhook receiver, or MCP-channel
36    /// server. `sender_id` is typically populated.
37    ExternalUser,
38    /// System-injected turn (heartbeat tick, scheduler fire,
39    /// event-subscriber binding declared as `internal_system`).
40    /// `sender_id` is `None`.
41    InternalSystem,
42    /// Inter-agent delegation receive. `origin_session_id`
43    /// carries the calling agent's session.
44    InterSession,
45}
46
47/// Per-turn metadata about the inbound message that triggered the
48/// agent turn.
49///
50/// Stamped on every tool call dispatched to a stdio
51/// extension or an MCP server (under
52/// `params._meta.nexo.inbound`). All fields except `kind` are
53/// optional; consumers must tolerate absence and branch
54/// gracefully.
55///
56/// # Example — external user message
57///
58/// ```
59/// use nexo_tool_meta::{InboundKind, InboundMessageMeta};
60///
61/// let meta = InboundMessageMeta::external_user("+5491100", "wa.ABCD1234");
62/// assert_eq!(meta.kind, InboundKind::ExternalUser);
63/// assert_eq!(meta.sender_id.as_deref(), Some("+5491100"));
64/// assert_eq!(meta.msg_id.as_deref(), Some("wa.ABCD1234"));
65/// ```
66///
67/// # Example — internal system tick
68///
69/// ```
70/// use nexo_tool_meta::{InboundKind, InboundMessageMeta};
71///
72/// let meta = InboundMessageMeta::internal_system();
73/// assert_eq!(meta.kind, InboundKind::InternalSystem);
74/// assert!(meta.sender_id.is_none());
75/// assert!(meta.msg_id.is_none());
76/// ```
77#[non_exhaustive]
78#[cfg_attr(feature = "ts-export", derive(ts_rs::TS), ts(export))]
79#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
80pub struct InboundMessageMeta {
81    /// Origin discriminator. Always present when the bucket is.
82    pub kind: InboundKind,
83
84    /// Provider-native sender id (E.164 phone, telegram_user_id,
85    /// RFC822 from-address, slack U-id, …). `None` for kinds
86    /// other than `ExternalUser` and for paths where the provider
87    /// does not expose a sender id (e.g. webhook with no auth).
88    #[serde(skip_serializing_if = "Option::is_none", default)]
89    pub sender_id: Option<String>,
90
91    /// Provider-native message id (single canonical, no shortened
92    /// alias). Used for idempotency, dedupe, and as the
93    /// `reply_to_msg_id` target on subsequent turns. When a
94    /// debounce window collapses several inbound messages into a
95    /// single turn, this carries the id of the **first** message
96    /// in the batch.
97    #[serde(skip_serializing_if = "Option::is_none", default)]
98    pub msg_id: Option<String>,
99
100    /// Inbound timestamp in UTC. Wire shape: RFC3339 string via
101    /// chrono's serde adapter. Producers must clamp wildly-skewed
102    /// provider timestamps to `now()` before emitting.
103    #[serde(skip_serializing_if = "Option::is_none", default)]
104    pub inbound_ts: Option<DateTime<Utc>>,
105
106    /// Provider-native id of the message this one replies to.
107    /// `None` when the inbound is not a reply.
108    #[serde(skip_serializing_if = "Option::is_none", default)]
109    pub reply_to_msg_id: Option<String>,
110
111    /// `true` when the inbound carries non-text media
112    /// (image/audio/video/file/sticker). Microapps gate
113    /// media-aware tools on this; the actual bytes/URLs live in
114    /// `arguments`, not in `_meta`.
115    #[serde(default, skip_serializing_if = "is_false")]
116    pub has_media: bool,
117
118    /// Origin session id when `kind == InterSession` (delegation
119    /// receive). `None` otherwise.
120    #[serde(skip_serializing_if = "Option::is_none", default)]
121    pub origin_session_id: Option<Uuid>,
122}
123
124fn is_false(b: &bool) -> bool {
125    !b
126}
127
128impl InboundMessageMeta {
129    /// Build the minimum meta for an external user message
130    /// (channel plugin / webhook / MCP-channel inbound).
131    pub fn external_user(sender_id: impl Into<String>, msg_id: impl Into<String>) -> Self {
132        Self {
133            kind: InboundKind::ExternalUser,
134            sender_id: Some(sender_id.into()),
135            msg_id: Some(msg_id.into()),
136            inbound_ts: None,
137            reply_to_msg_id: None,
138            has_media: false,
139            origin_session_id: None,
140        }
141    }
142
143    /// Build the meta for a heartbeat / scheduler tick or a
144    /// yaml-declared `internal_system` event-subscriber binding.
145    pub fn internal_system() -> Self {
146        Self {
147            kind: InboundKind::InternalSystem,
148            sender_id: None,
149            msg_id: None,
150            inbound_ts: None,
151            reply_to_msg_id: None,
152            has_media: false,
153            origin_session_id: None,
154        }
155    }
156
157    /// Build the meta for an inter-agent delegation receive.
158    /// `origin_session_id` is required — `None` would be
159    /// indistinguishable from `internal_system`.
160    pub fn inter_session(origin_session_id: Uuid) -> Self {
161        Self {
162            kind: InboundKind::InterSession,
163            sender_id: None,
164            msg_id: None,
165            inbound_ts: None,
166            reply_to_msg_id: None,
167            has_media: false,
168            origin_session_id: Some(origin_session_id),
169        }
170    }
171
172    /// Builder: layer the inbound timestamp.
173    pub fn with_ts(mut self, ts: DateTime<Utc>) -> Self {
174        self.inbound_ts = Some(ts);
175        self
176    }
177
178    /// Builder: layer the reply-to message id.
179    pub fn with_reply_to(mut self, reply_to_msg_id: impl Into<String>) -> Self {
180        self.reply_to_msg_id = Some(reply_to_msg_id.into());
181        self
182    }
183
184    /// Builder: flag that the inbound carries non-text media.
185    pub fn with_media(mut self) -> Self {
186        self.has_media = true;
187        self
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194    use chrono::TimeZone;
195
196    #[test]
197    fn external_user_builder_sets_kind_and_sender_msg() {
198        let m = InboundMessageMeta::external_user("+5491100", "wa.ABCD");
199        assert_eq!(m.kind, InboundKind::ExternalUser);
200        assert_eq!(m.sender_id.as_deref(), Some("+5491100"));
201        assert_eq!(m.msg_id.as_deref(), Some("wa.ABCD"));
202        assert!(m.inbound_ts.is_none());
203        assert!(m.reply_to_msg_id.is_none());
204        assert!(!m.has_media);
205        assert!(m.origin_session_id.is_none());
206    }
207
208    #[test]
209    fn internal_system_builder_clears_sender_msg_origin() {
210        let m = InboundMessageMeta::internal_system();
211        assert_eq!(m.kind, InboundKind::InternalSystem);
212        assert!(m.sender_id.is_none());
213        assert!(m.msg_id.is_none());
214        assert!(m.origin_session_id.is_none());
215    }
216
217    #[test]
218    fn inter_session_builder_carries_origin_session_id() {
219        let id = Uuid::from_u128(0x42);
220        let m = InboundMessageMeta::inter_session(id);
221        assert_eq!(m.kind, InboundKind::InterSession);
222        assert!(m.sender_id.is_none());
223        assert_eq!(m.origin_session_id, Some(id));
224    }
225
226    #[test]
227    fn with_ts_with_reply_to_with_media_layer_correctly() {
228        let ts = Utc.with_ymd_and_hms(2026, 5, 1, 12, 34, 56).unwrap();
229        let m = InboundMessageMeta::external_user("+5491100", "wa.ABCD")
230            .with_ts(ts)
231            .with_reply_to("wa.PREV0001")
232            .with_media();
233        assert_eq!(m.inbound_ts, Some(ts));
234        assert_eq!(m.reply_to_msg_id.as_deref(), Some("wa.PREV0001"));
235        assert!(m.has_media);
236    }
237
238    #[test]
239    fn serialise_skips_none_and_false_fields() {
240        let m = InboundMessageMeta::internal_system();
241        let v = serde_json::to_value(&m).unwrap();
242        let obj = v.as_object().unwrap();
243        assert!(obj.contains_key("kind"));
244        assert!(!obj.contains_key("sender_id"));
245        assert!(!obj.contains_key("msg_id"));
246        assert!(!obj.contains_key("inbound_ts"));
247        assert!(!obj.contains_key("reply_to_msg_id"));
248        // has_media: false is omitted.
249        assert!(!obj.contains_key("has_media"));
250        assert!(!obj.contains_key("origin_session_id"));
251    }
252
253    #[test]
254    fn round_trip_through_serde_full_payload() {
255        let ts = Utc.with_ymd_and_hms(2026, 5, 1, 12, 34, 56).unwrap();
256        let original = InboundMessageMeta::external_user("user@host", "msg-1")
257            .with_ts(ts)
258            .with_reply_to("msg-0")
259            .with_media();
260        let s = serde_json::to_string(&original).unwrap();
261        let back: InboundMessageMeta = serde_json::from_str(&s).unwrap();
262        assert_eq!(original, back);
263    }
264
265    #[test]
266    fn parse_rejects_unknown_kind_string() {
267        // `kind` is a closed enum on the wire (no #[serde(other)]).
268        // A future producer emitting a new kind must wait for the
269        // consumer to bump the type. Strict reject is the safer
270        // default.
271        let raw = serde_json::json!({ "kind": "future_kind" });
272        let r: Result<InboundMessageMeta, _> = serde_json::from_value(raw);
273        assert!(r.is_err(), "unknown kind must reject, not silently accept");
274    }
275
276    #[test]
277    fn clone_eq_holds_for_full_payload() {
278        let a = InboundMessageMeta::external_user("+5491100", "wa.ABCD")
279            .with_ts(Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap())
280            .with_reply_to("wa.PREV")
281            .with_media();
282        let b = a.clone();
283        assert_eq!(a, b);
284    }
285
286    #[test]
287    fn provider_agnostic_sender_id_accepts_arbitrary_string() {
288        // Validates abstraction: shape is not whatsapp-only.
289        // Same struct happily carries telegram, email, slack ids.
290        let wa = InboundMessageMeta::external_user("+5491100", "wa.1");
291        let tg = InboundMessageMeta::external_user("tg.user_42", "tg.msg.7");
292        let em = InboundMessageMeta::external_user("alice@example.com", "<id@host>");
293        assert_eq!(wa.sender_id.as_deref(), Some("+5491100"));
294        assert_eq!(tg.sender_id.as_deref(), Some("tg.user_42"));
295        assert_eq!(em.sender_id.as_deref(), Some("alice@example.com"));
296    }
297}