use agentcat_api::models::PublishEventRequest;
use agentcat_api::models::publish_event_request::EventType;
use chrono::DateTime;
use serde_json::{Number, Value};
use crate::pipeline::EventObject;
use crate::wire;
#[cfg(test)]
mod tests;
pub(crate) fn publish_request(event: &EventObject) -> PublishEventRequest {
let text = |key: &str| event.get(key).and_then(Value::as_str).map(str::to_owned);
let json = |key: &str| event.get(key).map(wire::as_published);
let number = |key: &str| event.get(key).and_then(Value::as_number).map(saturate);
let identify_actor_given_id = text("identifyActorGivenId");
PublishEventRequest {
id: text("id"),
project_id: text("projectId").unwrap_or_default(),
session_id: Some(text("sessionId").filter(|session| !session.is_empty())),
actor_id: identify_actor_given_id.clone(),
event_id: None,
event_type: event
.get("eventType")
.and_then(Value::as_str)
.and_then(event_type),
is_error: event.get("isError").and_then(Value::as_bool),
error: json("error"),
resource_name: text("resourceName"),
duration: number("duration"),
input_tokens: number("inputTokens"),
output_tokens: number("outputTokens"),
timestamp: event
.get("timestamp")
.and_then(Value::as_str)
.and_then(|time| DateTime::parse_from_rfc3339(time).ok()),
user_intent: event.get("userIntent").and_then(user_intent),
parameters: json("parameters"),
response: json("response"),
identify_actor_given_id,
identify_actor_name: text("identifyActorName"),
identify_data: json("identifyActorData"),
tags: event.get("tags").and_then(Value::as_object).map(|tags| {
tags.iter()
.filter_map(|(key, tag)| Some((key.clone(), tag.as_str()?.to_owned())))
.collect()
}),
properties: event
.get("properties")
.filter(|properties| !properties.is_null())
.map(wire::as_published),
ip_address: None,
sdk_language: text("sdkLanguage"),
agentcat_version: text("agentcatVersion"),
server_name: text("serverName"),
server_version: text("serverVersion"),
client_name: text("clientName"),
client_version: text("clientVersion"),
}
}
fn event_type(text: &str) -> Option<EventType> {
match text {
"mcp:tools/call" => Some(EventType::McpColonToolsSlashCall),
"agentcat:custom" => Some(EventType::AgentcatColonCustom),
_ => None,
}
}
#[expect(
clippy::cast_possible_truncation,
reason = "a float-to-int `as` saturates, which is the intent"
)]
fn saturate(number: &Number) -> i32 {
if let Some(integer) = number.as_i64() {
let clamped = integer.clamp(i64::from(i32::MIN), i64::from(i32::MAX));
return i32::try_from(clamped).unwrap_or_default();
}
if number.is_u64() {
return i32::MAX;
}
number.as_f64().map_or(0, |float| float as i32)
}
fn user_intent(intent: &Value) -> Option<String> {
match intent {
Value::Null => None,
Value::String(text) => Some(text.clone()),
other => Some(wire::stringify(other)),
}
}