Merge pull request #248 from zeroclaw-labs/feat/discord-typing-indicator

feat(channel): add typing indicator for Discord
This commit is contained in:
Argenis 2026-02-15 21:22:34 -05:00 committed by GitHub
commit 68325198e8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 116 additions and 22 deletions

View file

@ -11,6 +11,7 @@ pub struct DiscordChannel {
guild_id: Option<String>, guild_id: Option<String>,
allowed_users: Vec<String>, allowed_users: Vec<String>,
client: reqwest::Client, client: reqwest::Client,
typing_handle: std::sync::Mutex<Option<tokio::task::JoinHandle<()>>>,
} }
impl DiscordChannel { impl DiscordChannel {
@ -20,6 +21,7 @@ impl DiscordChannel {
guild_id, guild_id,
allowed_users, allowed_users,
client: reqwest::Client::new(), client: reqwest::Client::new(),
typing_handle: std::sync::Mutex::new(None),
} }
} }
@ -357,6 +359,41 @@ impl Channel for DiscordChannel {
.map(|r| r.status().is_success()) .map(|r| r.status().is_success())
.unwrap_or(false) .unwrap_or(false)
} }
async fn start_typing(&self, recipient: &str) -> anyhow::Result<()> {
self.stop_typing(recipient).await?;
let client = self.client.clone();
let token = self.bot_token.clone();
let channel_id = recipient.to_string();
let handle = tokio::spawn(async move {
let url = format!("https://discord.com/api/v10/channels/{channel_id}/typing");
loop {
let _ = client
.post(&url)
.header("Authorization", format!("Bot {token}"))
.send()
.await;
tokio::time::sleep(std::time::Duration::from_secs(8)).await;
}
});
if let Ok(mut guard) = self.typing_handle.lock() {
*guard = Some(handle);
}
Ok(())
}
async fn stop_typing(&self, _recipient: &str) -> anyhow::Result<()> {
if let Ok(mut guard) = self.typing_handle.lock() {
if let Some(handle) = guard.take() {
handle.abort();
}
}
Ok(())
}
} }
#[cfg(test)] #[cfg(test)]
@ -581,4 +618,44 @@ mod tests {
let reconstructed = chunks.concat(); let reconstructed = chunks.concat();
assert_eq!(reconstructed, msg); assert_eq!(reconstructed, msg);
} }
#[test]
fn typing_handle_starts_as_none() {
let ch = DiscordChannel::new("fake".into(), None, vec![]);
let guard = ch.typing_handle.lock().unwrap();
assert!(guard.is_none());
}
#[tokio::test]
async fn start_typing_sets_handle() {
let ch = DiscordChannel::new("fake".into(), None, vec![]);
let _ = ch.start_typing("123456").await;
let guard = ch.typing_handle.lock().unwrap();
assert!(guard.is_some());
}
#[tokio::test]
async fn stop_typing_clears_handle() {
let ch = DiscordChannel::new("fake".into(), None, vec![]);
let _ = ch.start_typing("123456").await;
let _ = ch.stop_typing("123456").await;
let guard = ch.typing_handle.lock().unwrap();
assert!(guard.is_none());
}
#[tokio::test]
async fn stop_typing_is_idempotent() {
let ch = DiscordChannel::new("fake".into(), None, vec![]);
assert!(ch.stop_typing("123456").await.is_ok());
assert!(ch.stop_typing("123456").await.is_ok());
}
#[tokio::test]
async fn start_typing_replaces_existing_task() {
let ch = DiscordChannel::new("fake".into(), None, vec![]);
let _ = ch.start_typing("111").await;
let _ = ch.start_typing("222").await;
let guard = ch.typing_handle.lock().unwrap();
assert!(guard.is_some());
}
} }

View file

@ -692,6 +692,15 @@ pub async fn start_channels(config: Config) -> Result<()> {
.await; .await;
} }
let target_channel = channels.iter().find(|ch| ch.name() == msg.channel);
// Show typing indicator while processing
if let Some(ch) = target_channel {
if let Err(e) = ch.start_typing(&msg.sender).await {
tracing::debug!("Failed to start typing on {}: {e}", ch.name());
}
}
// Call the LLM with system prompt (identity + soul + tools) // Call the LLM with system prompt (identity + soul + tools)
println!(" ⏳ Processing message..."); println!(" ⏳ Processing message...");
let started_at = Instant::now(); let started_at = Instant::now();
@ -702,6 +711,13 @@ pub async fn start_channels(config: Config) -> Result<()> {
) )
.await; .await;
// Stop typing before sending the response
if let Some(ch) = target_channel {
if let Err(e) = ch.stop_typing(&msg.sender).await {
tracing::debug!("Failed to stop typing on {}: {e}", ch.name());
}
}
match llm_result { match llm_result {
Ok(Ok(response)) => { Ok(Ok(response)) => {
println!( println!(
@ -709,13 +725,9 @@ pub async fn start_channels(config: Config) -> Result<()> {
started_at.elapsed().as_millis(), started_at.elapsed().as_millis(),
truncate_with_ellipsis(&response, 80) truncate_with_ellipsis(&response, 80)
); );
// Find the channel that sent this message and reply if let Some(ch) = target_channel {
for ch in &channels { if let Err(e) = ch.send(&response, &msg.sender).await {
if ch.name() == msg.channel { eprintln!(" ❌ Failed to reply on {}: {e}", ch.name());
if let Err(e) = ch.send(&response, &msg.sender).await {
eprintln!(" ❌ Failed to reply on {}: {e}", ch.name());
}
break;
} }
} }
} }
@ -724,11 +736,8 @@ pub async fn start_channels(config: Config) -> Result<()> {
" ❌ LLM error after {}ms: {e}", " ❌ LLM error after {}ms: {e}",
started_at.elapsed().as_millis() started_at.elapsed().as_millis()
); );
for ch in &channels { if let Some(ch) = target_channel {
if ch.name() == msg.channel { let _ = ch.send(&format!("⚠️ Error: {e}"), &msg.sender).await;
let _ = ch.send(&format!("⚠️ Error: {e}"), &msg.sender).await;
break;
}
} }
} }
Err(_) => { Err(_) => {
@ -741,16 +750,13 @@ pub async fn start_channels(config: Config) -> Result<()> {
timeout_msg, timeout_msg,
started_at.elapsed().as_millis() started_at.elapsed().as_millis()
); );
for ch in &channels { if let Some(ch) = target_channel {
if ch.name() == msg.channel { let _ = ch
let _ = ch .send(
.send( "⚠️ Request timed out while waiting for the model. Please try again.",
"⚠️ Request timed out while waiting for the model. Please try again.", &msg.sender,
&msg.sender, )
) .await;
.await;
break;
}
} }
} }
} }

View file

@ -26,4 +26,15 @@ pub trait Channel: Send + Sync {
async fn health_check(&self) -> bool { async fn health_check(&self) -> bool {
true true
} }
/// Signal that the bot is processing a response (e.g. "typing" indicator).
/// Implementations should repeat the indicator as needed for their platform.
async fn start_typing(&self, _recipient: &str) -> anyhow::Result<()> {
Ok(())
}
/// Stop any active typing indicator.
async fn stop_typing(&self, _recipient: &str) -> anyhow::Result<()> {
Ok(())
}
} }