use std::sync::Arc;
use std::time::{Duration, Instant};
use async_trait::async_trait;
use futures::{Stream, StreamExt};
use reqwest::{
Client, RequestBuilder, Response, StatusCode,
header::{HeaderMap, HeaderName, HeaderValue, RETRY_AFTER},
};
use serde_json::Value as JsonValue;
use tokio::select;
use tokio_util::sync::CancellationToken;
use tracing::warn;
use chat_engine_sdk::error::PluginError;
use chat_engine_sdk::models::{Capability, HealthStatus, StreamingEvent};
use chat_engine_sdk::plugin::{
ChatEngineBackendPlugin, MessagePluginCtx, PluginCallContext, PluginStream, SessionPluginCtx,
SessionPluginResponse, empty_stream, stream_from_events,
};
const CONFIG_KEY_ENDPOINT: &str = "endpoint";
const CONFIG_KEY_AUTH: &str = "auth";
const CONFIG_KEY_AUTH_VALUE: &str = "auth_value";
const CONFIG_KEY_DEFAULT_TIMEOUT_MS: &str = "default_timeout_ms";
pub const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
pub const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
pub struct WebhookCompatPlugin {
plugin_instance_id: String,
http: Client,
}
impl WebhookCompatPlugin {
pub fn new(plugin_instance_id: impl Into<String>) -> Result<Self, PluginError> {
let http = Client::builder()
.connect_timeout(DEFAULT_CONNECT_TIMEOUT)
.timeout(DEFAULT_REQUEST_TIMEOUT)
.build()
.map_err(|e| PluginError::internal_with("failed to build reqwest client", e))?;
Ok(Self {
plugin_instance_id: plugin_instance_id.into(),
http,
})
}
#[must_use]
pub fn with_client(plugin_instance_id: impl Into<String>, http: Client) -> Self {
Self {
plugin_instance_id: plugin_instance_id.into(),
http,
}
}
fn extract_config(call_ctx: &PluginCallContext) -> Result<WebhookConfig, PluginError> {
let cfg = call_ctx
.plugin_config
.as_ref()
.ok_or_else(|| PluginError::invalid_input("missing key: plugin_config"))?;
let raw_endpoint = cfg
.get(CONFIG_KEY_ENDPOINT)
.and_then(JsonValue::as_str)
.ok_or_else(|| {
PluginError::invalid_input(format!("missing key: {CONFIG_KEY_ENDPOINT}"))
})?;
crate::infra::url_guard::validate_outbound_url(raw_endpoint, CONFIG_KEY_ENDPOINT)?;
let endpoint = raw_endpoint.to_owned();
let auth_kind = cfg.get(CONFIG_KEY_AUTH).cloned();
let auth_value = cfg
.get(CONFIG_KEY_AUTH_VALUE)
.and_then(JsonValue::as_str)
.map(str::to_owned);
let default_timeout = cfg
.get(CONFIG_KEY_DEFAULT_TIMEOUT_MS)
.and_then(JsonValue::as_u64)
.map(Duration::from_millis);
Ok(WebhookConfig {
endpoint,
auth_kind,
auth_value,
default_timeout,
})
}
}
#[async_trait]
impl ChatEngineBackendPlugin for WebhookCompatPlugin {
async fn on_session_type_configured(
&self,
ctx: SessionPluginCtx,
) -> Result<SessionPluginResponse, PluginError> {
let body = serde_json::json!({
"event": "session_type_configured",
"session_type_id": ctx.session_type_id,
"session_id": ctx.session_id,
});
let cfg = Self::extract_config(&ctx.call_ctx)?;
let resp = post_json(
&self.http,
&cfg,
&ctx.call_ctx,
&body,
"session_type_configured",
)
.await?;
parse_session_response(resp).await
}
async fn on_session_created(
&self,
ctx: SessionPluginCtx,
) -> Result<SessionPluginResponse, PluginError> {
let body = serde_json::json!({
"event": "session_created",
"session_type_id": ctx.session_type_id,
"session_id": ctx.session_id,
});
let cfg = Self::extract_config(&ctx.call_ctx)?;
let resp = post_json(&self.http, &cfg, &ctx.call_ctx, &body, "session_created").await?;
parse_session_response(resp).await
}
async fn on_session_updated(
&self,
ctx: SessionPluginCtx,
) -> Result<SessionPluginResponse, PluginError> {
let body = serde_json::json!({
"event": "session_updated",
"session_type_id": ctx.session_type_id,
"session_id": ctx.session_id,
});
let cfg = Self::extract_config(&ctx.call_ctx)?;
let resp = post_json(&self.http, &cfg, &ctx.call_ctx, &body, "session_updated").await?;
parse_session_response(resp).await
}
async fn on_message(&self, ctx: MessagePluginCtx) -> Result<PluginStream, PluginError> {
let cfg = Self::extract_config(&ctx.call_ctx)?;
let body = serde_json::json!({
"event": "message",
"session_id": ctx.session_id,
"message_id": ctx.message_id,
"messages": ctx.messages,
});
run_streaming_request(
self.http.clone(),
cfg,
ctx.call_ctx.clone(),
body,
"message",
)
.await
}
async fn on_message_recreate(
&self,
ctx: MessagePluginCtx,
) -> Result<PluginStream, PluginError> {
let cfg = Self::extract_config(&ctx.call_ctx)?;
let body = serde_json::json!({
"event": "message_recreate",
"session_id": ctx.session_id,
"message_id": ctx.message_id,
"messages": ctx.messages,
});
run_streaming_request(
self.http.clone(),
cfg,
ctx.call_ctx.clone(),
body,
"message_recreate",
)
.await
}
async fn on_session_summary(&self, ctx: SessionPluginCtx) -> Result<PluginStream, PluginError> {
let cfg = Self::extract_config(&ctx.call_ctx)?;
let body = serde_json::json!({
"event": "session_summary",
"session_type_id": ctx.session_type_id,
"session_id": ctx.session_id,
});
run_streaming_request(
self.http.clone(),
cfg,
ctx.call_ctx.clone(),
body,
"session_summary",
)
.await
}
async fn health_check(&self) -> Result<HealthStatus, PluginError> {
Ok(HealthStatus::Healthy)
}
fn plugin_instance_id(&self) -> &str {
&self.plugin_instance_id
}
}
#[derive(Debug)]
struct WebhookConfig {
endpoint: String,
auth_kind: Option<JsonValue>,
auth_value: Option<String>,
default_timeout: Option<Duration>,
}
impl WebhookConfig {
fn resolved_timeout(&self, call_ctx: &PluginCallContext) -> Result<Duration, PluginError> {
match call_ctx.remaining() {
Some(d) if d.is_zero() => Err(PluginError::timeout_with(std::io::Error::new(
std::io::ErrorKind::TimedOut,
"deadline elapsed before request",
))),
Some(d) => Ok(d),
None => Ok(self.default_timeout.unwrap_or(DEFAULT_REQUEST_TIMEOUT)),
}
}
fn auth_headers(&self) -> Result<HeaderMap, PluginError> {
let mut headers = HeaderMap::new();
let Some(kind) = &self.auth_kind else {
return Ok(headers);
};
if kind.get("bearer").is_some() {
let value = self.auth_value.as_deref().ok_or_else(|| {
PluginError::invalid_input(format!("missing key: {CONFIG_KEY_AUTH_VALUE}"))
})?;
let header_val = HeaderValue::from_str(&format!("Bearer {value}"))
.map_err(|_| PluginError::invalid_input("invalid header bytes in auth_value"))?;
headers.insert(reqwest::header::AUTHORIZATION, header_val);
return Ok(headers);
}
if let Some(name) = kind.get("api_key_header").and_then(JsonValue::as_str) {
let value = self.auth_value.as_deref().ok_or_else(|| {
PluginError::invalid_input(format!("missing key: {CONFIG_KEY_AUTH_VALUE}"))
})?;
let header_name = HeaderName::from_bytes(name.as_bytes())
.map_err(|e| PluginError::invalid_input_with("invalid header name in auth", e))?;
let header_val = HeaderValue::from_str(value)
.map_err(|_| PluginError::invalid_input("invalid header bytes in auth_value"))?;
headers.insert(header_name, header_val);
return Ok(headers);
}
Err(PluginError::invalid_input(
"unsupported auth kind (expected `bearer` or `api_key_header`)",
))
}
}
fn build_request(
http: &Client,
cfg: &WebhookConfig,
call_ctx: &PluginCallContext,
body: &JsonValue,
) -> Result<RequestBuilder, PluginError> {
let timeout = cfg.resolved_timeout(call_ctx)?;
let req = http
.post(&cfg.endpoint)
.headers(cfg.auth_headers()?)
.json(body)
.timeout(timeout);
Ok(req)
}
async fn post_json(
http: &Client,
cfg: &WebhookConfig,
call_ctx: &PluginCallContext,
body: &JsonValue,
method_label: &'static str,
) -> Result<Response, PluginError> {
if call_ctx.is_cancelled() {
return Err(PluginError::transient("cancelled"));
}
let started = Instant::now();
let req = build_request(http, cfg, call_ctx, body)?;
let cancel = call_ctx.cancel.clone();
let request_id = call_ctx.request_id;
let resp = select! {
biased;
_ = cancel.cancelled() => {
return Err(PluginError::transient("cancelled"));
}
r = req.send() => r.map_err(map_reqwest_error)?,
};
let status = resp.status();
if !status.is_success() {
let retry_after = parse_retry_after(resp.headers());
let elapsed_ms = started.elapsed().as_millis() as u64;
warn!(
request_id = %request_id,
method = method_label,
status = status.as_u16(),
elapsed_ms,
"webhook-compat received non-success status"
);
return Err(map_status_to_error(status, retry_after));
}
Ok(resp)
}
async fn run_streaming_request(
http: Client,
cfg: WebhookConfig,
call_ctx: PluginCallContext,
body: JsonValue,
method_label: &'static str,
) -> Result<PluginStream, PluginError> {
let cancel = call_ctx.cancel.clone();
let request_id = call_ctx.request_id;
if call_ctx.is_cancelled() {
return Err(PluginError::transient("cancelled"));
}
let req = build_request(&http, &cfg, &call_ctx, &body)?;
let started = Instant::now();
let resp = select! {
biased;
_ = cancel.cancelled() => {
return Err(PluginError::transient("cancelled"));
}
r = req.send() => r.map_err(map_reqwest_error)?,
};
let status = resp.status();
if !status.is_success() {
let retry_after = parse_retry_after(resp.headers());
let elapsed_ms = started.elapsed().as_millis() as u64;
warn!(
request_id = %request_id,
method = method_label,
status = status.as_u16(),
elapsed_ms,
"webhook-compat streaming setup received non-success status"
);
return Err(map_status_to_error(status, retry_after));
}
let byte_stream = resp.bytes_stream();
Ok(Box::pin(ndjson_event_stream(byte_stream, cancel)))
}
fn ndjson_event_stream<S>(
bytes: S,
cancel: CancellationToken,
) -> impl Stream<Item = Result<StreamingEvent, PluginError>> + Send + 'static
where
S: Stream<Item = reqwest::Result<bytes::Bytes>> + Send + 'static,
{
use futures::stream::unfold;
struct State<S> {
inner: S,
buf: Vec<u8>,
cancel: CancellationToken,
done: bool,
}
let state = State {
inner: Box::pin(bytes),
buf: Vec::with_capacity(1024),
cancel,
done: false,
};
unfold(state, |mut s| async move {
if s.done {
return None;
}
loop {
if let Some(pos) = s.buf.iter().position(|b| *b == b'\n') {
let line: Vec<u8> = s.buf.drain(..=pos).collect();
let trimmed = trim_newline(&line);
if trimmed.is_empty() {
continue;
}
match serde_json::from_slice::<StreamingEvent>(trimmed) {
Ok(evt) => return Some((Ok(evt), s)),
Err(e) => {
s.done = true;
return Some((
Err(PluginError::internal_with("malformed NDJSON event", e)),
s,
));
}
}
}
let next = select! {
biased;
_ = s.cancel.cancelled() => {
s.done = true;
return Some((Err(PluginError::transient("cancelled")), s));
}
r = s.inner.next() => r,
};
match next {
Some(Ok(chunk)) => {
s.buf.extend_from_slice(&chunk);
}
Some(Err(e)) => {
s.done = true;
return Some((Err(map_reqwest_error(e)), s));
}
None => {
s.done = true;
let trimmed = trim_newline(&s.buf);
if trimmed.is_empty() {
return None;
}
match serde_json::from_slice::<StreamingEvent>(trimmed) {
Ok(evt) => {
s.buf.clear();
return Some((Ok(evt), s));
}
Err(e) => {
return Some((
Err(PluginError::internal_with("malformed NDJSON event", e)),
s,
));
}
}
}
}
}
})
}
fn trim_newline(line: &[u8]) -> &[u8] {
let mut end = line.len();
while end > 0 && (line[end - 1] == b'\n' || line[end - 1] == b'\r') {
end -= 1;
}
&line[..end]
}
async fn parse_session_response(resp: Response) -> Result<SessionPluginResponse, PluginError> {
let bytes = resp
.bytes()
.await
.map_err(|e| PluginError::transient_with("failed to read response body", e))?;
if bytes.is_empty() {
return Ok(SessionPluginResponse::default());
}
let value: serde_json::Value = serde_json::from_slice(&bytes)
.map_err(|e| PluginError::internal_with("malformed session response", e))?;
match value {
serde_json::Value::Array(_) => {
let capabilities = serde_json::from_value::<Vec<Capability>>(value)
.map_err(|e| PluginError::internal_with("malformed capability payload", e))?;
Ok(SessionPluginResponse::from(capabilities))
}
serde_json::Value::Object(mut map) => {
let capabilities = match map.remove("capabilities") {
Some(c) => serde_json::from_value::<Vec<Capability>>(c)
.map_err(|e| PluginError::internal_with("malformed capability payload", e))?,
None => Vec::new(),
};
let metadata = map.remove("metadata").filter(|v| !v.is_null());
Ok(SessionPluginResponse {
capabilities,
metadata,
})
}
_ => Err(PluginError::internal(
"session response must be a capability array or an object",
)),
}
}
fn map_status_to_error(status: StatusCode, retry_after: Option<Duration>) -> PluginError {
match status.as_u16() {
429 => PluginError::rate_limited(retry_after),
401 | 403 => PluginError::unauthorized(format!("upstream returned {status}")),
404 => PluginError::not_found(format!("upstream returned {status}")),
400 | 422 => PluginError::invalid_input(format!("upstream returned {status}")),
s if (500..=599).contains(&s) => {
PluginError::transient(format!("upstream returned {status}"))
}
_ => PluginError::internal(format!("unexpected upstream status {status}")),
}
}
fn map_reqwest_error(err: reqwest::Error) -> PluginError {
if err.is_timeout() {
return PluginError::timeout_with(err);
}
if err.is_connect() || err.is_request() {
return PluginError::transient_with("network error", err);
}
if let Some(status) = err.status() {
return map_status_to_error(status, None);
}
PluginError::internal_with("reqwest error", err)
}
fn parse_retry_after(headers: &HeaderMap) -> Option<Duration> {
headers
.get(RETRY_AFTER)
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok())
.map(Duration::from_secs)
}
#[allow(dead_code)]
fn _doc_arc_self_pattern(me: Arc<WebhookCompatPlugin>) -> PluginStream {
let _id = me.plugin_instance_id().to_owned();
stream_from_events(Vec::new())
}
#[allow(dead_code)]
fn _doc_empty() -> PluginStream {
empty_stream()
}
#[cfg(test)]
#[path = "webhook_compat_tests.rs"]
mod webhook_compat_tests;