diff --git a/examples/testbot_instances.rs b/examples/testbot_instances.rs new file mode 100644 index 0000000..1c0e5bf --- /dev/null +++ b/examples/testbot_instances.rs @@ -0,0 +1,183 @@ +//! Similar to the `testbot_manual` example, but using [`Instance`] to connect +//! to the room (and to reconnect). + +use euphoxide::api::packet::ParsedPacket; +use euphoxide::api::{Data, Nick, Send}; +use euphoxide::bot::instance::{Event, ServerConfig, Snapshot}; +use euphoxide::bot::instances::Instances; +use time::OffsetDateTime; +use tokio::sync::mpsc; + +const NICK: &str = "TestBot"; +const HELP: &str = "I'm an example bot for https://github.com/Garmelon/euphoxide"; + +fn format_delta(delta: time::Duration) -> String { + const MINUTE: u64 = 60; + const HOUR: u64 = MINUTE * 60; + const DAY: u64 = HOUR * 24; + + let mut seconds: u64 = delta.whole_seconds().try_into().unwrap(); + let mut parts = vec![]; + + let days = seconds / DAY; + if days > 0 { + parts.push(format!("{days}d")); + seconds -= days * DAY; + } + + let hours = seconds / HOUR; + if hours > 0 { + parts.push(format!("{hours}h")); + seconds -= hours * HOUR; + } + + let mins = seconds / MINUTE; + if mins > 0 { + parts.push(format!("{mins}m")); + seconds -= mins * MINUTE; + } + + if parts.is_empty() || seconds > 0 { + parts.push(format!("{seconds}s")); + } + + parts.join(" ") +} + +async fn on_packet(packet: ParsedPacket, snapshot: Snapshot) -> Result<(), ()> { + let data = match packet.content { + Ok(data) => data, + Err(err) => { + println!("Error for {}: {err}", packet.r#type); + return Err(()); + } + }; + + match data { + Data::HelloEvent(ev) => println!("Connected with id {}", ev.session.id), + Data::SnapshotEvent(ev) => { + for session in ev.listing { + println!("{:?} ({}) is already here", session.name, session.id); + } + + // Here, a new task is spawned so the main event loop can + // continue running immediately instead of waiting for a reply + // from the server. + // + // We only need to do this because we want to log the result of + // the nick command. Otherwise, we could've just called + // tx.send() synchronously and ignored the returned Future. + let conn_tx_clone = snapshot.conn_tx.clone(); + tokio::spawn(async move { + // Awaiting the future returned by the send command lets you + // (type-safely) access the server's reply. + let reply = conn_tx_clone + .send(Nick { + name: NICK.to_string(), + }) + .await; + match reply { + Ok(reply) => println!("Set nick to {:?}", reply.to), + Err(err) => println!("Failed to set nick: {err}"), + }; + }); + } + Data::BounceEvent(_) => { + println!("Received bounce event, stopping"); + return Err(()); + } + Data::DisconnectEvent(_) => { + println!("Received disconnect event, stopping"); + return Err(()); + } + Data::JoinEvent(event) => println!("{:?} ({}) joined", event.0.name, event.0.id), + Data::PartEvent(event) => println!("{:?} ({}) left", event.0.name, event.0.id), + Data::NickEvent(event) => println!( + "{:?} ({}) is now known as {:?}", + event.from, event.id, event.to + ), + Data::SendEvent(event) => { + println!("Message {} was just sent", event.0.id.0); + + let content = event.0.content.trim(); + let mut reply = None; + + if content == "!ping" || content == format!("!ping @{NICK}") { + reply = Some("Pong!".to_string()); + } else if content == format!("!help @{NICK}") { + reply = Some(HELP.to_string()); + } else if content == format!("!uptime @{NICK}") { + if let Some(joined) = snapshot.state.joined() { + let delta = OffsetDateTime::now_utc() - joined.since; + reply = Some(format!("/me has been up for {}", format_delta(delta))); + } + } else if content == "!test" { + reply = Some("Test successful!".to_string()); + } else if content == format!("!kill @{NICK}") { + println!( + "I was killed by {:?} ({})", + event.0.sender.name, event.0.sender.id + ); + // Awaiting the server reply in the main loop to ensure the + // message is sent before we exit the loop. Otherwise, there + // would be a race between sending the message and closing + // the connection as the send function can return before the + // message has actually been sent. + let _ = snapshot + .conn_tx + .send(Send { + content: "/me dies".to_string(), + parent: Some(event.0.id), + }) + .await; + return Err(()); + } + + if let Some(reply) = reply { + // If you are not interested in the result, you can just + // throw away the future returned by the send function. + println!("Sending reply..."); + let _ = snapshot.conn_tx.send(Send { + content: reply, + parent: Some(event.0.id), + }); + println!("Reply sent!"); + } + } + _ => {} + } + + Ok(()) +} + +#[tokio::main] +async fn main() { + let (tx, mut rx) = mpsc::unbounded_channel(); + let mut instances = Instances::new(ServerConfig::default()); + + for room in ["test", "test2", "testing"] { + let tx_clone = tx.clone(); + let instance = instances + .server_config() + .clone() + .room(room) + .username(Some("TestBot")) + .build(move |e| { + let _ = tx_clone.send(e); + }); + instances.add(instance); + } + + while let Some(event) = rx.recv().await { + instances.purge(); + if instances.is_empty() { + break; + } + + if let Event::Packet(_config, packet, snapshot) = event { + if on_packet(packet, snapshot).await.is_err() { + break; + } + } + } +} diff --git a/src/bot.rs b/src/bot.rs index 8d4c2e5..ea7f5f0 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -1,3 +1,4 @@ //! Building blocks for bots. pub mod instance; +pub mod instances; diff --git a/src/bot/instances.rs b/src/bot/instances.rs new file mode 100644 index 0000000..f54ecf6 --- /dev/null +++ b/src/bot/instances.rs @@ -0,0 +1,58 @@ +//! A convenient way to keep a [`ServerConfig`] and some [`Instance`]s. + +use std::collections::HashMap; + +use super::instance::{Instance, ServerConfig}; + +/// A convenient way to keep a [`ServerConfig`] and some [`Instance`]s. +pub struct Instances { + server_config: ServerConfig, + instances: HashMap, +} + +impl Instances { + pub fn new(server_config: ServerConfig) -> Self { + Self { + server_config, + instances: HashMap::new(), + } + } + + pub fn server_config(&self) -> &ServerConfig { + &self.server_config + } + + pub fn instances(&self) -> impl Iterator { + self.instances.values() + } + + pub fn is_empty(&self) -> bool { + self.instances.is_empty() + } + + /// Get an instance by its name. + pub fn get(&self, name: &str) -> Option<&Instance> { + self.instances.get(name) + } + + /// Add a new instance. + /// + /// If an instance with the same name exists already, it will be replaced by + /// the new instance. + pub fn add(&mut self, instance: Instance) { + self.instances + .insert(instance.config().name.clone(), instance); + } + + /// Remove an instance by its name. + pub fn remove(&mut self, name: &str) -> Option { + self.instances.remove(name) + } + + /// Remove all stopped instances. + /// + /// This function should be called regularly. + pub fn purge(&mut self) { + self.instances.retain(|_, i| !i.stopped()); + } +}