mod enrichment;
pub mod gui;
pub mod telegram;
pub mod voice;
pub use enrichment::{EnrichmentStrategy, enrich_links, enrich_message, has_only_audio_markers};
pub use telegram::mirror_gui_message_to_telegram;
use crate::chat_history::ChatHistoryInsert;
use crate::turso;
use crate::{ChannelMessage, ChatDirection};
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) {
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_direction = match self.direction {
ChatDirection::Agent => "agent".to_string(),
ChatDirection::User => "user".to_string(),
ChatDirection::Divider => {
unreachable!("Divider markers should not go through broadcast_and_persist")
}
};
broadcast_chat_event(
&message_id,
&self.user_name,
&self.content,
self.direction,
&self.channel,
self.agent_role.clone(),
&self.workspace,
self.optimistic_id.clone(),
×tamp,
);
let store = crate::chat_history::store();
let _ = store
.insert(&ChatHistoryInsert {
message_id,
user_name: self.user_name,
direction: db_direction,
content: self.content,
agent_role: self.agent_role,
workspace: self.workspace,
})
.await;
}
}
pub(crate) 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;
}
#[expect(clippy::too_many_arguments)]
pub(crate) fn broadcast_chat_event(
message_id: &str,
user_name: &str,
content: &str,
direction: ChatDirection,
channel: &str,
agent_role: Option<String>,
workspace: &str,
optimistic_id: Option<String>,
timestamp: &str,
) {
use crate::ChatEvent;
if let Some(tx) = crate::CHAT_BROADCAST.get() {
let _ = tx.send(ChatEvent::Message {
message_id: message_id.to_string(),
user_name: user_name.to_string(),
content: content.to_string(),
direction,
timestamp: timestamp.to_string(),
channel: channel.to_string(),
agent_role,
workspace: workspace.to_string(),
optimistic_id,
});
}
}
pub async fn broadcast_and_persist_incoming_message(
msg: &ChannelMessage,
broadcast_content: &str,
persist_content: &str,
) {
let message_id = crate::generate_id();
let timestamp = turso::now();
broadcast_chat_event(
&message_id,
&msg.user_name,
broadcast_content,
ChatDirection::User,
&msg.channel,
None,
&msg.workspace,
msg.optimistic_id.clone(),
×tamp,
);
tokio::join!(
async {
let store = crate::chat_history::store();
let _ = store
.insert(&ChatHistoryInsert {
message_id,
user_name: msg.user_name.clone(),
direction: "user".to_string(),
content: persist_content.to_string(),
agent_role: None,
workspace: msg.workspace.clone(),
})
.await;
},
async {
let mut mirror_msg = msg.clone();
mirror_msg.content = persist_content.to_string();
mirror_gui_message_to_telegram(&mirror_msg).await;
},
);
}
#[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}");
}
}