feat(grid-agent): add chat and IM interactions (#123)
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
|
||||
use crate::policy::AuthorizedAction;
|
||||
use crate::types::{GridEvent, GridEventKind, ToolCallOutcome};
|
||||
#[cfg(feature = "live-grid")]
|
||||
use libremetaverse_types::UUID;
|
||||
use libremetaverse_types::compat::CancellationToken;
|
||||
use std::error::Error;
|
||||
use std::fmt;
|
||||
@@ -11,6 +13,8 @@ use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
#[cfg(feature = "live-grid")]
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
#[cfg(feature = "live-grid")]
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
pub type BackendFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
|
||||
@@ -88,6 +92,8 @@ impl OfflineGridBackend {
|
||||
#[cfg(feature = "live-grid")]
|
||||
pub struct LibremetaverseClientOwner {
|
||||
client: libremetaverse::GridClient,
|
||||
agent: Arc<libremetaverse::AgentManager>,
|
||||
delivery_generation: Arc<std::sync::RwLock<Option<u64>>>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "live-grid")]
|
||||
@@ -96,7 +102,7 @@ impl fmt::Debug for LibremetaverseClientOwner {
|
||||
formatter
|
||||
.debug_struct("LibremetaverseClientOwner")
|
||||
.field("client", &"[NATIVE CLIENT REDACTED]")
|
||||
.finish()
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,10 +112,13 @@ impl fmt::Debug for LibremetaverseClientOwner {
|
||||
#[derive(Clone)]
|
||||
pub struct LibremetaverseSessionBackend {
|
||||
network: libremetaverse::NetworkManager,
|
||||
agent: Arc<libremetaverse::AgentManager>,
|
||||
login_url: String,
|
||||
first_name: String,
|
||||
last_name: String,
|
||||
password: crate::config::SecretString,
|
||||
interaction: Option<crate::interaction::InteractionIngress>,
|
||||
delivery_generation: Arc<std::sync::RwLock<Option<u64>>>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "live-grid")]
|
||||
@@ -138,7 +147,18 @@ impl LibremetaverseClientOwner {
|
||||
.map_err(|_| BackendError::Configuration {
|
||||
component: "libremetaverse client defaults",
|
||||
})?;
|
||||
Ok(Self { client })
|
||||
let agent = Arc::new(
|
||||
libremetaverse::AgentManager::new(Some(Arc::new(client.clone()))).map_err(|_| {
|
||||
BackendError::Configuration {
|
||||
component: "native agent manager",
|
||||
}
|
||||
})?,
|
||||
);
|
||||
Ok(Self {
|
||||
client,
|
||||
agent,
|
||||
delivery_generation: Arc::new(std::sync::RwLock::new(None)),
|
||||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
@@ -147,9 +167,35 @@ impl LibremetaverseClientOwner {
|
||||
}
|
||||
|
||||
/// Creates the supervised live-session adapter without logging in.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Rejects a malformed avatar identity before any network operation.
|
||||
pub fn session_backend(
|
||||
&self,
|
||||
connection: crate::config::GridConnection,
|
||||
) -> Result<LibremetaverseSessionBackend, BackendError> {
|
||||
self.session_backend_inner(connection, None)
|
||||
}
|
||||
|
||||
/// Creates a supervised live session whose generation owns exactly one
|
||||
/// native chat and IM subscription.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Rejects a malformed avatar identity before any subscription or login.
|
||||
pub fn session_backend_with_interaction(
|
||||
&self,
|
||||
connection: crate::config::GridConnection,
|
||||
ingress: crate::interaction::InteractionIngress,
|
||||
) -> Result<LibremetaverseSessionBackend, BackendError> {
|
||||
self.session_backend_inner(connection, Some(ingress))
|
||||
}
|
||||
|
||||
fn session_backend_inner(
|
||||
&self,
|
||||
connection: crate::config::GridConnection,
|
||||
interaction: Option<crate::interaction::InteractionIngress>,
|
||||
) -> Result<LibremetaverseSessionBackend, BackendError> {
|
||||
let avatar = connection.avatar_name.trim();
|
||||
let (first_name, last_name) = avatar
|
||||
@@ -162,12 +208,78 @@ impl LibremetaverseClientOwner {
|
||||
}
|
||||
Ok(LibremetaverseSessionBackend {
|
||||
network: self.client.network(),
|
||||
agent: Arc::clone(&self.agent),
|
||||
login_url: connection.login_url.expose_url().to_owned(),
|
||||
first_name: first_name.to_owned(),
|
||||
last_name: last_name.to_owned(),
|
||||
password: connection.password,
|
||||
interaction,
|
||||
delivery_generation: Arc::clone(&self.delivery_generation),
|
||||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn interaction_sink(&self) -> LibremetaverseInteractionSink {
|
||||
LibremetaverseInteractionSink {
|
||||
agent: Arc::clone(&self.agent),
|
||||
delivery_generation: Arc::clone(&self.delivery_generation),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "live-grid")]
|
||||
#[derive(Clone)]
|
||||
pub struct LibremetaverseInteractionSink {
|
||||
agent: Arc<libremetaverse::AgentManager>,
|
||||
delivery_generation: Arc<std::sync::RwLock<Option<u64>>>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "live-grid")]
|
||||
impl fmt::Debug for LibremetaverseInteractionSink {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("LibremetaverseInteractionSink")
|
||||
.field("agent", &"[NATIVE AGENT MANAGER]")
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "live-grid")]
|
||||
impl crate::interaction::InteractionSink for LibremetaverseInteractionSink {
|
||||
fn deliver(
|
||||
&self,
|
||||
outbound: crate::interaction::OutboundInteraction,
|
||||
cancellation: CancellationToken,
|
||||
) -> crate::interaction::DeliveryFuture<'_> {
|
||||
let result = if cancellation.is_cancellation_requested() {
|
||||
Err(crate::interaction::InteractionDeliveryError::Disconnected)
|
||||
} else {
|
||||
let generation = self
|
||||
.delivery_generation
|
||||
.read()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if *generation == Some(outbound.generation) {
|
||||
match outbound.channel {
|
||||
crate::interaction::InteractionChannel::PublicChat => self.agent.chat(
|
||||
outbound.body.into_inner(),
|
||||
0,
|
||||
libremetaverse::ChatType::Normal,
|
||||
Some(false),
|
||||
),
|
||||
crate::interaction::InteractionChannel::DirectIm => {
|
||||
self.agent.instant_message_with_uuid_string(
|
||||
outbound.recipient_id,
|
||||
outbound.body.into_inner(),
|
||||
)
|
||||
}
|
||||
}
|
||||
.map_err(|_| crate::interaction::InteractionDeliveryError::Failed)
|
||||
} else {
|
||||
Err(crate::interaction::InteractionDeliveryError::Disconnected)
|
||||
}
|
||||
};
|
||||
Box::pin(async move { result })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "live-grid")]
|
||||
@@ -176,6 +288,8 @@ struct LibremetaverseSession {
|
||||
network: libremetaverse::NetworkManager,
|
||||
signals: mpsc::Receiver<crate::session::SessionSignal>,
|
||||
subscriptions: Vec<libremetaverse_types::compat::Subscription>,
|
||||
interaction: Option<crate::interaction::InteractionIngress>,
|
||||
delivery_generation: Arc<std::sync::RwLock<Option<u64>>>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "live-grid")]
|
||||
@@ -211,6 +325,13 @@ impl crate::session::GridSession for LibremetaverseSession {
|
||||
cancellation: CancellationToken,
|
||||
) -> crate::session::SessionFuture<'static, Result<(), crate::session::SessionFailure>> {
|
||||
Box::pin(async move {
|
||||
*self
|
||||
.delivery_generation
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) = None;
|
||||
if let Some(interaction) = &self.interaction {
|
||||
let _ = interaction.try_disconnected();
|
||||
}
|
||||
self.subscriptions.clear();
|
||||
self.network
|
||||
.native_logout_async(Some(cancellation))
|
||||
@@ -226,6 +347,7 @@ impl crate::session::GridSession for LibremetaverseSession {
|
||||
|
||||
#[cfg(feature = "live-grid")]
|
||||
impl crate::session::GridSessionBackend for LibremetaverseSessionBackend {
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn login(
|
||||
&self,
|
||||
generation: u64,
|
||||
@@ -235,6 +357,10 @@ impl crate::session::GridSessionBackend for LibremetaverseSessionBackend {
|
||||
Result<Box<dyn crate::session::GridSession>, crate::session::SessionFailure>,
|
||||
> {
|
||||
Box::pin(async move {
|
||||
*self
|
||||
.delivery_generation
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) = None;
|
||||
let mut params = self
|
||||
.network
|
||||
.native_default_login_params(
|
||||
@@ -250,7 +376,7 @@ impl crate::session::GridSessionBackend for LibremetaverseSessionBackend {
|
||||
)
|
||||
})?;
|
||||
params.uri.clone_from(&self.login_url);
|
||||
params.start = "last".to_owned();
|
||||
"last".clone_into(&mut params.start);
|
||||
let logged_in = self
|
||||
.network
|
||||
.native_login(params, Some(cancellation))
|
||||
@@ -272,11 +398,20 @@ impl crate::session::GridSessionBackend for LibremetaverseSessionBackend {
|
||||
// adds only one disconnect and one readiness subscription.
|
||||
let (sender, signals) = mpsc::channel(8);
|
||||
let disconnected_sender = sender.clone();
|
||||
let disconnected_interaction = self.interaction.clone();
|
||||
let disconnected_generation = Arc::clone(&self.delivery_generation);
|
||||
let disconnected = self
|
||||
.network
|
||||
.native_subscribe_disconnected(Arc::new(move |event| {
|
||||
*disconnected_generation
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) = None;
|
||||
if let Some(interaction) = &disconnected_interaction {
|
||||
let _ = interaction.try_disconnected();
|
||||
}
|
||||
let kind = match event.reason() {
|
||||
libremetaverse::NetworkManagerDisconnectType::NetworkTimeout => {
|
||||
libremetaverse::NetworkManagerDisconnectType::NetworkTimeout
|
||||
| libremetaverse::NetworkManagerDisconnectType::ClientInitiated => {
|
||||
crate::session::SessionFailureKind::TransientTransport
|
||||
}
|
||||
libremetaverse::NetworkManagerDisconnectType::ServerInitiated => {
|
||||
@@ -285,9 +420,6 @@ impl crate::session::GridSessionBackend for LibremetaverseSessionBackend {
|
||||
libremetaverse::NetworkManagerDisconnectType::SimShutdown => {
|
||||
crate::session::SessionFailureKind::Maintenance
|
||||
}
|
||||
libremetaverse::NetworkManagerDisconnectType::ClientInitiated => {
|
||||
crate::session::SessionFailureKind::TransientTransport
|
||||
}
|
||||
};
|
||||
let _ =
|
||||
disconnected_sender.try_send(crate::session::SessionSignal::Disconnected(
|
||||
@@ -295,15 +427,43 @@ impl crate::session::GridSessionBackend for LibremetaverseSessionBackend {
|
||||
));
|
||||
}));
|
||||
let ready_sender = sender.clone();
|
||||
let ready_interaction = self.interaction.clone();
|
||||
let ready_agent = Arc::clone(&self.agent);
|
||||
let ready_generation = Arc::clone(&self.delivery_generation);
|
||||
let ready_once = Arc::new(AtomicBool::new(false));
|
||||
let callback_ready = Arc::clone(&ready_once);
|
||||
let ready = self
|
||||
.network
|
||||
.native_subscribe_event_queue_running(Arc::new(move |_| {
|
||||
if !callback_ready.swap(true, Ordering::AcqRel) {
|
||||
*ready_generation
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(generation);
|
||||
if let Some(interaction) = &ready_interaction {
|
||||
let _ = interaction.try_connected(generation, ready_agent.agent_id());
|
||||
}
|
||||
let _ = ready_sender.try_send(crate::session::SessionSignal::Ready);
|
||||
}
|
||||
}));
|
||||
let mut subscriptions = vec![disconnected, ready];
|
||||
if let Some(interaction) = &self.interaction {
|
||||
let chat_ingress = interaction.clone();
|
||||
let chat_agent = Arc::clone(&self.agent);
|
||||
subscriptions.push(self.agent.subscribe_chat_from_simulator(Arc::new(
|
||||
move |event| {
|
||||
if let Some(inbound) = native_public_interaction(&chat_agent, &event) {
|
||||
let _ = chat_ingress.try_submit(inbound);
|
||||
}
|
||||
},
|
||||
)));
|
||||
let im_ingress = interaction.clone();
|
||||
let im_agent = Arc::clone(&self.agent);
|
||||
subscriptions.push(self.agent.subscribe_im(Arc::new(move |event| {
|
||||
if let Some(inbound) = native_im_interaction(&im_agent, &event) {
|
||||
let _ = im_ingress.try_submit(inbound);
|
||||
}
|
||||
})));
|
||||
}
|
||||
if !self.network.native_connected() {
|
||||
let _ = sender.try_send(crate::session::SessionSignal::Disconnected(
|
||||
crate::session::SessionFailure::new(
|
||||
@@ -315,16 +475,24 @@ impl crate::session::GridSessionBackend for LibremetaverseSessionBackend {
|
||||
.native_current_sim()
|
||||
.and_then(|simulator| simulator.native_is_event_queue_running(None).ok())
|
||||
.unwrap_or(false)
|
||||
&& !ready_once.swap(true, Ordering::AcqRel)
|
||||
{
|
||||
if !ready_once.swap(true, Ordering::AcqRel) {
|
||||
let _ = sender.try_send(crate::session::SessionSignal::Ready);
|
||||
*self
|
||||
.delivery_generation
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(generation);
|
||||
if let Some(interaction) = &self.interaction {
|
||||
let _ = interaction.try_connected(generation, self.agent.agent_id());
|
||||
}
|
||||
let _ = sender.try_send(crate::session::SessionSignal::Ready);
|
||||
}
|
||||
let session: Box<dyn crate::session::GridSession> = Box::new(LibremetaverseSession {
|
||||
generation,
|
||||
network: self.network.clone(),
|
||||
signals,
|
||||
subscriptions: vec![disconnected, ready],
|
||||
subscriptions,
|
||||
interaction: self.interaction.clone(),
|
||||
delivery_generation: Arc::clone(&self.delivery_generation),
|
||||
});
|
||||
Ok(session)
|
||||
})
|
||||
@@ -338,6 +506,134 @@ impl crate::session::GridSessionBackend for LibremetaverseSessionBackend {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "live-grid")]
|
||||
fn native_public_interaction(
|
||||
agent: &libremetaverse::AgentManager,
|
||||
event: &libremetaverse::ChatEventArgs,
|
||||
) -> Option<crate::interaction::InboundInteraction> {
|
||||
if event.audible_level() == libremetaverse::ChatAudibleLevel::Not
|
||||
|| !matches!(
|
||||
event.type_(),
|
||||
libremetaverse::ChatType::Normal
|
||||
| libremetaverse::ChatType::Whisper
|
||||
| libremetaverse::ChatType::Shout
|
||||
)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let source = match event.source_type() {
|
||||
libremetaverse::ChatSourceType::Agent => crate::interaction::InboundSource::Avatar,
|
||||
libremetaverse::ChatSourceType::Object => crate::interaction::InboundSource::Object,
|
||||
libremetaverse::ChatSourceType::System => crate::interaction::InboundSource::System,
|
||||
};
|
||||
let source_id = event.source_id();
|
||||
let owner_id = event.owner_id();
|
||||
let message = event.message();
|
||||
let name = event.from_name();
|
||||
let bucket = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or(Duration::ZERO)
|
||||
.as_secs()
|
||||
/ 5;
|
||||
let delivery_id = native_delivery_id(
|
||||
"chat",
|
||||
&[
|
||||
&source_id.to_string(),
|
||||
&owner_id.to_string(),
|
||||
&bucket.to_string(),
|
||||
&message,
|
||||
],
|
||||
);
|
||||
crate::interaction::InboundInteraction::new(
|
||||
delivery_id,
|
||||
source_id,
|
||||
name,
|
||||
crate::interaction::InteractionChannel::PublicChat,
|
||||
source,
|
||||
crate::interaction::ImDialogKind::OneToOneMessage,
|
||||
event.audible_level() == libremetaverse::ChatAudibleLevel::Fully,
|
||||
native_muted(agent, source_id, owner_id),
|
||||
message,
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
#[cfg(feature = "live-grid")]
|
||||
fn native_im_interaction(
|
||||
agent: &libremetaverse::AgentManager,
|
||||
event: &libremetaverse::InstantMessageEventArgs,
|
||||
) -> Option<crate::interaction::InboundInteraction> {
|
||||
let im = event.im();
|
||||
let dialog = match im.dialog {
|
||||
libremetaverse::InstantMessageDialog::MessageFromAgent if !im.group_im => {
|
||||
crate::interaction::ImDialogKind::OneToOneMessage
|
||||
}
|
||||
libremetaverse::InstantMessageDialog::StartTyping
|
||||
| libremetaverse::InstantMessageDialog::StopTyping => {
|
||||
crate::interaction::ImDialogKind::Typing
|
||||
}
|
||||
libremetaverse::InstantMessageDialog::SessionSend => {
|
||||
crate::interaction::ImDialogKind::GroupOrConference
|
||||
}
|
||||
_ => crate::interaction::ImDialogKind::Unsupported,
|
||||
};
|
||||
let timestamp = im
|
||||
.timestamp
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or(Duration::ZERO)
|
||||
.as_millis()
|
||||
.to_string();
|
||||
let delivery_id = native_delivery_id(
|
||||
"im",
|
||||
&[
|
||||
&im.from_agent_id.to_string(),
|
||||
&im.im_session_id.to_string(),
|
||||
×tamp,
|
||||
&im.message,
|
||||
],
|
||||
);
|
||||
crate::interaction::InboundInteraction::new(
|
||||
delivery_id,
|
||||
im.from_agent_id,
|
||||
im.from_agent_name,
|
||||
crate::interaction::InteractionChannel::DirectIm,
|
||||
crate::interaction::InboundSource::Avatar,
|
||||
dialog,
|
||||
false,
|
||||
native_muted(agent, im.from_agent_id, UUID::zero()),
|
||||
im.message,
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
#[cfg(feature = "live-grid")]
|
||||
fn native_muted(agent: &libremetaverse::AgentManager, source: UUID, owner: UUID) -> bool {
|
||||
agent
|
||||
.mute_list
|
||||
.read()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.values()
|
||||
.any(|entry| entry.id == source || (owner != UUID::zero() && entry.id == owner))
|
||||
}
|
||||
|
||||
#[cfg(feature = "live-grid")]
|
||||
fn native_delivery_id(prefix: &str, fields: &[&str]) -> String {
|
||||
use sha2::Digest as _;
|
||||
use std::fmt::Write as _;
|
||||
let mut digest = sha2::Sha256::new();
|
||||
for field in fields {
|
||||
digest.update(field.as_bytes());
|
||||
digest.update([0]);
|
||||
}
|
||||
let mut output = String::with_capacity(prefix.len() + 65);
|
||||
output.push_str(prefix);
|
||||
output.push('-');
|
||||
for byte in digest.finalize() {
|
||||
let _ = write!(output, "{byte:02x}");
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
#[cfg(feature = "live-grid")]
|
||||
fn classify_native_login_failure(error_key: &str) -> crate::session::SessionFailure {
|
||||
let normalized = error_key.trim().to_ascii_lowercase();
|
||||
|
||||
Reference in New Issue
Block a user