use crate::{
constants::{
EVENT_INTENT_FAILURE, EVENT_POLICY_DENIED, EVENT_RECOGNIZER_LOOP_UTTERANCE, EVENT_SPEAK,
EVENT_UTTERANCE_HANDLED,
},
errors::{Result, ThalovantError},
events::{
context_with_correlation, event_matches_context, merge_context, new_request_id,
new_session_id, utterance_payload, Context, Data, Reply,
},
identity::Identity,
protocols::{HubProtocol, DEFAULT_PROTOCOL_PREFERENCE},
transport::{RuntimeTransport, TransportHealth},
};
use serde_json::{Map, Value};
use std::{path::Path, time::Duration};
use tokio::time::timeout;
#[derive(Clone)]
pub struct Client {
pub identity: Identity,
pub transport: RuntimeTransport,
}
#[derive(Clone, Debug, Default)]
pub struct RequestOptions {
pub timeout: Option<Duration>,
pub lang: Option<String>,
pub context: Option<Context>,
pub session_id: Option<String>,
pub request_id: Option<String>,
}
#[derive(Clone, Debug, Default)]
pub struct ActionOptions {
pub title: Option<String>,
pub lang: Option<String>,
pub context: Option<Context>,
pub session_id: Option<String>,
pub request_id: Option<String>,
}
#[derive(Clone, Debug, Default)]
pub struct CodeOptions {
pub kind: Option<String>,
pub label: Option<String>,
pub lang: Option<String>,
pub context: Option<Context>,
pub session_id: Option<String>,
pub request_id: Option<String>,
}
#[derive(Clone, Debug, Default)]
pub struct ConversationOptions {
pub session_id: Option<String>,
pub lang: Option<String>,
pub context: Option<Context>,
}
#[derive(Clone)]
pub struct Conversation {
client: Client,
pub session_id: String,
pub lang: String,
pub context: Context,
}
impl Client {
pub fn new(identity: Identity) -> Self {
Self {
transport: RuntimeTransport::Http(crate::transport::HttpTransport::new(
identity.clone(),
)),
identity,
}
}
pub fn with_protocol(identity: Identity, protocol: HubProtocol) -> Result<Self> {
let transport = RuntimeTransport::for_protocol(identity.clone(), protocol)?;
Ok(Self {
identity,
transport,
})
}
pub fn auto(identity: Identity) -> Result<Self> {
Self::with_protocol(identity.clone(), default_runtime_protocol(&identity)?)
}
pub fn from_file(path: impl AsRef<Path>) -> Result<Self> {
Self::auto(Identity::from_file(path)?)
}
pub fn from_config(profile: Option<&str>) -> Result<Self> {
Self::auto(Identity::from_config(profile)?)
}
pub fn from_config_file(path: impl AsRef<Path>, profile: Option<&str>) -> Result<Self> {
Self::auto(Identity::from_config_file(path, profile)?)
}
pub fn from_env() -> Result<Self> {
Self::auto(Identity::from_env()?)
}
pub async fn connect(&self) -> Result<()> {
if self.healthcheck().await.connected {
return Ok(());
}
self.transport.connect().await
}
pub async fn close(&self) -> Result<()> {
self.transport.disconnect().await
}
pub async fn healthcheck(&self) -> TransportHealth {
self.transport.healthcheck().await
}
pub async fn emit(&self, event_type: &str, data: Data, context: Context) -> Result<()> {
self.connect().await?;
self.transport
.emit_bus(
event_type,
data,
self.context_with_identity_metadata(context),
)
.await
}
fn context_with_identity_metadata(&self, context: Context) -> Context {
if self.identity.metadata.is_empty() {
return context;
}
let mut next = context;
let mut metadata = next
.get("metadata")
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
for (key, value) in &self.identity.metadata {
metadata.entry(key.clone()).or_insert_with(|| value.clone());
}
next.insert("metadata".to_string(), Value::Object(metadata));
next
}
pub async fn send_utterance(&self, text: &str, opts: RequestOptions) -> Result<()> {
let prompt = text.trim();
if prompt.is_empty() {
return Err(ThalovantError::Runtime(
"send_utterance requires non-empty text".to_string(),
));
}
let lang = opts.lang.as_deref().unwrap_or("en-us");
let request_id = opts.request_id.unwrap_or_else(new_request_id);
let context = context_with_correlation(
opts.context.as_ref(),
opts.session_id.as_deref(),
Some(&self.identity.site_id),
Some(lang),
Some(&request_id),
);
self.emit(
EVENT_RECOGNIZER_LOOP_UTTERANCE,
utterance_payload(prompt, lang),
context,
)
.await
}
pub async fn send_action(&self, payload: &str, opts: ActionOptions) -> Result<()> {
let prompt = payload.trim();
if prompt.is_empty() {
return Err(ThalovantError::Runtime(
"send_action requires non-empty payload".to_string(),
));
}
let mut input = Map::new();
input.insert("kind".to_string(), Value::String("action".to_string()));
if let Some(title) = opts.title {
input.insert("title".to_string(), Value::String(title));
}
input.insert("payload".to_string(), Value::String(prompt.to_string()));
let mut extra = Context::new();
extra.insert("input".to_string(), Value::Object(input));
self.send_utterance(
prompt,
RequestOptions {
lang: opts.lang,
context: Some(merge_context(opts.context.as_ref(), Some(&extra))),
session_id: opts.session_id,
request_id: opts.request_id,
timeout: None,
},
)
.await
}
pub async fn send_code(&self, value: &str, opts: CodeOptions) -> Result<()> {
let code = value.trim();
if code.is_empty() {
return Err(ThalovantError::Runtime(
"send_code requires non-empty value".to_string(),
));
}
let lang = opts.lang.as_deref().unwrap_or("en-us");
let request_id = opts.request_id.unwrap_or_else(new_request_id);
let mut input = Map::new();
input.insert(
"kind".to_string(),
Value::String(opts.kind.unwrap_or_else(|| "code".to_string())),
);
if let Some(label) = opts.label {
input.insert("label".to_string(), Value::String(label));
}
input.insert("value".to_string(), Value::String(code.to_string()));
input.insert("exact".to_string(), Value::Bool(true));
let mut extra = Context::new();
extra.insert("input".to_string(), Value::Object(input.clone()));
let context = context_with_correlation(
Some(&merge_context(opts.context.as_ref(), Some(&extra))),
opts.session_id.as_deref(),
Some(&self.identity.site_id),
Some(lang),
Some(&request_id),
);
let mut data = utterance_payload(code, lang);
data.insert("input".to_string(), Value::Object(input));
self.emit(EVENT_RECOGNIZER_LOOP_UTTERANCE, data, context)
.await
}
pub async fn ask(&self, text: &str, opts: RequestOptions) -> Result<Reply> {
let prompt = text.trim();
if prompt.is_empty() {
return Err(ThalovantError::Runtime(
"ask requires non-empty text".to_string(),
));
}
self.connect().await?;
let lang = opts.lang.as_deref().unwrap_or("en-us");
let timeout_duration = opts.timeout.unwrap_or(Duration::from_secs(12));
let request_id = opts.request_id.unwrap_or_else(new_request_id);
let context = context_with_correlation(
opts.context.as_ref(),
opts.session_id.as_deref(),
Some(&self.identity.site_id),
Some(lang),
Some(&request_id),
);
let mut receiver = self.transport.subscribe();
self.transport
.emit_bus(
EVENT_RECOGNIZER_LOOP_UTTERANCE,
utterance_payload(prompt, lang),
context.clone(),
)
.await?;
let mut events = Vec::new();
let mut fragments = Vec::new();
let mut failure_event = None;
timeout(timeout_duration, async {
loop {
let event = receiver
.recv()
.await
.map_err(|err| ThalovantError::Runtime(err.to_string()))?;
if !event_matches_context(&event, Some(&context)) {
continue;
}
events.push(event.clone());
match event.name.as_str() {
EVENT_SPEAK => {
let text = event.text();
if !text.is_empty() {
fragments.push(text);
}
}
EVENT_INTENT_FAILURE | EVENT_POLICY_DENIED => failure_event = Some(event),
EVENT_UTTERANCE_HANDLED => break,
_ => {}
}
}
Ok::<(), ThalovantError>(())
})
.await
.map_err(|_| ThalovantError::Timeout("utterance handling timed out".to_string()))??;
if failure_event.is_some() && fragments.is_empty() {
return Err(ThalovantError::Runtime(
failure_event
.as_ref()
.map(|event| event.name.clone())
.unwrap_or_default(),
));
}
Ok(Reply {
text: fragments.join(" "),
utterances: fragments,
handled: failure_event.is_none(),
ok: failure_event.is_none(),
session_id: context
.get("session")
.and_then(|value| value.get("session_id"))
.and_then(|value| value.as_str())
.map(str::to_string),
request_id: Some(request_id),
events,
failure_event,
})
}
pub fn conversation(&self, opts: ConversationOptions) -> Conversation {
Conversation {
client: self.clone(),
session_id: opts.session_id.unwrap_or_else(new_session_id),
lang: opts.lang.unwrap_or_else(|| "en-us".to_string()),
context: opts.context.unwrap_or_default(),
}
}
}
fn default_runtime_protocol(identity: &Identity) -> Result<HubProtocol> {
for protocol in DEFAULT_PROTOCOL_PREFERENCE {
match protocol {
HubProtocol::Wss => {
if identity.supports_protocol(HubProtocol::Wss)
&& identity.endpoint_for(HubProtocol::Wss).is_some()
{
return Ok(HubProtocol::Wss);
}
}
HubProtocol::Https => {
if identity.supports_protocol(HubProtocol::Https)
|| identity.endpoint_for(HubProtocol::Https).is_some()
{
return Ok(HubProtocol::Https);
}
}
HubProtocol::Mqtt => {
if identity.supports_protocol(HubProtocol::Mqtt) && identity.mqtt.is_some() {
return Ok(HubProtocol::Mqtt);
}
}
}
}
Err(ThalovantError::UnsupportedProtocol(
"identity does not include a usable WSS, HTTPS, or MQTT endpoint".to_string(),
))
}
impl Conversation {
pub async fn ask(&self, text: &str, mut opts: RequestOptions) -> Result<Reply> {
opts.session_id = Some(self.session_id.clone());
if opts.lang.is_none() {
opts.lang = Some(self.lang.clone());
}
opts.context = Some(merge_context(Some(&self.context), opts.context.as_ref()));
self.client.ask(text, opts).await
}
pub async fn send_utterance(&self, text: &str, mut opts: RequestOptions) -> Result<()> {
opts.session_id = Some(self.session_id.clone());
if opts.lang.is_none() {
opts.lang = Some(self.lang.clone());
}
opts.context = Some(merge_context(Some(&self.context), opts.context.as_ref()));
self.client.send_utterance(text, opts).await
}
pub async fn send_action(&self, payload: &str, mut opts: ActionOptions) -> Result<()> {
opts.session_id = Some(self.session_id.clone());
if opts.lang.is_none() {
opts.lang = Some(self.lang.clone());
}
opts.context = Some(merge_context(Some(&self.context), opts.context.as_ref()));
self.client.send_action(payload, opts).await
}
pub async fn send_code(&self, value: &str, mut opts: CodeOptions) -> Result<()> {
opts.session_id = Some(self.session_id.clone());
if opts.lang.is_none() {
opts.lang = Some(self.lang.clone());
}
opts.context = Some(merge_context(Some(&self.context), opts.context.as_ref()));
self.client.send_code(value, opts).await
}
}