sync from monorepo @ 2452e92e
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
# Package: dirigent_matrix
|
||||
|
||||
Matrix integration for Dirigent session sharing.
|
||||
|
||||
## Quick Facts
|
||||
- **Type**: Library
|
||||
- **Main Entry**: src/lib.rs
|
||||
- **Dependencies**: matrix-sdk, dirigent_protocol, tokio, serde, thiserror, async-trait
|
||||
- **Status**: Phase 1 -- Bot-mode session sharing
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides bidirectional bridging between Dirigent sessions and Matrix rooms.
|
||||
A session can be "shared" to a Matrix room, allowing Matrix users to send
|
||||
messages to the agent and see responses in real-time.
|
||||
|
||||
## Architecture
|
||||
|
||||
### MatrixService (`service.rs`)
|
||||
Central singleton owning the matrix-sdk Client. Handles:
|
||||
- Bot authentication (login with username/password, session restore via SQLite store)
|
||||
- Background sync loop for receiving Matrix events
|
||||
- Share registry (tracks active session shares by connector_id + session_id)
|
||||
- Room message dispatch to appropriate shares
|
||||
|
||||
### MatrixSessionShare (`share.rs`)
|
||||
Bidirectional bridge for one (connector_id, session_id) to one Matrix room:
|
||||
- **Dirigent to Matrix**: Subscribes to connector events, forwards completed assistant messages as m.notice
|
||||
- **Matrix to Dirigent**: Receives room messages via MatrixService dispatch, sends ConnectorCommandProxy through mpsc channel
|
||||
- Implements the `SessionShare` trait from dirigent_protocol
|
||||
|
||||
### MatrixConfig (`config.rs`)
|
||||
Configuration parsed from `[matrix]` section in dirigent.toml:
|
||||
- Homeserver URL, username, password source (env var or inline)
|
||||
- Device ID for session persistence across restarts
|
||||
- Display name, default invite list, store path
|
||||
|
||||
### Room Management (`room.rs`)
|
||||
- Private, non-federated room creation for session shares
|
||||
- Room naming conventions (`"Dirigent: <title>"`)
|
||||
|
||||
## Configuration
|
||||
|
||||
Identity and credentials live in an Account; sharing behavior in `[matrix]`:
|
||||
|
||||
```toml
|
||||
[accounts.matrix-bot]
|
||||
type = "matrix"
|
||||
homeserver = "https://matrix.example.com"
|
||||
username = "dirigent_bot"
|
||||
device_id = "DIRIGENT_01"
|
||||
display_name = "Dirigent Bot"
|
||||
|
||||
[accounts.matrix-bot.credentials.password]
|
||||
source = "env"
|
||||
key = "DIRIGENT_MATRIX_PASSWORD"
|
||||
|
||||
[matrix]
|
||||
account = "matrix-bot"
|
||||
default_invite = ["@user:example.com"]
|
||||
store_path = "matrix/bot/store"
|
||||
```
|
||||
|
||||
## Key Types
|
||||
- `MatrixService` -- Singleton service, owns Client and share registry
|
||||
- `MatrixSessionShare` -- Bidirectional session-to-room bridge
|
||||
- `MatrixBehaviorConfig` -- Sharing behavior (account ref, invites, store path)
|
||||
- `ConnectorCommandProxy` -- Message proxy decoupling from dirigent_core types
|
||||
- `CreateRoomOptions` -- Room creation parameters
|
||||
|
||||
## Integration with CoreRuntime
|
||||
|
||||
The MatrixService is wired into CoreRuntime as an optional component (like archivist):
|
||||
- `CoreRuntime::start_matrix_service()` -- Resolves Account from config, creates and starts service
|
||||
- `CoreRuntime::create_matrix_share()` -- Creates room, starts bridge, spawns command proxy task
|
||||
- `CoreRuntime::matrix_service()` -- Accessor for the running service
|
||||
|
||||
## Event Flow
|
||||
|
||||
```
|
||||
Connector emits Event::MessageCompleted (role=assistant)
|
||||
-> MatrixSessionShare event forwarder task
|
||||
-> Sends m.notice to Matrix room
|
||||
|
||||
Matrix user sends message in room
|
||||
-> MatrixService sync loop receives SyncRoomMessageEvent
|
||||
-> Looks up share by room_id
|
||||
-> share.inject_message(text) -> ConnectorCommandProxy
|
||||
-> Proxy task translates to ConnectorCommand::SendMessage
|
||||
-> Connector processes message
|
||||
```
|
||||
|
||||
## Related Packages
|
||||
- **dirigent_protocol**: SessionShare trait, Event types consumed by share forwarder
|
||||
- **dirigent_core**: CoreRuntime integration, ConnectorCommand, ConnectorHandle
|
||||
- **dirigent_config**: Path resolution (DIRIGENT_DATA_DIR for SQLite store)
|
||||
@@ -0,0 +1,47 @@
|
||||
[package]
|
||||
name = "dirigent_matrix"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[features]
|
||||
default = ["bundled-sqlite"]
|
||||
bundled-sqlite = ["matrix-sdk/bundled-sqlite"]
|
||||
|
||||
[dependencies]
|
||||
# Matrix SDK
|
||||
matrix-sdk = { version = "0.9", default-features = false, features = ["rustls-tls", "sqlite"] }
|
||||
|
||||
# Internal dependencies
|
||||
dirigent_protocol = { path = "../dirigent_protocol" }
|
||||
dirigent_auth = { path = "../dirigent_auth" }
|
||||
|
||||
# Async runtime
|
||||
tokio = { version = "1.42", features = ["sync", "time", "macros", "rt"] }
|
||||
|
||||
# Markdown rendering
|
||||
pulldown-cmark = "0.12"
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
# Logging
|
||||
tracing = "0.1"
|
||||
|
||||
# Error handling
|
||||
thiserror = "2.0"
|
||||
|
||||
# Async traits
|
||||
async-trait = "0.1"
|
||||
|
||||
# UUID
|
||||
uuid = { version = "1.11", features = ["v7"] }
|
||||
|
||||
# Timestamps (for StreamSummary::active_since)
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1.42", features = ["full"] }
|
||||
@@ -0,0 +1,73 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// How Dirigent connects to Matrix.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum MatrixConnectionMode {
|
||||
/// Dedicated bot user with username/password login (existing behavior).
|
||||
#[default]
|
||||
Bot,
|
||||
/// Appservice-provisioned virtual user with stored access token.
|
||||
Provisioned,
|
||||
}
|
||||
|
||||
/// A persistent Matrix room defined in configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PersistentRoom {
|
||||
/// Human-readable label for this room (shown in room picker).
|
||||
pub label: String,
|
||||
/// Matrix room ID (e.g. "!abc:matrix.org").
|
||||
pub room_id: String,
|
||||
}
|
||||
|
||||
/// Matrix sharing behavior — separate from identity.
|
||||
///
|
||||
/// Identity and credentials come from an Account referenced by name.
|
||||
/// This struct only defines sharing behavior.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MatrixBehaviorConfig {
|
||||
/// Account name (key in [accounts.*]) for the Matrix connection.
|
||||
pub account: String,
|
||||
/// Connection mode: "bot" (default) or "provisioned".
|
||||
#[serde(default)]
|
||||
pub mode: MatrixConnectionMode,
|
||||
/// Matrix user IDs to invite to newly created share rooms.
|
||||
#[serde(default)]
|
||||
pub default_invite: Vec<String>,
|
||||
/// Directory for matrix-sdk SQLite store (relative to DIRIGENT_DATA_DIR).
|
||||
#[serde(default = "default_store_path")]
|
||||
pub store_path: String,
|
||||
/// Pre-defined rooms that always appear in the room selection UI.
|
||||
#[serde(default)]
|
||||
pub rooms: Vec<PersistentRoom>,
|
||||
}
|
||||
|
||||
fn default_store_path() -> String {
|
||||
"matrix/bot/store".to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_default_mode_is_bot() {
|
||||
let json = r#"{"account": "matrix-bot"}"#;
|
||||
let config: MatrixBehaviorConfig = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(config.mode, MatrixConnectionMode::Bot);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_provisioned_mode() {
|
||||
let json = r#"{"account": "matrix-virt", "mode": "provisioned"}"#;
|
||||
let config: MatrixBehaviorConfig = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(config.mode, MatrixConnectionMode::Provisioned);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bot_mode_explicit() {
|
||||
let json = r#"{"account": "matrix-bot", "mode": "bot"}"#;
|
||||
let config: MatrixBehaviorConfig = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(config.mode, MatrixConnectionMode::Bot);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum MatrixError {
|
||||
#[error("Matrix SDK error: {0}")]
|
||||
Sdk(#[from] matrix_sdk::Error),
|
||||
|
||||
#[error("Matrix HTTP error: {0}")]
|
||||
Http(#[from] matrix_sdk::HttpError),
|
||||
|
||||
#[error("Matrix client build error: {0}")]
|
||||
ClientBuild(#[from] matrix_sdk::ClientBuildError),
|
||||
|
||||
#[error("Not logged in")]
|
||||
NotLoggedIn,
|
||||
|
||||
#[error("Room not found: {0}")]
|
||||
RoomNotFound(String),
|
||||
|
||||
#[error("Share not found: connector={connector_id} session={session_id}")]
|
||||
ShareNotFound {
|
||||
connector_id: String,
|
||||
session_id: String,
|
||||
},
|
||||
|
||||
#[error("Share already exists: connector={connector_id} session={session_id}")]
|
||||
ShareAlreadyExists {
|
||||
connector_id: String,
|
||||
session_id: String,
|
||||
},
|
||||
|
||||
#[error("Configuration error: {0}")]
|
||||
Config(String),
|
||||
|
||||
#[error("Channel closed")]
|
||||
ChannelClosed,
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, MatrixError>;
|
||||
@@ -0,0 +1,17 @@
|
||||
//! Matrix integration for Dirigent session sharing
|
||||
//!
|
||||
//! This package provides bidirectional bridging between Dirigent sessions
|
||||
//! and Matrix rooms. A session can be "shared" to a Matrix room, allowing
|
||||
//! Matrix users to interact with the agent and see responses in real-time.
|
||||
|
||||
pub mod config;
|
||||
pub mod error;
|
||||
pub mod room;
|
||||
pub mod service;
|
||||
pub mod share;
|
||||
|
||||
pub use config::MatrixBehaviorConfig;
|
||||
pub use error::{MatrixError, Result};
|
||||
pub use room::CreateRoomOptions;
|
||||
pub use service::MatrixService;
|
||||
pub use share::{ConnectorCommandProxy, MatrixSessionShare};
|
||||
@@ -0,0 +1,81 @@
|
||||
//! Matrix room creation and management helpers.
|
||||
|
||||
use matrix_sdk::{
|
||||
ruma::{
|
||||
api::client::room::create_room::v3::{
|
||||
CreationContent, Request as CreateRoomRequest, RoomPreset,
|
||||
},
|
||||
OwnedRoomId, OwnedUserId,
|
||||
},
|
||||
Client,
|
||||
};
|
||||
use tracing::debug;
|
||||
|
||||
/// Options for creating a new Matrix room for session sharing.
|
||||
pub struct CreateRoomOptions {
|
||||
/// Human-readable room name.
|
||||
pub name: String,
|
||||
/// Optional room topic.
|
||||
pub topic: Option<String>,
|
||||
/// Matrix user IDs (as strings, e.g. "@user:example.com") to invite at
|
||||
/// creation time. Invalid IDs are silently skipped.
|
||||
pub invite: Vec<String>,
|
||||
}
|
||||
|
||||
/// Create a private, non-federated Matrix room for bridging a Dirigent session.
|
||||
///
|
||||
/// The room is configured as a `PrivateChat` (invite-only, shared history) and
|
||||
/// the `m.federate` flag is disabled so the room does not appear on remote
|
||||
/// homeservers.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`crate::MatrixError::NotLoggedIn`] if the client is not authenticated.
|
||||
/// Other errors propagate from the Matrix SDK.
|
||||
pub async fn create_share_room(
|
||||
client: &Client,
|
||||
options: CreateRoomOptions,
|
||||
) -> crate::Result<OwnedRoomId> {
|
||||
if !client.logged_in() {
|
||||
return Err(crate::MatrixError::NotLoggedIn);
|
||||
}
|
||||
|
||||
let invite: Vec<OwnedUserId> = options
|
||||
.invite
|
||||
.iter()
|
||||
.filter_map(|id| id.parse::<OwnedUserId>().ok())
|
||||
.collect();
|
||||
|
||||
let mut request = CreateRoomRequest::new();
|
||||
request.name = Some(options.name.clone());
|
||||
request.topic = options.topic.clone();
|
||||
request.invite = invite;
|
||||
request.preset = Some(RoomPreset::PrivateChat);
|
||||
|
||||
// Disable federation so the room stays on the local homeserver.
|
||||
let mut creation_content = CreationContent::new();
|
||||
creation_content.federate = false;
|
||||
request.creation_content =
|
||||
Some(matrix_sdk::ruma::serde::Raw::new(&creation_content).map_err(|e| {
|
||||
crate::MatrixError::Config(format!(
|
||||
"Failed to serialize creation content: {}",
|
||||
e
|
||||
))
|
||||
})?);
|
||||
|
||||
debug!(room_name = %options.name, "Creating Matrix share room");
|
||||
|
||||
let room = client.create_room(request).await?;
|
||||
Ok(room.room_id().to_owned())
|
||||
}
|
||||
|
||||
/// Generate a human-readable Matrix room name for a session.
|
||||
///
|
||||
/// Format: `"Dirigent: <session_title>"` or `"Dirigent: <connector_id>"` when
|
||||
/// no title is available.
|
||||
pub fn room_name_for_session(connector_id: &str, session_title: Option<&str>) -> String {
|
||||
match session_title.filter(|t| !t.is_empty()) {
|
||||
Some(title) => format!("Dirigent: {}", title),
|
||||
None => format!("Dirigent: {}", connector_id),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
path::PathBuf,
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use matrix_sdk::{
|
||||
config::SyncSettings,
|
||||
ruma::{
|
||||
events::room::message::{MessageType, SyncRoomMessageEvent},
|
||||
OwnedUserId,
|
||||
},
|
||||
Client, Room,
|
||||
};
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use crate::{config::MatrixBehaviorConfig, error::MatrixError, share::MatrixSessionShare, Result};
|
||||
|
||||
/// Key for the share registry: (connector_id, session_id)
|
||||
type ShareKey = (String, String);
|
||||
|
||||
/// Central Matrix service.
|
||||
///
|
||||
/// Owns the SDK [`Client`], handles login/session-restore, manages the sync
|
||||
/// loop, and maintains a registry of [`MatrixSessionShare`]s that bridge
|
||||
/// Dirigent sessions to Matrix rooms.
|
||||
pub struct MatrixService {
|
||||
account: dirigent_auth::Account,
|
||||
homeserver: String,
|
||||
username: String,
|
||||
display_name_str: String,
|
||||
device_id: String,
|
||||
behavior: MatrixBehaviorConfig,
|
||||
data_dir: PathBuf,
|
||||
client: Arc<RwLock<Option<Client>>>,
|
||||
shares: Arc<RwLock<HashMap<ShareKey, MatrixSessionShare>>>,
|
||||
}
|
||||
|
||||
impl MatrixService {
|
||||
/// Create a new (not yet logged-in) service from an Account and behavior config.
|
||||
pub fn from_account(
|
||||
account: &dirigent_auth::Account,
|
||||
behavior: MatrixBehaviorConfig,
|
||||
data_dir: PathBuf,
|
||||
) -> Result<Self> {
|
||||
let homeserver = account
|
||||
.property_str("homeserver")
|
||||
.ok_or_else(|| MatrixError::Config("Account missing 'homeserver' property".into()))?
|
||||
.to_string();
|
||||
let username = account
|
||||
.profile
|
||||
.username
|
||||
.clone()
|
||||
.ok_or_else(|| {
|
||||
MatrixError::Config("Account missing 'username' in profile".into())
|
||||
})?;
|
||||
let device_id = account
|
||||
.property_str_or("device_id", "DIRIGENT_01")
|
||||
.to_string();
|
||||
let display_name_str = account.display_name().to_string();
|
||||
|
||||
Ok(Self {
|
||||
account: account.clone(),
|
||||
homeserver,
|
||||
username,
|
||||
display_name_str,
|
||||
device_id,
|
||||
behavior,
|
||||
data_dir,
|
||||
client: Arc::new(RwLock::new(None)),
|
||||
shares: Arc::new(RwLock::new(HashMap::new())),
|
||||
})
|
||||
}
|
||||
|
||||
/// Return the current behavior configuration.
|
||||
pub fn behavior(&self) -> &MatrixBehaviorConfig {
|
||||
&self.behavior
|
||||
}
|
||||
|
||||
/// Return a clone of the inner [`Client`], if logged in.
|
||||
pub async fn client_cloned(&self) -> Option<Client> {
|
||||
self.client.read().await.clone()
|
||||
}
|
||||
|
||||
/// Resolve a Matrix [`Room`] handle from its string room id.
|
||||
///
|
||||
/// Returns:
|
||||
/// - `Ok(Some(room))` — client is logged in and knows the room
|
||||
/// - `Ok(None)` — client is logged in but the room isn't known (not
|
||||
/// joined / never invited / wrong id)
|
||||
/// - `Err(MatrixError::NotLoggedIn)` — no client yet
|
||||
/// - `Err(MatrixError::Config(..))` — `room_id` isn't a valid Matrix id
|
||||
///
|
||||
/// Exposed for consumers (e.g. `dirigent_core`'s `MatrixFactory`)
|
||||
/// that need to look up a pre-existing room without taking a
|
||||
/// `matrix_sdk` dependency of their own.
|
||||
pub async fn room_by_id(&self, room_id: &str) -> Result<Option<Room>> {
|
||||
let client = self
|
||||
.client_cloned()
|
||||
.await
|
||||
.ok_or(MatrixError::NotLoggedIn)?;
|
||||
|
||||
let parsed: matrix_sdk::ruma::OwnedRoomId = room_id
|
||||
.parse()
|
||||
.map_err(|e: matrix_sdk::ruma::IdParseError| {
|
||||
MatrixError::Config(format!("invalid room_id '{}': {}", room_id, e))
|
||||
})?;
|
||||
|
||||
Ok(client.get_room(&parsed))
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Authentication
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Build the SDK client (with SQLite store) and authenticate.
|
||||
///
|
||||
/// Attempts to restore a previously persisted session first. Falls back
|
||||
/// to a fresh username/password login when no session is found.
|
||||
pub async fn login(&self) -> Result<()> {
|
||||
let store_path = self.data_dir.join(&self.behavior.store_path);
|
||||
std::fs::create_dir_all(&store_path).map_err(|e| {
|
||||
MatrixError::Config(format!("Failed to create store directory: {}", e))
|
||||
})?;
|
||||
|
||||
let client = Client::builder()
|
||||
.homeserver_url(&self.homeserver)
|
||||
.sqlite_store(&store_path, None)
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
// Try to restore an existing session from the store.
|
||||
if client.logged_in() {
|
||||
info!(
|
||||
homeserver = %self.homeserver,
|
||||
username = %self.username,
|
||||
"Restored existing Matrix session"
|
||||
);
|
||||
*self.client.write().await = Some(client);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// No stored session — authenticate based on the configured mode.
|
||||
match self.behavior.mode {
|
||||
crate::config::MatrixConnectionMode::Bot => {
|
||||
// Existing flow: login with bot username/password.
|
||||
let password = self
|
||||
.account
|
||||
.resolve_credential("password")
|
||||
.map_err(|e| {
|
||||
MatrixError::Config(format!("Failed to resolve password: {}", e))
|
||||
})?;
|
||||
|
||||
info!(
|
||||
homeserver = %self.homeserver,
|
||||
username = %self.username,
|
||||
"Performing fresh Matrix login (bot mode)"
|
||||
);
|
||||
|
||||
client
|
||||
.matrix_auth()
|
||||
.login_username(&self.username, &password)
|
||||
.device_id(&self.device_id)
|
||||
.initial_device_display_name(&self.display_name_str)
|
||||
.send()
|
||||
.await?;
|
||||
}
|
||||
crate::config::MatrixConnectionMode::Provisioned => {
|
||||
// Restore session from a stored virtual-user access token.
|
||||
let token = self
|
||||
.account
|
||||
.resolve_credential("token")
|
||||
.map_err(|e| {
|
||||
MatrixError::Config(format!("Failed to resolve token: {}", e))
|
||||
})?;
|
||||
|
||||
let user_id_str = self
|
||||
.account
|
||||
.property_str("user_id")
|
||||
.ok_or_else(|| {
|
||||
MatrixError::Config(
|
||||
"Provisioned mode requires 'user_id' property on account".into(),
|
||||
)
|
||||
})?;
|
||||
|
||||
info!(
|
||||
homeserver = %self.homeserver,
|
||||
user_id = %user_id_str,
|
||||
"Restoring provisioned session with access token"
|
||||
);
|
||||
|
||||
use matrix_sdk::matrix_auth::MatrixSession;
|
||||
use matrix_sdk::ruma::{OwnedDeviceId, OwnedUserId};
|
||||
|
||||
let user_id: OwnedUserId = user_id_str.try_into().map_err(|_| {
|
||||
MatrixError::Config(format!("Invalid user_id: {}", user_id_str))
|
||||
})?;
|
||||
let device_id: OwnedDeviceId = self.device_id.clone().into();
|
||||
|
||||
let session = MatrixSession {
|
||||
meta: matrix_sdk::SessionMeta {
|
||||
user_id,
|
||||
device_id,
|
||||
},
|
||||
tokens: matrix_sdk::matrix_auth::MatrixSessionTokens {
|
||||
access_token: token,
|
||||
refresh_token: None,
|
||||
},
|
||||
};
|
||||
|
||||
client.matrix_auth().restore_session(session).await?;
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
homeserver = %self.homeserver,
|
||||
username = %self.username,
|
||||
"Matrix login successful"
|
||||
);
|
||||
|
||||
*self.client.write().await = Some(client);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Sync loop
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Start the background sync task.
|
||||
///
|
||||
/// Registers an event handler for incoming room messages and spawns a
|
||||
/// task that runs `client.sync()` indefinitely. Returns immediately
|
||||
/// after spawning.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`MatrixError::NotLoggedIn`] if `login()` was not called first.
|
||||
pub async fn start_sync(&self) -> Result<()> {
|
||||
let client = self
|
||||
.client
|
||||
.read()
|
||||
.await
|
||||
.clone()
|
||||
.ok_or(MatrixError::NotLoggedIn)?;
|
||||
|
||||
if !client.logged_in() {
|
||||
return Err(MatrixError::NotLoggedIn);
|
||||
}
|
||||
|
||||
let shares = Arc::clone(&self.shares);
|
||||
let bot_user_id: Option<OwnedUserId> = client.user_id().map(|u| u.to_owned());
|
||||
|
||||
// Register the room message event handler.
|
||||
client.add_event_handler({
|
||||
let shares = Arc::clone(&shares);
|
||||
let bot_user_id = bot_user_id.clone();
|
||||
move |ev: SyncRoomMessageEvent, room: Room| {
|
||||
let shares = Arc::clone(&shares);
|
||||
let bot_user_id = bot_user_id.clone();
|
||||
async move {
|
||||
on_room_message(ev, room, shares, bot_user_id).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Spawn the sync loop.
|
||||
tokio::spawn(async move {
|
||||
info!("Starting Matrix sync loop");
|
||||
if let Err(e) = client.sync(SyncSettings::default()).await {
|
||||
error!("Matrix sync loop exited with error: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Share registry
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Register a share, making it eligible to receive Matrix messages.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`MatrixError::ShareAlreadyExists`] if a share for the same
|
||||
/// `(connector_id, session_id)` pair is already registered.
|
||||
pub async fn register_share(&self, share: MatrixSessionShare) -> Result<()> {
|
||||
let key = (share.connector_id.clone(), share.session_id.clone());
|
||||
let mut map = self.shares.write().await;
|
||||
if map.contains_key(&key) {
|
||||
return Err(MatrixError::ShareAlreadyExists {
|
||||
connector_id: key.0,
|
||||
session_id: key.1,
|
||||
});
|
||||
}
|
||||
map.insert(key, share);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a share from the registry and shut it down.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`MatrixError::ShareNotFound`] if no matching share exists.
|
||||
pub async fn remove_share(&self, connector_id: &str, session_id: &str) -> Result<()> {
|
||||
let key = (connector_id.to_owned(), session_id.to_owned());
|
||||
let share = self
|
||||
.shares
|
||||
.write()
|
||||
.await
|
||||
.remove(&key)
|
||||
.ok_or_else(|| MatrixError::ShareNotFound {
|
||||
connector_id: connector_id.to_owned(),
|
||||
session_id: session_id.to_owned(),
|
||||
})?;
|
||||
share.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Return the number of currently registered shares.
|
||||
pub async fn share_count(&self) -> usize {
|
||||
self.shares.read().await.len()
|
||||
}
|
||||
|
||||
/// Return the room IDs and keys for all currently registered shares.
|
||||
///
|
||||
/// Returns a list of `(connector_id, session_id, room_id)` tuples.
|
||||
pub async fn list_shares(&self) -> Vec<(String, String, String)> {
|
||||
self.shares
|
||||
.read()
|
||||
.await
|
||||
.iter()
|
||||
.map(|((cid, sid), s)| (cid.clone(), sid.clone(), s.room_id.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Look up a share by connector and session ID.
|
||||
///
|
||||
/// Returns `None` when not found.
|
||||
pub async fn get_share(
|
||||
&self,
|
||||
connector_id: &str,
|
||||
session_id: &str,
|
||||
) -> Option<(String, bool)> {
|
||||
let key = (connector_id.to_owned(), session_id.to_owned());
|
||||
let map = self.shares.read().await;
|
||||
if let Some(share) = map.get(&key) {
|
||||
let room_id = share.room_id.clone();
|
||||
// is_active requires an await; we can't hold the read guard across
|
||||
// the await point. Drop the guard first.
|
||||
drop(map);
|
||||
let active = {
|
||||
// Re-acquire to call is_active (we still have shares Arc)
|
||||
let map = self.shares.read().await;
|
||||
if let Some(s) = map.get(&key) {
|
||||
s.is_active().await
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
Some((room_id, active))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Shut down all shares and signal the service to stop.
|
||||
pub async fn shutdown(&self) {
|
||||
let mut map = self.shares.write().await;
|
||||
for (_, share) in map.drain() {
|
||||
share.shutdown().await;
|
||||
}
|
||||
// Release the client so the sync loop can terminate naturally.
|
||||
*self.client.write().await = None;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event handler: Matrix → Dirigent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Called by the SDK for every incoming room message.
|
||||
///
|
||||
/// Looks up which registered share owns this room, then calls
|
||||
/// `share.inject_message(text)` so the text flows into the Dirigent session.
|
||||
/// Messages from the bot itself are skipped.
|
||||
async fn on_room_message(
|
||||
ev: SyncRoomMessageEvent,
|
||||
room: Room,
|
||||
shares: Arc<RwLock<HashMap<ShareKey, MatrixSessionShare>>>,
|
||||
bot_user_id: Option<OwnedUserId>,
|
||||
) {
|
||||
// We only care about original (non-redacted, non-edited) messages.
|
||||
let original = match ev.as_original() {
|
||||
Some(o) => o,
|
||||
None => return,
|
||||
};
|
||||
|
||||
// Skip bot's own messages.
|
||||
if let Some(bot_id) = &bot_user_id {
|
||||
if original.sender == *bot_id {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract plain-text body.
|
||||
let text = match &original.content.msgtype {
|
||||
MessageType::Text(t) => t.body.clone(),
|
||||
MessageType::Notice(_) => return, // ignore notices (including our own)
|
||||
_ => return,
|
||||
};
|
||||
|
||||
let room_id_str = room.room_id().as_str().to_owned();
|
||||
|
||||
// Find the share that owns this room.
|
||||
let map = shares.read().await;
|
||||
let matching = map
|
||||
.values()
|
||||
.find(|s| s.room_id == room_id_str);
|
||||
|
||||
if let Some(share) = matching {
|
||||
debug!(
|
||||
room_id = %room_id_str,
|
||||
connector_id = %share.connector_id,
|
||||
session_id = %share.session_id,
|
||||
"Injecting Matrix message into Dirigent session"
|
||||
);
|
||||
share.inject_message(&text).await;
|
||||
} else {
|
||||
warn!(
|
||||
room_id = %room_id_str,
|
||||
"Received message in unregistered room, ignoring"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,723 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use matrix_sdk::Room;
|
||||
use tokio::sync::{broadcast, mpsc, oneshot, Mutex, RwLock};
|
||||
use tracing::{debug, error, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use dirigent_protocol::accumulator::{AccumulatedMessage, AccumulatedPart, MessageAccumulator, ToolCallData};
|
||||
use dirigent_protocol::{ContentBlock, Event, MessageRole, SessionUpdate};
|
||||
|
||||
/// Command proxy sent from Matrix → Dirigent direction.
|
||||
///
|
||||
/// The caller who wires up the share is responsible for translating this
|
||||
/// into a real `ConnectorCommand::SendMessage` (or equivalent) using their
|
||||
/// own connector/session handle.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConnectorCommandProxy {
|
||||
pub session_id: String,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// A bidirectional bridge between a Dirigent session and a Matrix room.
|
||||
///
|
||||
/// **Dirigent → Matrix**: Subscribes to a broadcast event stream, forwards
|
||||
/// completed assistant messages and session errors into the Matrix room as
|
||||
/// `m.notice` messages.
|
||||
///
|
||||
/// **Matrix → Dirigent**: The `inject_message` method is called by
|
||||
/// `MatrixService` when a Matrix message arrives; it sends a
|
||||
/// `ConnectorCommandProxy` through an mpsc channel that the owner can read.
|
||||
pub struct MatrixSessionShare {
|
||||
/// Connector that owns the session.
|
||||
pub connector_id: String,
|
||||
/// Session being bridged (native connector session ID).
|
||||
pub session_id: String,
|
||||
/// Scroll ID for the archived session this share is scoped to.
|
||||
///
|
||||
/// Required by `SessionStream::scope()` to select `StreamScope::Session`.
|
||||
pub scroll_id: Uuid,
|
||||
/// Matrix room ID (as a string, e.g. "!abc:example.com").
|
||||
pub room_id: String,
|
||||
/// Room handle used by the stream `on_event` path when no legacy
|
||||
/// forwarder task is running. Behind an `Option` so `start()` — which
|
||||
/// consumes the `Room` when spawning the legacy forwarder — can leave
|
||||
/// it empty.
|
||||
room_for_stream: Option<Room>,
|
||||
/// Shared message accumulator so streaming chunks survive across
|
||||
/// multiple `on_event` calls (and the legacy forwarder task).
|
||||
accumulator: Arc<Mutex<MessageAccumulator>>,
|
||||
/// When this share was activated (for `StreamSummary::active_since`).
|
||||
active_since: DateTime<Utc>,
|
||||
|
||||
/// Sender side of the Matrix→Dirigent command channel.
|
||||
command_tx: mpsc::Sender<ConnectorCommandProxy>,
|
||||
/// Shutdown signal for the event-forwarder task.
|
||||
shutdown_tx: Arc<RwLock<Option<oneshot::Sender<()>>>>,
|
||||
/// Whether the forwarder task is still running.
|
||||
is_active: Arc<RwLock<bool>>,
|
||||
}
|
||||
|
||||
impl MatrixSessionShare {
|
||||
/// Construct and start a new `MatrixSessionShare`.
|
||||
///
|
||||
/// Spawns a background task that reads from `event_rx`, filters for
|
||||
/// events belonging to `(connector_id, session_id)`, and forwards
|
||||
/// relevant ones into the Matrix `room`.
|
||||
///
|
||||
/// Returns the share and the receiver end of the Matrix→Dirigent channel.
|
||||
pub fn start(
|
||||
connector_id: String,
|
||||
session_id: String,
|
||||
room_id: String,
|
||||
room: Room,
|
||||
event_rx: broadcast::Receiver<Event>,
|
||||
) -> (Self, mpsc::Receiver<ConnectorCommandProxy>) {
|
||||
// Legacy start() keeps ownership of the Room inside the forwarder
|
||||
// task; `scroll_id` isn't known by the legacy call path so we
|
||||
// default it to `Uuid::nil()`. The stream-path consumers use
|
||||
// `new_for_stream` instead and supply a real scroll_id.
|
||||
Self::start_with_scroll(
|
||||
connector_id,
|
||||
session_id,
|
||||
Uuid::nil(),
|
||||
room_id,
|
||||
room,
|
||||
event_rx,
|
||||
)
|
||||
}
|
||||
|
||||
/// Same as `start`, but lets callers attach the `scroll_id` of the
|
||||
/// archived session — required for `SessionStream::scope()` to be
|
||||
/// meaningful when the share is also driven as a stream.
|
||||
pub fn start_with_scroll(
|
||||
connector_id: String,
|
||||
session_id: String,
|
||||
scroll_id: Uuid,
|
||||
room_id: String,
|
||||
room: Room,
|
||||
event_rx: broadcast::Receiver<Event>,
|
||||
) -> (Self, mpsc::Receiver<ConnectorCommandProxy>) {
|
||||
let (command_tx, command_rx) = mpsc::channel(32);
|
||||
let (shutdown_tx, shutdown_rx) = oneshot::channel();
|
||||
let is_active = Arc::new(RwLock::new(true));
|
||||
let accumulator = Arc::new(Mutex::new(MessageAccumulator::new()));
|
||||
|
||||
let share = MatrixSessionShare {
|
||||
connector_id: connector_id.clone(),
|
||||
session_id: session_id.clone(),
|
||||
scroll_id,
|
||||
room_id: room_id.clone(),
|
||||
// Legacy path: the forwarder task owns the Room, so we don't
|
||||
// hold a second handle on the struct. `on_event` would double-
|
||||
// drive delivery if both were active.
|
||||
room_for_stream: None,
|
||||
accumulator: accumulator.clone(),
|
||||
active_since: Utc::now(),
|
||||
command_tx,
|
||||
shutdown_tx: Arc::new(RwLock::new(Some(shutdown_tx))),
|
||||
is_active: is_active.clone(),
|
||||
};
|
||||
|
||||
// Spawn the event-forwarder task
|
||||
tokio::spawn(run_event_forwarder(
|
||||
connector_id,
|
||||
session_id,
|
||||
room,
|
||||
event_rx,
|
||||
shutdown_rx,
|
||||
is_active,
|
||||
accumulator,
|
||||
));
|
||||
|
||||
(share, command_rx)
|
||||
}
|
||||
|
||||
/// Construct a share wired for the stream path (`SessionStream`) only.
|
||||
///
|
||||
/// No legacy event-forwarder task is spawned — the
|
||||
/// `StreamRegistry` worker will drive `on_event` instead. The Room
|
||||
/// handle is retained on the struct so `on_event` can deliver to it.
|
||||
///
|
||||
/// Returns the share plus the receiver end of the Matrix→Dirigent
|
||||
/// command channel (identical semantics to `start`).
|
||||
pub fn new_for_stream(
|
||||
connector_id: String,
|
||||
session_id: String,
|
||||
scroll_id: Uuid,
|
||||
room_id: String,
|
||||
room: Room,
|
||||
) -> (Self, mpsc::Receiver<ConnectorCommandProxy>) {
|
||||
let (command_tx, command_rx) = mpsc::channel(32);
|
||||
let (shutdown_tx, _shutdown_rx) = oneshot::channel();
|
||||
let is_active = Arc::new(RwLock::new(true));
|
||||
let accumulator = Arc::new(Mutex::new(MessageAccumulator::new()));
|
||||
|
||||
let share = MatrixSessionShare {
|
||||
connector_id,
|
||||
session_id,
|
||||
scroll_id,
|
||||
room_id,
|
||||
room_for_stream: Some(room),
|
||||
accumulator,
|
||||
active_since: Utc::now(),
|
||||
command_tx,
|
||||
shutdown_tx: Arc::new(RwLock::new(Some(shutdown_tx))),
|
||||
is_active,
|
||||
};
|
||||
|
||||
(share, command_rx)
|
||||
}
|
||||
|
||||
/// Inject a message received from Matrix into the Dirigent session.
|
||||
///
|
||||
/// Called by `MatrixService` when a room message arrives (filtered to
|
||||
/// skip the bot's own messages). Sends a `ConnectorCommandProxy` through
|
||||
/// the internal mpsc channel.
|
||||
pub async fn inject_message(&self, text: &str) {
|
||||
let proxy = ConnectorCommandProxy {
|
||||
session_id: self.session_id.clone(),
|
||||
text: text.to_owned(),
|
||||
};
|
||||
if let Err(e) = self.command_tx.send(proxy).await {
|
||||
warn!(
|
||||
connector_id = %self.connector_id,
|
||||
session_id = %self.session_id,
|
||||
"Failed to inject Matrix message into session (channel closed): {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Signal the event-forwarder task to stop and wait for it to finish.
|
||||
pub async fn shutdown(&self) {
|
||||
let tx = self.shutdown_tx.write().await.take();
|
||||
if let Some(tx) = tx {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
// Give the task a moment to notice the shutdown signal.
|
||||
// The is_active flag is set to false by the task itself when it exits.
|
||||
}
|
||||
|
||||
/// Whether the event-forwarder task is still running.
|
||||
pub async fn is_active(&self) -> bool {
|
||||
*self.is_active.read().await
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal event-forwarder task
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn run_event_forwarder(
|
||||
connector_id: String,
|
||||
session_id: String,
|
||||
room: Room,
|
||||
mut event_rx: broadcast::Receiver<Event>,
|
||||
shutdown_rx: oneshot::Receiver<()>,
|
||||
is_active: Arc<RwLock<bool>>,
|
||||
accumulator: Arc<Mutex<MessageAccumulator>>,
|
||||
) {
|
||||
// Fuse the shutdown signal so we can use it inside tokio::select!
|
||||
let mut shutdown_rx = shutdown_rx;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = &mut shutdown_rx => {
|
||||
debug!(
|
||||
connector_id = %connector_id,
|
||||
session_id = %session_id,
|
||||
"MatrixSessionShare forwarder received shutdown signal"
|
||||
);
|
||||
break;
|
||||
}
|
||||
result = event_rx.recv() => {
|
||||
match result {
|
||||
Err(broadcast::error::RecvError::Closed) => {
|
||||
debug!(
|
||||
connector_id = %connector_id,
|
||||
session_id = %session_id,
|
||||
"Event broadcast channel closed, stopping forwarder"
|
||||
);
|
||||
break;
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
warn!(
|
||||
connector_id = %connector_id,
|
||||
session_id = %session_id,
|
||||
"Event forwarder lagged by {} messages",
|
||||
n
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Ok(event) => {
|
||||
let mut acc = accumulator.lock().await;
|
||||
handle_event(
|
||||
&event,
|
||||
&connector_id,
|
||||
&session_id,
|
||||
&room,
|
||||
&mut *acc,
|
||||
).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
*is_active.write().await = false;
|
||||
}
|
||||
|
||||
/// Handle a single protocol event: forward relevant ones to the Matrix room.
|
||||
async fn handle_event(
|
||||
event: &Event,
|
||||
connector_id: &str,
|
||||
session_id: &str,
|
||||
room: &Room,
|
||||
accumulator: &mut MessageAccumulator,
|
||||
) {
|
||||
use matrix_sdk::ruma::events::room::message::RoomMessageEventContent;
|
||||
|
||||
match event {
|
||||
// -- Streaming accumulation: gather chunks into the accumulator --
|
||||
Event::SessionUpdate {
|
||||
connector_id: cid,
|
||||
session_id: sid,
|
||||
update,
|
||||
} if cid == connector_id && sid == session_id => {
|
||||
// Send typing indicator on first chunk of a new message
|
||||
let is_agent_chunk = matches!(
|
||||
update,
|
||||
SessionUpdate::AgentMessageChunk { .. }
|
||||
| SessionUpdate::AgentThoughtChunk { .. }
|
||||
| SessionUpdate::ToolCall { .. }
|
||||
);
|
||||
let msg_id = match update {
|
||||
SessionUpdate::AgentMessageChunk { message_id, .. }
|
||||
| SessionUpdate::AgentThoughtChunk { message_id, .. }
|
||||
| SessionUpdate::ToolCall { message_id, .. }
|
||||
| SessionUpdate::ToolCallUpdate { message_id, .. } => Some(message_id.as_str()),
|
||||
_ => None,
|
||||
};
|
||||
if is_agent_chunk {
|
||||
if let Some(mid) = msg_id {
|
||||
if !accumulator.has_buffer(mid) {
|
||||
// First chunk for this message — start typing indicator
|
||||
let _ = room.typing_notice(true).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match update {
|
||||
SessionUpdate::AgentMessageChunk {
|
||||
message_id,
|
||||
content,
|
||||
..
|
||||
} => {
|
||||
accumulator.add_chunk(message_id, session_id, &connector_id, "assistant", content.clone());
|
||||
}
|
||||
SessionUpdate::AgentThoughtChunk {
|
||||
message_id,
|
||||
content,
|
||||
..
|
||||
} => {
|
||||
if let ContentBlock::Text { text } = content {
|
||||
accumulator.add_thinking(message_id, session_id, &connector_id, text);
|
||||
}
|
||||
}
|
||||
SessionUpdate::ToolCall {
|
||||
message_id,
|
||||
tool_call,
|
||||
..
|
||||
} => {
|
||||
accumulator.add_or_update_tool_call(
|
||||
message_id,
|
||||
ToolCallData {
|
||||
id: tool_call.id.clone(),
|
||||
tool_name: tool_call.tool_name.clone(),
|
||||
input: tool_call.raw_input.clone().unwrap_or_default(),
|
||||
output: tool_call.raw_output.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
SessionUpdate::ToolCallUpdate {
|
||||
message_id,
|
||||
tool_call,
|
||||
..
|
||||
} => {
|
||||
accumulator.add_or_update_tool_call(
|
||||
message_id,
|
||||
ToolCallData {
|
||||
id: tool_call.id.clone(),
|
||||
tool_name: tool_call.tool_name.clone(),
|
||||
input: tool_call.raw_input.clone().unwrap_or_default(),
|
||||
output: tool_call.raw_output.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
_ => {} // UserMessageChunk, Unknown -- not forwarded
|
||||
}
|
||||
}
|
||||
|
||||
// -- Streaming finalization: send accumulated content on TurnComplete --
|
||||
Event::TurnComplete {
|
||||
connector_id: cid,
|
||||
session_id: sid,
|
||||
message_id,
|
||||
..
|
||||
} if cid == connector_id && sid == session_id => {
|
||||
if let Some(accumulated) = accumulator.finalize(message_id) {
|
||||
if accumulated.role == "assistant" && !accumulated.is_empty() {
|
||||
send_accumulated_to_matrix(&accumulated, room).await;
|
||||
}
|
||||
}
|
||||
// Stop typing indicator
|
||||
let _ = room.typing_notice(false).await;
|
||||
debug!(
|
||||
connector_id = %connector_id,
|
||||
session_id = %session_id,
|
||||
message_id = %message_id,
|
||||
"TurnComplete received for bridged session"
|
||||
);
|
||||
}
|
||||
|
||||
// -- Non-streaming fallback: send content from MessageCompleted --
|
||||
Event::MessageCompleted {
|
||||
connector_id: cid,
|
||||
message,
|
||||
} if cid == connector_id && message.session_id == session_id => {
|
||||
if message.role != MessageRole::Assistant {
|
||||
debug!(
|
||||
connector_id = %connector_id,
|
||||
session_id = %session_id,
|
||||
role = ?message.role,
|
||||
"Skipping non-assistant MessageCompleted"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-streaming path: content populated directly in MessageCompleted.
|
||||
// Skip if the accumulator already has data for this message (streaming
|
||||
// path handles delivery via TurnComplete instead).
|
||||
if !message.content.is_empty() && !accumulator.has_buffer(&message.id) {
|
||||
let accumulated = AccumulatedMessage::from_message_parts(
|
||||
message.id.clone(),
|
||||
message.session_id.clone(),
|
||||
connector_id.to_string(),
|
||||
"assistant".to_string(),
|
||||
&message.content,
|
||||
);
|
||||
send_accumulated_to_matrix(&accumulated, room).await;
|
||||
}
|
||||
}
|
||||
|
||||
Event::SessionError {
|
||||
connector_id: cid,
|
||||
session_id: sid,
|
||||
error_message,
|
||||
is_recoverable,
|
||||
..
|
||||
} if cid == connector_id && sid == session_id => {
|
||||
let notice = if *is_recoverable {
|
||||
format!("\u{26a0}\u{fe0f} Session warning: {}", error_message)
|
||||
} else {
|
||||
format!("\u{274c} Session error (unrecoverable): {}", error_message)
|
||||
};
|
||||
let content = RoomMessageEventContent::notice_plain(notice);
|
||||
if let Err(e) = room.send(content).await {
|
||||
error!(
|
||||
connector_id = %connector_id,
|
||||
session_id = %session_id,
|
||||
"Failed to send session error to Matrix room: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Events for *this* session but not handled above (expected, low noise)
|
||||
Event::SessionIdle { session_id: sid, .. }
|
||||
| Event::SessionMetadataUpdated {
|
||||
session_id: sid, ..
|
||||
}
|
||||
if sid == session_id =>
|
||||
{
|
||||
debug!(
|
||||
connector_id = %connector_id,
|
||||
session_id = %session_id,
|
||||
event = event_name(event),
|
||||
"Ignoring non-forwarded event for this session"
|
||||
);
|
||||
}
|
||||
|
||||
// Events for a *different* session/connector -- expected, just noise
|
||||
Event::MessageCompleted {
|
||||
connector_id: cid,
|
||||
message,
|
||||
} if cid != connector_id || message.session_id != session_id => {
|
||||
// Different session's message -- expected on a shared broadcast channel
|
||||
}
|
||||
Event::TurnComplete {
|
||||
connector_id: cid,
|
||||
session_id: sid,
|
||||
..
|
||||
} if cid != connector_id || sid != session_id => {
|
||||
// Different session -- expected
|
||||
}
|
||||
Event::SessionError {
|
||||
connector_id: cid,
|
||||
session_id: sid,
|
||||
..
|
||||
} if cid != connector_id || sid != session_id => {
|
||||
// Different session -- expected
|
||||
}
|
||||
Event::SessionUpdate {
|
||||
connector_id: cid,
|
||||
session_id: sid,
|
||||
..
|
||||
} if cid != connector_id || sid != session_id => {
|
||||
// Different session -- expected
|
||||
}
|
||||
|
||||
// Connector lifecycle, inspector, system events -- expected on broadcast
|
||||
Event::ConnectorCreated { .. }
|
||||
| Event::ConnectorRemoved { .. }
|
||||
| Event::ConnectorStateChanged { .. }
|
||||
| Event::Connected
|
||||
| Event::Disconnected
|
||||
| Event::InspectorSnapshot { .. }
|
||||
| Event::InspectorNodeRegistered { .. }
|
||||
| Event::InspectorNodeRemoved { .. }
|
||||
| Event::InspectorStateChanged { .. }
|
||||
| Event::InspectorPropertiesUpdated { .. }
|
||||
| Event::SystemTaskStatusChanged { .. }
|
||||
| Event::SessionsListed { .. }
|
||||
| Event::SessionCreated { .. }
|
||||
| Event::SessionUpdated { .. }
|
||||
| Event::SessionDeleted { .. }
|
||||
| Event::SessionClosed { .. }
|
||||
| Event::MessagesListed { .. }
|
||||
| Event::SessionSystemMessageSet { .. }
|
||||
| Event::SessionMetadataReceived { .. }
|
||||
| Event::SessionTransferred { .. }
|
||||
| Event::ForwardingPanic { .. }
|
||||
| Event::AgentRequest { .. }
|
||||
| Event::AcpClientConnected { .. }
|
||||
| Event::AcpClientDisconnected { .. }
|
||||
| Event::AcpClientSessionOpened { .. }
|
||||
| Event::AcpClientSessionRouted { .. }
|
||||
| Event::MessageStarted { .. }
|
||||
| Event::MessageFailed { .. }
|
||||
| Event::Error { .. }
|
||||
| Event::SessionRegistered { .. } => {
|
||||
// Expected broadcast traffic, not relevant to this share
|
||||
}
|
||||
|
||||
other => {
|
||||
warn!(
|
||||
connector_id = %connector_id,
|
||||
session_id = %session_id,
|
||||
event = event_name(other),
|
||||
"Unhandled event type in Matrix forwarder"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render markdown text to HTML for Matrix consumption.
|
||||
fn markdown_to_html(markdown: &str) -> String {
|
||||
use pulldown_cmark::{Options, Parser};
|
||||
|
||||
let mut options = Options::empty();
|
||||
options.insert(Options::ENABLE_STRIKETHROUGH);
|
||||
options.insert(Options::ENABLE_TABLES);
|
||||
|
||||
let parser = Parser::new_ext(markdown, options);
|
||||
let mut html = String::new();
|
||||
pulldown_cmark::html::push_html(&mut html, parser);
|
||||
html
|
||||
}
|
||||
|
||||
/// Send an accumulated message to a Matrix room, one message per content part.
|
||||
async fn send_accumulated_to_matrix(msg: &AccumulatedMessage, room: &Room) {
|
||||
use matrix_sdk::ruma::events::room::message::RoomMessageEventContent;
|
||||
|
||||
for part in &msg.parts {
|
||||
let content = match part {
|
||||
AccumulatedPart::Text { text } if !text.is_empty() => {
|
||||
let html = markdown_to_html(text);
|
||||
Some(RoomMessageEventContent::text_html(text.clone(), html))
|
||||
}
|
||||
AccumulatedPart::Thinking { text } if !text.is_empty() => {
|
||||
Some(RoomMessageEventContent::notice_plain(format!(
|
||||
"\u{1f4ad} {text}"
|
||||
)))
|
||||
}
|
||||
AccumulatedPart::Tool { data } => {
|
||||
let mut notice = format!("\u{1f527} Tool: {}", data.tool_name);
|
||||
if let Some(out) = &data.output {
|
||||
let out_str = if let Some(s) = out.as_str() {
|
||||
s.to_string()
|
||||
} else {
|
||||
serde_json::to_string_pretty(out).unwrap_or_default()
|
||||
};
|
||||
if !out_str.is_empty() {
|
||||
let truncated = if out_str.len() > 500 {
|
||||
format!("{}... (truncated)", &out_str[..500])
|
||||
} else {
|
||||
out_str
|
||||
};
|
||||
notice.push_str(&format!("\nOutput: {truncated}"));
|
||||
}
|
||||
}
|
||||
Some(RoomMessageEventContent::notice_plain(notice))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(content) = content {
|
||||
if let Err(e) = room.send(content).await {
|
||||
error!("Failed to send message part to Matrix room: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Return a human-readable name for an event variant (for logging).
|
||||
fn event_name(event: &Event) -> &'static str {
|
||||
match event {
|
||||
Event::SessionsListed { .. } => "SessionsListed",
|
||||
Event::SessionCreated { .. } => "SessionCreated",
|
||||
Event::SessionUpdated { .. } => "SessionUpdated",
|
||||
Event::SessionMetadataUpdated { .. } => "SessionMetadataUpdated",
|
||||
Event::SessionDeleted { .. } => "SessionDeleted",
|
||||
Event::SessionClosed { .. } => "SessionClosed",
|
||||
Event::SessionSystemMessageSet { .. } => "SessionSystemMessageSet",
|
||||
Event::SessionIdle { .. } => "SessionIdle",
|
||||
Event::SessionMetadataReceived { .. } => "SessionMetadataReceived",
|
||||
Event::TurnComplete { .. } => "TurnComplete",
|
||||
Event::SessionError { .. } => "SessionError",
|
||||
Event::SessionTransferred { .. } => "SessionTransferred",
|
||||
Event::ForwardingPanic { .. } => "ForwardingPanic",
|
||||
Event::SessionUpdate { .. } => "SessionUpdate",
|
||||
Event::AgentRequest { .. } => "AgentRequest",
|
||||
Event::AcpClientConnected { .. } => "AcpClientConnected",
|
||||
Event::AcpClientDisconnected { .. } => "AcpClientDisconnected",
|
||||
Event::AcpClientSessionOpened { .. } => "AcpClientSessionOpened",
|
||||
Event::AcpClientSessionRouted { .. } => "AcpClientSessionRouted",
|
||||
Event::MessagesListed { .. } => "MessagesListed",
|
||||
Event::MessageStarted { .. } => "MessageStarted",
|
||||
Event::MessageCompleted { .. } => "MessageCompleted",
|
||||
Event::MessageFailed { .. } => "MessageFailed",
|
||||
Event::ConnectorCreated { .. } => "ConnectorCreated",
|
||||
Event::ConnectorRemoved { .. } => "ConnectorRemoved",
|
||||
Event::ConnectorStateChanged { .. } => "ConnectorStateChanged",
|
||||
Event::Connected => "Connected",
|
||||
Event::Disconnected => "Disconnected",
|
||||
Event::Error { .. } => "Error",
|
||||
Event::InspectorSnapshot { .. } => "InspectorSnapshot",
|
||||
Event::InspectorNodeRegistered { .. } => "InspectorNodeRegistered",
|
||||
Event::InspectorNodeRemoved { .. } => "InspectorNodeRemoved",
|
||||
Event::InspectorStateChanged { .. } => "InspectorStateChanged",
|
||||
Event::InspectorPropertiesUpdated { .. } => "InspectorPropertiesUpdated",
|
||||
Event::SessionRegistered { .. } => "SessionRegistered",
|
||||
Event::SystemTaskStatusChanged { .. } => "SystemTaskStatusChanged",
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SessionShare trait implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl dirigent_protocol::sharing::SessionShare for MatrixSessionShare {
|
||||
fn summary(&self) -> dirigent_protocol::sharing::ShareSummary {
|
||||
dirigent_protocol::sharing::ShareSummary {
|
||||
id: format!("matrix:{}:{}", self.connector_id, self.session_id),
|
||||
connector_id: self.connector_id.clone(),
|
||||
session_id: self.session_id.clone(),
|
||||
backend: "matrix".to_string(),
|
||||
destination: self.room_id.clone(),
|
||||
active: self.is_active.try_read().map(|g| *g).unwrap_or(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_active(&self) -> bool {
|
||||
self.is_active.try_read().map(|g| *g).unwrap_or(false)
|
||||
}
|
||||
|
||||
async fn shutdown(&self) {
|
||||
// Delegate to the existing shutdown method (same implementation)
|
||||
let tx = self.shutdown_tx.write().await.take();
|
||||
if let Some(tx) = tx {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SessionStream trait implementation (Phase 4 migration, Task 18)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Dual-impl: `MatrixSessionShare` keeps its bi-directional `SessionShare`
|
||||
// impl (room management, `inject_message`) while also gaining a
|
||||
// uni-directional `SessionStream` impl so the `StreamRegistry` can drive it
|
||||
// via the central `SharingBus`.
|
||||
//
|
||||
// When driven as a stream, the caller (factory) is expected to construct
|
||||
// the share via `new_for_stream(..)` so a Room handle is stored on the
|
||||
// struct. `on_event` then translates bus events back to the legacy
|
||||
// `handle_event` dispatcher, preserving the accumulator state across
|
||||
// calls via the shared `Arc<Mutex<MessageAccumulator>>`.
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl dirigent_protocol::streaming::SessionStream for MatrixSessionShare {
|
||||
fn summary(&self) -> dirigent_protocol::streaming::StreamSummary {
|
||||
dirigent_protocol::streaming::StreamSummary {
|
||||
name: format!("{}:{}", self.connector_id, self.session_id),
|
||||
kind: dirigent_protocol::streaming::StreamKind::Matrix,
|
||||
target: format!("matrix:{}", self.room_id),
|
||||
active_since: self.active_since,
|
||||
}
|
||||
}
|
||||
|
||||
fn scope(&self) -> dirigent_protocol::streaming::StreamScope {
|
||||
dirigent_protocol::streaming::StreamScope::Session {
|
||||
scroll_id: self.scroll_id,
|
||||
}
|
||||
}
|
||||
|
||||
async fn on_event(
|
||||
&self,
|
||||
event: &dirigent_protocol::streaming::BusEvent,
|
||||
) -> dirigent_protocol::streaming::StreamOutcome {
|
||||
// If we were started via the legacy `start()` path, the Room
|
||||
// handle lives inside the forwarder task and this method has no
|
||||
// way to deliver. Treat that as a deliberate skip rather than a
|
||||
// transport failure.
|
||||
let room = match &self.room_for_stream {
|
||||
Some(r) => r,
|
||||
None => return dirigent_protocol::streaming::StreamOutcome::Skipped,
|
||||
};
|
||||
|
||||
let mut acc = self.accumulator.lock().await;
|
||||
handle_event(
|
||||
&*event.event,
|
||||
&self.connector_id,
|
||||
&self.session_id,
|
||||
room,
|
||||
&mut *acc,
|
||||
)
|
||||
.await;
|
||||
dirigent_protocol::streaming::StreamOutcome::Ok
|
||||
}
|
||||
|
||||
async fn shutdown(&self) {
|
||||
// Delegate to the bi-directional shutdown; both impls share the
|
||||
// same underlying oneshot + is_active signal.
|
||||
let tx = self.shutdown_tx.write().await.take();
|
||||
if let Some(tx) = tx {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
*self.is_active.write().await = false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user