mod enrichment;
pub mod gui;
pub mod telegram;
pub use enrichment::{EnrichmentStrategy, enrich_links, enrich_message};
pub use telegram::mirror_gui_message_to_telegram;
use crate::chat_history::ChatHistoryInsert;
use crate::turso;
use crate::{ChannelMessage, ChatDirection, SendMessage};
use tokio_util::sync::CancellationToken;
const CHANNEL_TYPING_REFRESH_INTERVAL_SECS: u64 = 4;
#[derive(Debug, Clone)]
struct BroadcastPersistEntry {
user_name: String,
channel: String,
content: String,
direction: ChatDirection,
agent_role: Option<String>,
workspace: String,
optimistic_id: Option<String>,
}
impl BroadcastPersistEntry {
async fn broadcast_and_persist(self) {
use crate::ChatEvent;
debug_assert!(
self.direction != ChatDirection::Agent || self.agent_role.is_some(),
"BroadcastPersistEntry: direction=Agent but agent_role is None"
);
let message_id = crate::generate_id();
let timestamp = turso::now();
let (db_role, db_direction) = match self.direction {
ChatDirection::Agent => (
self.agent_role.as_deref().unwrap_or("").to_string(),
"agent".to_string(),
),
ChatDirection::User => ("user".to_string(), "user".to_string()),
};
if let Some(tx) = crate::CHAT_BROADCAST.get() {
let _ = tx.send(ChatEvent::Message {
message_id: message_id.clone(),
user_name: self.user_name.clone(),
content: self.content.clone(),
direction: self.direction,
timestamp: timestamp.clone(),
agent_role: self.agent_role.clone(),
workspace: self.workspace.clone(),
optimistic_id: self.optimistic_id,
});
}
let store = crate::chat_history::store();
let _ = store
.insert(&ChatHistoryInsert {
message_id,
user_name: self.user_name,
channel: self.channel,
role: db_role,
direction: db_direction,
content: self.content,
agent_role: self.agent_role,
workspace: self.workspace,
created_at: timestamp,
})
.await;
}
}
pub async fn broadcast_and_persist_agent_response(
user_name: &str,
channel: &str,
content: &str,
agent_role: Option<String>,
workspace: &str,
) {
BroadcastPersistEntry {
user_name: user_name.to_string(),
channel: channel.to_string(),
content: content.to_string(),
direction: ChatDirection::Agent,
agent_role,
workspace: workspace.to_string(),
optimistic_id: None, }
.broadcast_and_persist()
.await;
}
pub async fn write_incoming_to_broadcast(msg: &ChannelMessage) {
BroadcastPersistEntry {
user_name: msg.user_name.clone(),
channel: msg.channel.clone(),
content: msg.content.clone(),
direction: ChatDirection::User,
agent_role: None, workspace: msg.workspace.clone(),
optimistic_id: msg.optimistic_id.clone(), }
.broadcast_and_persist()
.await;
}
pub async fn send_channel_reply(content: String, msg: &ChannelMessage, agent_role: Option<String>) {
broadcast_and_persist_agent_response(
&msg.user_name,
&msg.channel,
&content,
agent_role,
&msg.workspace,
)
.await;
let Some(channel) = crate::channel_registry().get(&msg.channel) else {
tracing::warn!(
channel = %msg.channel,
"Channel not found in registry -- reply not delivered via transport (already broadcast & persisted)"
);
return;
};
let reply = SendMessage {
content,
recipient: msg.reply_target.clone(),
reply_markup: None,
};
if let Err(e) = channel.send(&reply).await {
tracing::error!("Failed to reply on {}: {e}", channel.name());
}
}
#[must_use]
pub fn spawn_scoped_typing_task(
recipient: String,
channel: String,
cancellation_token: CancellationToken,
) -> tokio::task::JoinHandle<()> {
let refresh_interval = std::time::Duration::from_secs(CHANNEL_TYPING_REFRESH_INTERVAL_SECS);
tokio::spawn(async move {
let Some(ch) = crate::channel_registry().get(&channel) else {
tracing::warn!(
channel = %channel,
"Channel not found in registry — skipping typing indicator"
);
return;
};
let mut interval = tokio::time::interval(refresh_interval);
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
tokio::select! {
() = cancellation_token.cancelled() => break,
_ = interval.tick() => {
if let Err(e) = ch.start_typing(&recipient).await {
tracing::debug!("Failed to start typing on {}: {e}", ch.name());
}
}
}
}
})
}
pub async fn stop_typing(handle: tokio::task::JoinHandle<()>) {
if let Err(error) = handle.await {
tracing::error!("Typing task crashed: {error}");
}
}