use std::sync::Arc;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::infra::ws_client::WsClientManager;
use crate::pipeline::model::{DslFlag, DslFlagKind, NodeFieldDef, NodeFieldType};
use crate::pipeline::{
NodeDefinition, PipelineError,
nodes::{NodeExecutionInput, NodeExecutionOutput, NodeHandler},
};
pub const NODE_KIND: &str = "n.ws.client.send";
const INPUT_PIN_IN: &str = "in";
const OUTPUT_PIN_OUT: &str = "out";
pub fn definition() -> NodeDefinition {
NodeDefinition {
kind: NODE_KIND.to_string(),
title: "WS Client Send".to_string(),
description: "Send a message through an active outbound WebSocket client connection. \
The connection must be owned by an n.trigger.ws.client node in an active pipeline. \
Use --connection to specify which trigger's connection to send through. \
Use --message-path to extract a specific field from the payload as the message body."
.to_string(),
input_schema: json!({ "type": "object" }),
output_schema: json!({ "type": "object" }),
input_pins: vec![INPUT_PIN_IN.to_string()],
output_pins: vec![OUTPUT_PIN_OUT.to_string()],
script_available: false,
script_bridge: None,
config_schema: json!({
"type": "object",
"required": ["connection"],
"properties": {
"connection": { "type": "string", "description": "Connection reference: 'pipeline/path:node_id' for cross-pipeline, or just node_id for same pipeline." },
"message_path": { "type": "string", "description": "JSON pointer into payload to extract the message. Empty = whole payload." }
}
}),
dsl_flags: vec![
DslFlag {
flag: "--connection".to_string(),
config_key: "connection".to_string(),
description: "Node ID of the WS client trigger owning the connection.".to_string(),
kind: DslFlagKind::Scalar,
required: true,
},
DslFlag {
flag: "--message-path".to_string(),
config_key: "message_path".to_string(),
description: "JSON pointer into payload to extract the message body.".to_string(),
kind: DslFlagKind::Scalar,
required: false,
},
],
fields: vec![
NodeFieldDef {
name: "connection".to_string(),
label: "Connection".to_string(),
field_type: NodeFieldType::Text,
help: Some("Node ID of the WS client trigger.".to_string()),
..Default::default()
},
NodeFieldDef {
name: "message_path".to_string(),
label: "Message Path".to_string(),
field_type: NodeFieldType::Text,
help: Some("JSON pointer into payload. Empty = whole payload.".to_string()),
..Default::default()
},
],
layout: vec![],
ai_tool: Default::default(),
..Default::default()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Config {
#[serde(default)]
pub connection: String,
#[serde(default)]
pub message_path: String,
}
pub struct Node {
config: Config,
ws_client_manager: Arc<WsClientManager>,
}
impl Node {
pub fn new(
config: Config,
ws_client_manager: Arc<WsClientManager>,
) -> Result<Self, PipelineError> {
Ok(Self {
config,
ws_client_manager,
})
}
}
#[async_trait]
impl NodeHandler for Node {
fn kind(&self) -> &'static str {
NODE_KIND
}
fn input_pins(&self) -> &'static [&'static str] {
&[INPUT_PIN_IN]
}
fn output_pins(&self) -> &'static [&'static str] {
&[OUTPUT_PIN_OUT]
}
async fn execute_async(
&self,
input: NodeExecutionInput,
) -> Result<NodeExecutionOutput, PipelineError> {
let owner = input
.metadata
.get("owner")
.and_then(Value::as_str)
.unwrap_or_default();
let project = input
.metadata
.get("project")
.and_then(Value::as_str)
.unwrap_or_default();
let message = if self.config.message_path.is_empty() {
input.payload.clone()
} else {
let ptr = if self.config.message_path.starts_with('/') {
self.config.message_path.clone()
} else {
format!("/{}", self.config.message_path)
};
input.payload.pointer(&ptr).cloned().unwrap_or(Value::Null)
};
let message_str = match &message {
Value::String(s) => s.clone(),
other => serde_json::to_string(other).unwrap_or_default(),
};
let connection_key = if self.config.connection.contains('/') {
format!("{}/{}/{}", owner, project, self.config.connection)
} else {
let pipeline_file = input
.payload
.get("__zf_pipeline_file")
.and_then(Value::as_str)
.unwrap_or_default();
if pipeline_file.is_empty() {
format!("{}/{}/:{}", owner, project, self.config.connection)
} else {
format!(
"{}/{}/{}:{}",
owner, project, pipeline_file, self.config.connection
)
}
};
match self
.ws_client_manager
.send(&connection_key, message_str)
.await
{
Ok(()) => Ok(NodeExecutionOutput {
output_pins: vec![OUTPUT_PIN_OUT.to_string()],
payload: input.payload,
trace: vec![format!(
"n.ws.client.send: connection={} sent",
self.config.connection
)],
}),
Err(err) => Err(PipelineError::new("FW_WS_CLIENT_SEND", err)),
}
}
}