Compare commits

..

32 commits

Author SHA1 Message Date
9798136dc4 Make instance channel unbounFOO 2024-12-27 19:38:18 +01:00
38556a16dd Make bot state readonly 2024-12-27 19:11:52 +01:00
818b8748a1 Fix Commands not being Send + Sync 2024-12-27 19:05:53 +01:00
4160d69a2b Add botrulez commands 2024-12-27 19:04:30 +01:00
12448c26c9 Reorganize command wrappers 2024-12-27 18:41:14 +01:00
7928eda0d0 Add clap-based commands 2024-12-27 18:41:14 +01:00
48f75a1efd Add command helpers 2024-12-27 18:41:14 +01:00
8a7d414a01 Add command abstraction 2024-12-27 18:41:14 +01:00
287ce97819 Remove unnecessary RwLock in Bot 2024-12-27 15:07:10 +01:00
0b8f3c45f3 Don't reexport bot and instance types 2024-12-27 15:07:10 +01:00
63edcba9fa Move config structs to corresponding modules 2024-12-27 15:07:10 +01:00
8377695529 Remove instance id generics
Instead, all instance ids are now usize (like message ids). This allows
me to enforce the fact that no two instances of a Bot must have the same
id by generating the ids in the Bot.

Reusing the same id for multiple instances that send their events to the
same place can lead to race conditions depending on how events are
handled. For example, the old instance might still be shutting down
while the new instance is already connected to a room, leading to an
InstanceEvent::Stopped from the old instance that seemingly applies to
the new instance.
2024-12-27 15:07:10 +01:00
17ff660ab2 Use next_id instead of last_id 2024-12-27 14:35:22 +01:00
f093c0e9e5 Set default ClientConn command channel bufsize to 1 2024-12-27 13:19:00 +01:00
36f6dc1587 Add Bot for managing multiple instances 2024-12-27 13:19:00 +01:00
0fdb77fbd3 Add instance-based examplebot 2024-12-27 13:18:36 +01:00
feacde16aa Add bot instance 2024-12-11 23:33:19 +01:00
b9c4c2a003 Set up workspace 2024-12-11 01:04:51 +01:00
9c055b67f7 Disconnect when required 2024-12-11 01:04:51 +01:00
a36f45eec6 Document client connection 2024-12-10 20:50:54 +01:00
ccae75bf28 Export Error and Result at top level 2024-12-08 18:22:16 +01:00
59456c295b Add examplebot 2024-12-07 23:38:17 +01:00
cbb668c9da Convert ParsedPacket into Data 2024-12-07 23:37:03 +01:00
1bb043e10a Add client connection 2024-12-07 23:21:00 +01:00
94b796262b Model client connection state 2024-12-07 23:00:09 +01:00
3e6ee77834 Move error to separate file 2024-12-07 22:59:25 +01:00
6f0e08350e Refactor basic euphoria connection 2024-12-07 22:59:25 +01:00
bcedd3350d Refactor replies module 2024-12-07 22:59:14 +01:00
55d672cddb Refactor and document nick module 2024-12-07 22:58:58 +01:00
31a6d33267 Refactor and document api module 2024-12-07 22:58:28 +01:00
a661449b6f Refactor and document emoji module 2024-12-07 22:58:12 +01:00
904dda1af0 Prepare rewrite 2024-12-07 22:57:26 +01:00
54 changed files with 3437 additions and 2875 deletions

12
.vscode/settings.json vendored
View file

@ -1,8 +1,8 @@
{
"files.insertFinalNewline": true,
"rust-analyzer.cargo.features": "all",
"rust-analyzer.imports.granularity.enforce": true,
"rust-analyzer.imports.granularity.group": "module",
"rust-analyzer.imports.group.enable": true,
"evenBetterToml.formatter.columnWidth": 100,
"files.insertFinalNewline": true,
"rust-analyzer.cargo.features": "all",
"rust-analyzer.imports.granularity.enforce": true,
"rust-analyzer.imports.granularity.group": "crate",
"rust-analyzer.imports.group.enable": true,
"evenBetterToml.formatter.columnWidth": 100
}

View file

@ -14,18 +14,6 @@ Procedure when bumping the version number:
## Unreleased
## v0.6.1 - 2025-02-23
### Changed
- Updated set of emoji names
### Fixed
- Nick hue hashing algorithm in some edge cases
## v0.6.0 - 2025-02-21
### Added
- `api::Time::from_timestamp`

View file

@ -1,48 +1,29 @@
[package]
name = "euphoxide"
version = "0.6.1"
[workspace]
resolver = "2"
members = ["euphoxide", "euphoxide-bot"]
[workspace.package]
version = "0.5.1"
edition = "2021"
[features]
bot = ["dep:async-trait", "dep:clap", "dep:cookie"]
[dependencies]
async-trait = { version = "0.1.86", optional = true }
caseless = "0.2.2"
cookie = { version = "0.18.1", optional = true }
futures-util = { version = "0.3.31", default-features = false, features = ["sink"] }
jiff = { version = "0.2.1", features = ["serde"] }
log = "0.4.25"
serde = { version = "1.0.218", features = ["derive"] }
serde_json = "1.0.139"
tokio = { version = "1.43.0", features = ["time", "sync", "macros", "rt"] }
tokio-stream = "0.1.17"
tokio-tungstenite = { version = "0.26.2", features = ["rustls-tls-native-roots"] }
[workspace.dependencies]
async-trait = "0.1.83"
caseless = "0.2.1"
clap = { version = "4.5.23", default-features = false, features = ["std", "derive"] }
cookie = "0.18.1"
futures-util = "0.3.31"
jiff = { version = "0.1.15", default-features = false, features = ["std"] }
log = "0.4.22"
serde = { version = "1.0.215", features = ["derive"] }
serde_json = "1.0.133"
tokio = { version = "1.42.0", features = ["macros", "sync", "time"] }
tokio-tungstenite = "0.24.0"
unicode-normalization = "0.1.24"
# For examples
anyhow = "1.0.94"
rustls = "0.23.19"
[dependencies.clap]
version = "4.5.30"
optional = true
default-features = false
features = ["std", "derive", "deprecated"]
[dev-dependencies] # For example bot
rustls = "0.23.23"
tokio = { version = "1.43.0", features = ["rt-multi-thread"] }
[[example]]
name = "testbot_instance"
required-features = ["bot"]
[[example]]
name = "testbot_instances"
required-features = ["bot"]
[[example]]
name = "testbot_commands"
required-features = ["bot"]
[lints]
[workspace.lints]
rust.unsafe_code = { level = "forbid", priority = 1 }
# Lint groups
rust.deprecated_safe = "warn"

26
euphoxide-bot/Cargo.toml Normal file
View file

@ -0,0 +1,26 @@
[package]
name = "euphoxide-bot"
version = { workspace = true }
edition = { workspace = true }
[features]
clap = ["dep:clap"]
[dependencies]
async-trait = { workspace = true }
clap = { workspace = true, optional = true }
cookie = { workspace = true }
euphoxide = { path = "../euphoxide" }
jiff = { workspace = true }
log = { workspace = true }
tokio = { workspace = true, features = ["rt"] }
tokio-tungstenite = { workspace = true }
[dev-dependencies]
anyhow = { workspace = true }
rustls = { workspace = true }
tokio = { workspace = true, features = ["full"] }
tokio-tungstenite = { workspace = true, features = ["rustls-tls-native-roots"] }
[lints]
workspace = true

View file

@ -0,0 +1,102 @@
use std::{sync::Arc, time::Duration};
use async_trait::async_trait;
use euphoxide::{
api::{Data, Message, Nick, ParsedPacket, Send},
client::{conn::ClientConnHandle, state::State},
};
use euphoxide_bot::{
bot::Bot,
command::{
botrulez::{FullHelp, HasCommandInfos, HasStartTime, Ping, ShortHelp, Uptime},
Command, CommandExt, Commands, Context, Info, Propagate,
},
instance::ServerConfig,
};
use jiff::Timestamp;
struct Pyramid;
#[async_trait]
impl Command<BotState> for Pyramid {
fn info(&self, _ctx: &Context) -> Info {
Info::new().with_description("build a pyramid")
}
async fn execute(
&self,
_arg: &str,
msg: &Message,
ctx: &Context,
_bot: &BotState,
) -> euphoxide::Result<Propagate> {
let mut parent = msg.id;
for _ in 0..3 {
let first = ctx.reply(parent, "brick").await?;
ctx.reply_only(parent, "brick").await?;
parent = first.await?.0.id;
tokio::time::sleep(Duration::from_secs(1)).await;
}
ctx.reply_only(parent, "brick").await?;
Ok(Propagate::No)
}
}
#[derive(Clone)]
struct BotState {
start_time: Timestamp,
commands: Arc<Commands<Self>>,
}
impl HasStartTime for BotState {
fn start_time(&self) -> Timestamp {
self.start_time
}
}
impl HasCommandInfos for BotState {
fn command_infos(&self, ctx: &Context) -> Vec<Info> {
self.commands.infos(ctx)
}
}
async fn run() -> anyhow::Result<()> {
let commands = Commands::new()
.then(Ping::default())
.then(Uptime)
.then(ShortHelp::new("/me demonstrates how to use euphoxide"))
.then(FullHelp::new())
.then(Pyramid.global("pyramid"));
let commands = Arc::new(commands);
let state = BotState {
start_time: Timestamp::now(),
commands: commands.clone(),
};
let mut bot = Bot::new();
let config = ServerConfig::default()
.instance("test")
.with_username("examplebot");
bot.add_instance(config);
while let Some(event) = bot.recv().await {
commands.on_bot_event(event, &state).await?;
}
Ok(())
}
#[tokio::main]
async fn main() {
loop {
if let Err(err) = run().await {
println!("Error while running: {err}");
}
}
}

View file

@ -0,0 +1,99 @@
use std::time::Duration;
use euphoxide::{
api::{Data, Message, Nick, Send},
client::conn::ClientConnHandle,
};
use euphoxide_bot::instance::{Instance, InstanceEvent, ServerConfig};
use tokio::sync::mpsc;
async fn set_nick(conn: &ClientConnHandle) -> anyhow::Result<()> {
conn.send_only(Nick {
name: "examplebot".to_string(),
})
.await?;
Ok(())
}
async fn send_pong(conn: &ClientConnHandle, msg: Message) -> anyhow::Result<()> {
conn.send_only(Send {
content: "Pong!".to_string(),
parent: Some(msg.id),
})
.await?;
Ok(())
}
async fn send_pyramid(conn: &ClientConnHandle, msg: Message) -> anyhow::Result<()> {
let mut parent = msg.id;
for _ in 0..3 {
let first = conn
.send(Send {
content: "brick".to_string(),
parent: Some(parent),
})
.await?;
conn.send_only(Send {
content: "brick".to_string(),
parent: Some(parent),
})
.await?;
parent = first.await?.0.id;
tokio::time::sleep(Duration::from_secs(1)).await;
}
conn.send_only(Send {
content: "brick".to_string(),
parent: Some(parent),
})
.await?;
Ok(())
}
async fn on_data(conn: ClientConnHandle, data: Data) {
let result = match data {
Data::SnapshotEvent(_) => set_nick(&conn).await,
Data::SendEvent(event) if event.0.content == "!ping" => send_pong(&conn, event.0).await,
Data::SendEvent(event) if event.0.content == "!pyramid" => {
send_pyramid(&conn, event.0).await
}
_ => Ok(()),
};
if let Err(err) = result {
println!("Error while responding: {err}");
}
}
async fn run() -> anyhow::Result<()> {
let config = ServerConfig::default()
.instance("test")
.with_username("examplebot");
let (event_tx, mut event_rx) = mpsc::channel(10);
let _instance = Instance::new(0, config, event_tx); // Don't drop or instance stops
while let Some(event) = event_rx.recv().await {
if let InstanceEvent::Packet { conn, packet, .. } = event {
let data = packet.into_data()?;
tokio::task::spawn(on_data(conn, data));
}
}
Ok(())
}
#[tokio::main]
async fn main() {
loop {
if let Err(err) = run().await {
println!("Error while running: {err}");
}
}
}

180
euphoxide-bot/src/bot.rs Normal file
View file

@ -0,0 +1,180 @@
use std::{collections::HashMap, time::Duration};
use euphoxide::{
api::ParsedPacket,
client::{conn::ClientConnHandle, state::State},
};
use tokio::sync::mpsc;
use crate::instance::{Instance, InstanceConfig, InstanceEvent};
#[derive(Debug)]
pub enum BotEvent {
Started {
instance: Instance,
},
Connecting {
instance: Instance,
},
Connected {
instance: Instance,
conn: ClientConnHandle,
state: State,
},
Joined {
instance: Instance,
conn: ClientConnHandle,
state: State,
},
Packet {
instance: Instance,
conn: ClientConnHandle,
state: State,
packet: ParsedPacket,
},
Disconnected {
instance: Instance,
},
Stopped {
instance: Instance,
},
}
impl BotEvent {
fn from_instance_event(instance: Instance, event: InstanceEvent) -> Self {
match event {
InstanceEvent::Started { id: _ } => Self::Started { instance },
InstanceEvent::Connecting { id: _ } => Self::Connecting { instance },
InstanceEvent::Connected { id: _, conn, state } => Self::Connected {
instance,
conn,
state,
},
InstanceEvent::Joined { id: _, conn, state } => Self::Joined {
instance,
conn,
state,
},
InstanceEvent::Packet {
id: _,
conn,
state,
packet,
} => Self::Packet {
instance,
conn,
state,
packet,
},
InstanceEvent::Disconnected { id: _ } => Self::Disconnected { instance },
InstanceEvent::Stopped { id: _ } => Self::Stopped { instance },
}
}
pub fn instance(&self) -> &Instance {
match self {
Self::Started { instance } => instance,
Self::Connecting { instance, .. } => instance,
Self::Connected { instance, .. } => instance,
Self::Joined { instance, .. } => instance,
Self::Packet { instance, .. } => instance,
Self::Disconnected { instance } => instance,
Self::Stopped { instance } => instance,
}
}
}
#[non_exhaustive]
pub struct BotConfig {
pub event_timeout: Duration,
pub event_channel_bufsize: usize,
}
impl Default for BotConfig {
fn default() -> Self {
Self {
event_timeout: Duration::from_secs(1),
event_channel_bufsize: 10,
}
}
}
pub struct Bot {
config: BotConfig,
next_id: usize,
instances: HashMap<usize, Instance>,
event_tx: mpsc::Sender<InstanceEvent>,
event_rx: mpsc::Receiver<InstanceEvent>,
}
impl Bot {
pub fn new() -> Self {
Self::new_with_config(BotConfig::default())
}
pub fn new_with_config(config: BotConfig) -> Self {
let (event_tx, event_rx) = mpsc::channel(10);
Self {
config,
next_id: 0,
instances: HashMap::new(),
event_tx,
event_rx,
}
}
fn purge_instances(&mut self) {
self.instances.retain(|_, v| !v.stopped());
}
pub fn get_instances(&self) -> Vec<Instance> {
self.instances.values().cloned().collect()
}
pub fn add_instance(&mut self, config: InstanceConfig) -> Instance {
let id = self.next_id;
self.next_id += 1;
assert!(!self.instances.contains_key(&id));
let instance = Instance::new(id, config, self.event_tx.clone());
self.instances.insert(id, instance.clone());
instance
}
pub async fn recv(&mut self) -> Option<BotEvent> {
// We hold exactly one sender. If no other senders exist, then all
// instances are dead and we'll never receive any more events unless we
// return and allow the user to add more instances again.
while self.event_rx.sender_strong_count() > 1 {
// Prevent potential memory leak
self.purge_instances();
let Ok(event) =
tokio::time::timeout(self.config.event_timeout, self.event_rx.recv()).await
else {
// We need to re-check the sender count occasionally. It's
// possible that there are still instances that just haven't
// sent an event in a while, so we can't just return here.
continue;
};
// This only returns None if no senders remain, and since we always
// own one sender, this can't happen.
let event = event.expect("event channel should never close since we own a sender");
if let Some(instance) = self.instances.get(&event.id()) {
return Some(BotEvent::from_instance_event(instance.clone(), event));
}
}
None
}
}
impl Default for Bot {
fn default() -> Self {
Self::new()
}
}

View file

@ -0,0 +1,238 @@
pub mod bang;
pub mod basic;
pub mod botrulez;
#[cfg(feature = "clap")]
pub mod clap;
use std::future::Future;
use async_trait::async_trait;
use bang::{General, Global, Specific};
use basic::{Described, Prefixed};
use euphoxide::{
api::{self, Data, Message, MessageId, ParsedPacket, SendEvent, SendReply},
client::{
conn::ClientConnHandle,
state::{Joined, State},
},
};
use crate::{bot::BotEvent, instance::InstanceEvent};
#[non_exhaustive]
pub struct Context {
pub conn: ClientConnHandle,
pub joined: Joined,
}
impl Context {
pub async fn send<S: ToString>(
&self,
content: S,
) -> euphoxide::Result<impl Future<Output = euphoxide::Result<SendReply>>> {
self.conn
.send(api::Send {
content: content.to_string(),
parent: None,
})
.await
}
pub async fn send_only<S: ToString>(&self, content: S) -> euphoxide::Result<()> {
let _ignore = self.send(content).await?;
Ok(())
}
pub async fn reply<S: ToString>(
&self,
parent: MessageId,
content: S,
) -> euphoxide::Result<impl Future<Output = euphoxide::Result<SendReply>>> {
self.conn
.send(api::Send {
content: content.to_string(),
parent: Some(parent),
})
.await
}
pub async fn reply_only<S: ToString>(
&self,
parent: MessageId,
content: S,
) -> euphoxide::Result<()> {
let _ignore = self.reply(parent, content).await?;
Ok(())
}
}
#[derive(Default)]
pub struct Info {
pub trigger: Option<String>,
pub description: Option<String>,
}
impl Info {
pub fn new() -> Self {
Self::default()
}
pub fn with_trigger(mut self, trigger: impl ToString) -> Self {
self.trigger = Some(trigger.to_string());
self
}
pub fn with_description(mut self, description: impl ToString) -> Self {
self.description = Some(description.to_string());
self
}
pub fn prepend_trigger(&mut self, trigger: impl ToString) {
let cur_trigger = self.trigger.get_or_insert_default();
if !cur_trigger.is_empty() {
cur_trigger.insert(0, ' ');
}
cur_trigger.insert_str(0, &trigger.to_string());
}
pub fn with_prepended_trigger(mut self, trigger: impl ToString) -> Self {
self.prepend_trigger(trigger);
self
}
}
/// Whether a message should propagate to subsequent commands.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Propagate {
No,
Yes,
}
#[allow(unused_variables)]
#[async_trait]
pub trait Command<B, E = euphoxide::Error> {
fn info(&self, ctx: &Context) -> Info {
Info::default()
}
async fn execute(
&self,
arg: &str,
msg: &Message,
ctx: &Context,
bot: &B,
) -> Result<Propagate, E>;
}
pub trait CommandExt<B, E>: Sized {
fn described(self) -> Described<Self> {
Described::new(self)
}
fn hidden(self) -> Described<Self> {
Described::hidden(self)
}
fn prefixed(self, prefix: impl ToString) -> Prefixed<Self> {
Prefixed::new(prefix, self)
}
fn general(self, name: impl ToString) -> General<Self> {
General::new(name, self)
}
fn global(self, name: impl ToString) -> Global<Self> {
Global::new(name, self)
}
fn specific(self, name: impl ToString) -> Specific<Self> {
Specific::new(name, self)
}
}
impl<B, E, C: Command<B, E>> CommandExt<B, E> for C {}
pub struct Commands<B, E = euphoxide::Error> {
commands: Vec<Box<dyn Command<B, E> + Sync + Send>>,
}
impl<B, E> Commands<B, E> {
pub fn new() -> Self {
Self { commands: vec![] }
}
pub fn add(&mut self, command: impl Command<B, E> + Sync + Send + 'static) {
self.commands.push(Box::new(command));
}
pub fn then(mut self, command: impl Command<B, E> + Sync + Send + 'static) -> Self {
self.add(command);
self
}
pub fn infos(&self, ctx: &Context) -> Vec<Info> {
self.commands.iter().map(|c| c.info(ctx)).collect()
}
pub async fn on_packet(
&self,
conn: ClientConnHandle,
state: State,
packet: ParsedPacket,
bot: &B,
) -> Result<Propagate, E> {
let Ok(Data::SendEvent(SendEvent(msg))) = &packet.content else {
return Ok(Propagate::Yes);
};
let State::Joined(joined) = state else {
return Ok(Propagate::Yes);
};
let ctx = Context { conn, joined };
for command in &self.commands {
let propagate = command.execute(&msg.content, msg, &ctx, bot).await?;
if propagate == Propagate::No {
return Ok(Propagate::No);
}
}
Ok(Propagate::Yes)
}
pub async fn on_instance_event(&self, event: InstanceEvent, bot: &B) -> Result<Propagate, E> {
if let InstanceEvent::Packet {
conn,
state,
packet,
..
} = event
{
self.on_packet(conn, state, packet, bot).await
} else {
Ok(Propagate::Yes)
}
}
pub async fn on_bot_event(&self, event: BotEvent, bot: &B) -> Result<Propagate, E> {
if let BotEvent::Packet {
conn,
state,
packet,
..
} = event
{
self.on_packet(conn, state, packet, bot).await
} else {
Ok(Propagate::Yes)
}
}
}
// Has fewer restrictions on generic types than #[derive(Default)].
impl<B, E> Default for Commands<B, E> {
fn default() -> Self {
Self::new()
}
}

View file

@ -1,9 +1,9 @@
//! Euphoria-style `!foo` and `!foo @bar` command wrappers.
use async_trait::async_trait;
use euphoxide::{api::Message, nick};
use crate::api::Message;
use crate::nick;
use super::{Command, Context};
use super::{Command, Context, Info, Propagate};
// TODO Don't ignore leading whitespace?
// I'm not entirely happy with how commands handle whitespace, and on euphoria,
@ -38,7 +38,7 @@ impl<C> Global<C> {
}
}
pub fn prefix<S: ToString>(mut self, prefix: S) -> Self {
pub fn with_prefix<S: ToString>(mut self, prefix: S) -> Self {
self.prefix = prefix.to_string();
self
}
@ -47,12 +47,13 @@ impl<C> Global<C> {
#[async_trait]
impl<B, E, C> Command<B, E> for Global<C>
where
B: Send,
B: Sync,
C: Command<B, E> + Send + Sync,
{
fn description(&self, ctx: &Context) -> Option<String> {
let inner = self.inner.description(ctx)?;
Some(format!("{}{} - {inner}", self.prefix, self.name))
fn info(&self, ctx: &Context) -> Info {
self.inner
.info(ctx)
.with_prepended_trigger(format!("{}{}", self.prefix, self.name))
}
async fn execute(
@ -60,16 +61,14 @@ where
arg: &str,
msg: &Message,
ctx: &Context,
bot: &mut B,
) -> Result<bool, E> {
// TODO Replace with let-else
let (name, rest) = match parse_prefix_initiated(arg, &self.prefix) {
Some(parsed) => parsed,
None => return Ok(false),
bot: &B,
) -> Result<Propagate, E> {
let Some((name, rest)) = parse_prefix_initiated(arg, &self.prefix) else {
return Ok(Propagate::Yes);
};
if name != self.name {
return Ok(false);
return Ok(Propagate::Yes);
}
self.inner.execute(rest, msg, ctx, bot).await
@ -91,7 +90,7 @@ impl<C> General<C> {
}
}
pub fn prefix<S: ToString>(mut self, prefix: S) -> Self {
pub fn with_prefix<S: ToString>(mut self, prefix: S) -> Self {
self.prefix = prefix.to_string();
self
}
@ -100,12 +99,13 @@ impl<C> General<C> {
#[async_trait]
impl<B, E, C> Command<B, E> for General<C>
where
B: Send,
B: Sync,
C: Command<B, E> + Send + Sync,
{
fn description(&self, ctx: &Context) -> Option<String> {
let inner = self.inner.description(ctx)?;
Some(format!("{}{} - {inner}", self.prefix, self.name))
fn info(&self, ctx: &Context) -> Info {
self.inner
.info(ctx)
.with_prepended_trigger(format!("{}{}", self.prefix, self.name))
}
async fn execute(
@ -113,23 +113,21 @@ where
arg: &str,
msg: &Message,
ctx: &Context,
bot: &mut B,
) -> Result<bool, E> {
// TODO Replace with let-else
let (name, rest) = match parse_prefix_initiated(arg, &self.prefix) {
Some(parsed) => parsed,
None => return Ok(false),
bot: &B,
) -> Result<Propagate, E> {
let Some((name, rest)) = parse_prefix_initiated(arg, &self.prefix) else {
return Ok(Propagate::Yes);
};
if name != self.name {
return Ok(false);
return Ok(Propagate::Yes);
}
if parse_prefix_initiated(rest, "@").is_some() {
// The command looks like a specific command. If we treated it like
// a general command match, we would interpret other bots' specific
// commands as general commands.
return Ok(false);
return Ok(Propagate::Yes);
}
self.inner.execute(rest, msg, ctx, bot).await
@ -151,7 +149,7 @@ impl<C> Specific<C> {
}
}
pub fn prefix<S: ToString>(mut self, prefix: S) -> Self {
pub fn with_prefix<S: ToString>(mut self, prefix: S) -> Self {
self.prefix = prefix.to_string();
self
}
@ -160,13 +158,14 @@ impl<C> Specific<C> {
#[async_trait]
impl<B, E, C> Command<B, E> for Specific<C>
where
B: Send,
B: Sync,
C: Command<B, E> + Send + Sync,
{
fn description(&self, ctx: &Context) -> Option<String> {
let inner = self.inner.description(ctx)?;
fn info(&self, ctx: &Context) -> Info {
let nick = nick::mention(&ctx.joined.session.name);
Some(format!("{}{} @{nick} - {inner}", self.prefix, self.name))
self.inner
.info(ctx)
.with_prepended_trigger(format!("{}{} @{nick}", self.prefix, self.name))
}
async fn execute(
@ -174,26 +173,22 @@ where
arg: &str,
msg: &Message,
ctx: &Context,
bot: &mut B,
) -> Result<bool, E> {
// TODO Replace with let-else
let (name, rest) = match parse_prefix_initiated(arg, &self.prefix) {
Some(parsed) => parsed,
None => return Ok(false),
bot: &B,
) -> Result<Propagate, E> {
let Some((name, rest)) = parse_prefix_initiated(arg, &self.prefix) else {
return Ok(Propagate::Yes);
};
if name != self.name {
return Ok(false);
return Ok(Propagate::Yes);
}
// TODO Replace with let-else
let (nick, rest) = match parse_prefix_initiated(rest, "@") {
Some(parsed) => parsed,
None => return Ok(false),
let Some((nick, rest)) = parse_prefix_initiated(rest, "@") else {
return Ok(Propagate::Yes);
};
if nick::normalize(nick) != nick::normalize(&ctx.joined.session.name) {
return Ok(false);
return Ok(Propagate::Yes);
}
self.inner.execute(rest, msg, ctx, bot).await

View file

@ -0,0 +1,113 @@
//! Basic command wrappers.
use async_trait::async_trait;
use euphoxide::api::Message;
use super::{Command, Context, Info, Propagate};
/// Rewrite or hide command info.
pub struct Described<C> {
pub inner: C,
pub trigger: Option<Option<String>>,
pub description: Option<Option<String>>,
}
impl<C> Described<C> {
pub fn new(inner: C) -> Self {
Self {
inner,
trigger: None,
description: None,
}
}
pub fn hidden(inner: C) -> Self {
Self::new(inner)
.with_trigger_hidden()
.with_description_hidden()
}
pub fn with_trigger(mut self, trigger: impl ToString) -> Self {
self.trigger = Some(Some(trigger.to_string()));
self
}
pub fn with_trigger_hidden(mut self) -> Self {
self.trigger = Some(None);
self
}
pub fn with_description(mut self, description: impl ToString) -> Self {
self.description = Some(Some(description.to_string()));
self
}
pub fn with_description_hidden(mut self) -> Self {
self.description = Some(None);
self
}
}
#[async_trait]
impl<B, E, C> Command<B, E> for Described<C>
where
B: Sync,
C: Command<B, E> + Send + Sync,
{
fn info(&self, ctx: &Context) -> Info {
let info = self.inner.info(ctx);
Info {
trigger: self.trigger.clone().unwrap_or(info.trigger),
description: self.description.clone().unwrap_or(info.description),
}
}
async fn execute(
&self,
arg: &str,
msg: &Message,
ctx: &Context,
bot: &B,
) -> Result<Propagate, E> {
self.inner.execute(arg, msg, ctx, bot).await
}
}
pub struct Prefixed<C> {
prefix: String,
inner: C,
}
impl<C> Prefixed<C> {
pub fn new<S: ToString>(prefix: S, inner: C) -> Self {
Self {
prefix: prefix.to_string(),
inner,
}
}
}
#[async_trait]
impl<B, E, C> Command<B, E> for Prefixed<C>
where
B: Sync,
C: Command<B, E> + Send + Sync,
{
fn info(&self, ctx: &Context) -> Info {
self.inner.info(ctx).with_prepended_trigger(&self.prefix)
}
async fn execute(
&self,
arg: &str,
msg: &Message,
ctx: &Context,
bot: &B,
) -> Result<Propagate, E> {
if let Some(rest) = arg.trim_start().strip_prefix(&self.prefix) {
self.inner.execute(rest, msg, ctx, bot).await
} else {
Ok(Propagate::Yes)
}
}
}

View file

@ -0,0 +1,8 @@
//! The main [botrulez](https://github.com/jedevc/botrulez) commands.
mod full_help;
mod ping;
mod short_help;
mod uptime;
pub use self::{full_help::*, ping::*, short_help::*, uptime::*};

View file

@ -0,0 +1,111 @@
use async_trait::async_trait;
#[cfg(feature = "clap")]
use clap::Parser;
use euphoxide::api::Message;
#[cfg(feature = "clap")]
use crate::command::clap::ClapCommand;
use crate::command::{Command, Context, Info, Propagate};
pub trait HasCommandInfos {
fn command_infos(&self, ctx: &Context) -> Vec<Info>;
}
#[derive(Default)]
pub struct FullHelp {
pub before: String,
pub after: String,
}
impl FullHelp {
pub fn new() -> Self {
Self::default()
}
pub fn with_before(mut self, before: impl ToString) -> Self {
self.before = before.to_string();
self
}
pub fn with_after(mut self, after: impl ToString) -> Self {
self.after = after.to_string();
self
}
fn formulate_reply<B: HasCommandInfos>(&self, ctx: &Context, bot: &B) -> String {
let mut result = String::new();
if !self.before.is_empty() {
result.push_str(&self.before);
result.push('\n');
}
for info in bot.command_infos(ctx) {
if let Some(trigger) = &info.trigger {
result.push_str(trigger);
if let Some(description) = &info.description {
result.push_str(" - ");
result.push_str(description);
}
result.push('\n');
}
}
if !self.after.is_empty() {
result.push_str(&self.after);
result.push('\n');
}
result
}
}
#[async_trait]
impl<B, E> Command<B, E> for FullHelp
where
B: HasCommandInfos + Sync,
E: From<euphoxide::Error>,
{
async fn execute(
&self,
arg: &str,
msg: &Message,
ctx: &Context,
bot: &B,
) -> Result<Propagate, E> {
if arg.trim().is_empty() {
let reply = self.formulate_reply(ctx, bot);
ctx.reply_only(msg.id, reply).await?;
Ok(Propagate::No)
} else {
Ok(Propagate::Yes)
}
}
}
/// Show full bot help.
#[cfg(feature = "clap")]
#[derive(Parser)]
pub struct FullHelpArgs {}
#[cfg(feature = "clap")]
#[async_trait]
impl<B, E> ClapCommand<B, E> for FullHelp
where
B: HasCommandInfos + Sync,
E: From<euphoxide::Error>,
{
type Args = FullHelpArgs;
async fn execute(
&self,
_args: Self::Args,
msg: &Message,
ctx: &Context,
bot: &B,
) -> Result<Propagate, E> {
let reply = self.formulate_reply(ctx, bot);
ctx.reply_only(msg.id, reply).await?;
Ok(Propagate::No)
}
}

View file

@ -1,9 +1,11 @@
use async_trait::async_trait;
#[cfg(feature = "clap")]
use clap::Parser;
use euphoxide::api::Message;
use crate::api::Message;
use crate::bot::command::{ClapCommand, Command, Context};
use crate::conn;
#[cfg(feature = "clap")]
use crate::command::clap::ClapCommand;
use crate::command::{Command, Context, Propagate};
pub struct Ping(pub String);
@ -22,43 +24,45 @@ impl Default for Ping {
#[async_trait]
impl<B, E> Command<B, E> for Ping
where
E: From<conn::Error>,
E: From<euphoxide::Error>,
{
async fn execute(
&self,
arg: &str,
msg: &Message,
ctx: &Context,
_bot: &mut B,
) -> Result<bool, E> {
_bot: &B,
) -> Result<Propagate, E> {
if arg.trim().is_empty() {
ctx.reply(msg.id, &self.0).await?;
Ok(true)
ctx.reply_only(msg.id, &self.0).await?;
Ok(Propagate::No)
} else {
Ok(false)
Ok(Propagate::Yes)
}
}
}
/// Trigger a short reply.
#[cfg(feature = "clap")]
#[derive(Parser)]
pub struct Args {}
pub struct PingArgs {}
#[cfg(feature = "clap")]
#[async_trait]
impl<B, E> ClapCommand<B, E> for Ping
where
E: From<conn::Error>,
E: From<euphoxide::Error>,
{
type Args = Args;
type Args = PingArgs;
async fn execute(
&self,
_args: Self::Args,
msg: &Message,
ctx: &Context,
_bot: &mut B,
) -> Result<bool, E> {
ctx.reply(msg.id, &self.0).await?;
Ok(true)
_bot: &B,
) -> Result<Propagate, E> {
ctx.reply_only(msg.id, &self.0).await?;
Ok(Propagate::No)
}
}

View file

@ -1,9 +1,11 @@
use async_trait::async_trait;
#[cfg(feature = "clap")]
use clap::Parser;
use euphoxide::api::Message;
use crate::api::Message;
use crate::bot::command::{ClapCommand, Command, Context};
use crate::conn;
#[cfg(feature = "clap")]
use crate::command::clap::ClapCommand;
use crate::command::{Command, Context, Propagate};
pub struct ShortHelp(pub String);
@ -16,43 +18,45 @@ impl ShortHelp {
#[async_trait]
impl<B, E> Command<B, E> for ShortHelp
where
E: From<conn::Error>,
E: From<euphoxide::Error>,
{
async fn execute(
&self,
arg: &str,
msg: &Message,
ctx: &Context,
_bot: &mut B,
) -> Result<bool, E> {
_bot: &B,
) -> Result<Propagate, E> {
if arg.trim().is_empty() {
ctx.reply(msg.id, &self.0).await?;
Ok(true)
ctx.reply_only(msg.id, &self.0).await?;
Ok(Propagate::No)
} else {
Ok(false)
Ok(Propagate::Yes)
}
}
}
/// Show short bot help.
#[cfg(feature = "clap")]
#[derive(Parser)]
pub struct Args {}
pub struct ShortHelpArgs {}
#[cfg(feature = "clap")]
#[async_trait]
impl<B, E> ClapCommand<B, E> for ShortHelp
where
E: From<conn::Error>,
E: From<euphoxide::Error>,
{
type Args = Args;
type Args = ShortHelpArgs;
async fn execute(
&self,
_args: Self::Args,
msg: &Message,
ctx: &Context,
_bot: &mut B,
) -> Result<bool, E> {
ctx.reply(msg.id, &self.0).await?;
Ok(true)
_bot: &B,
) -> Result<Propagate, E> {
ctx.reply_only(msg.id, &self.0).await?;
Ok(Propagate::No)
}
}

View file

@ -1,10 +1,12 @@
use async_trait::async_trait;
#[cfg(feature = "clap")]
use clap::Parser;
use euphoxide::api::Message;
use jiff::{Span, Timestamp, Unit};
use crate::api::Message;
use crate::bot::command::{ClapCommand, Command, Context};
use crate::conn;
#[cfg(feature = "clap")]
use crate::command::clap::ClapCommand;
use crate::command::{Command, Context, Propagate};
pub fn format_time(t: Timestamp) -> String {
t.strftime("%Y-%m-%d %H:%M:%S UTC").to_string()
@ -83,51 +85,53 @@ impl Uptime {
#[async_trait]
impl<B, E> Command<B, E> for Uptime
where
B: HasStartTime + Send,
E: From<conn::Error>,
B: HasStartTime + Sync,
E: From<euphoxide::Error>,
{
async fn execute(
&self,
arg: &str,
msg: &Message,
ctx: &Context,
bot: &mut B,
) -> Result<bool, E> {
bot: &B,
) -> Result<Propagate, E> {
if arg.trim().is_empty() {
let reply = self.formulate_reply(ctx, bot, false);
ctx.reply(msg.id, reply).await?;
Ok(true)
ctx.reply_only(msg.id, reply).await?;
Ok(Propagate::No)
} else {
Ok(false)
Ok(Propagate::Yes)
}
}
}
/// Show how long the bot has been online.
#[cfg(feature = "clap")]
#[derive(Parser)]
pub struct Args {
pub struct UptimeArgs {
/// Show how long the bot has been connected without interruption.
#[arg(long, short)]
pub connected: bool,
}
#[cfg(feature = "clap")]
#[async_trait]
impl<B, E> ClapCommand<B, E> for Uptime
where
B: HasStartTime + Send,
E: From<conn::Error>,
B: HasStartTime + Sync,
E: From<euphoxide::Error>,
{
type Args = Args;
type Args = UptimeArgs;
async fn execute(
&self,
args: Self::Args,
msg: &Message,
ctx: &Context,
bot: &mut B,
) -> Result<bool, E> {
bot: &B,
) -> Result<Propagate, E> {
let reply = self.formulate_reply(ctx, bot, args.connected);
ctx.reply(msg.id, reply).await?;
Ok(true)
ctx.reply_only(msg.id, reply).await?;
Ok(Propagate::No)
}
}

View file

@ -1,10 +1,10 @@
//! [`clap`]-based commands.
use async_trait::async_trait;
use clap::{CommandFactory, Parser};
use euphoxide::api::Message;
use crate::api::Message;
use crate::conn;
use super::{Command, Context};
use super::{Command, Context, Info, Propagate};
#[async_trait]
pub trait ClapCommand<B, E> {
@ -15,8 +15,8 @@ pub trait ClapCommand<B, E> {
args: Self::Args,
msg: &Message,
ctx: &Context,
bot: &mut B,
) -> Result<bool, E>;
bot: &B,
) -> Result<Propagate, E>;
}
/// Parse bash-like quoted arguments separated by whitespace.
@ -101,13 +101,16 @@ pub struct Clap<C>(pub C);
#[async_trait]
impl<B, E, C> Command<B, E> for Clap<C>
where
B: Send,
E: From<conn::Error>,
B: Sync,
E: From<euphoxide::Error>,
C: ClapCommand<B, E> + Send + Sync,
C::Args: Parser + Send,
{
fn description(&self, _ctx: &Context) -> Option<String> {
C::Args::command().get_about().map(|s| format!("{s}"))
fn info(&self, _ctx: &Context) -> Info {
Info {
description: C::Args::command().get_about().map(|s| s.to_string()),
..Info::default()
}
}
async fn execute(
@ -115,13 +118,13 @@ where
arg: &str,
msg: &Message,
ctx: &Context,
bot: &mut B,
) -> Result<bool, E> {
bot: &B,
) -> Result<Propagate, E> {
let mut args = match parse_quoted_args(arg) {
Ok(args) => args,
Err(err) => {
ctx.reply(msg.id, err).await?;
return Ok(true);
ctx.reply_only(msg.id, err).await?;
return Ok(Propagate::No);
}
};
@ -132,8 +135,8 @@ where
let args = match C::Args::try_parse_from(args) {
Ok(args) => args,
Err(err) => {
ctx.reply(msg.id, format!("{}", err.render())).await?;
return Ok(true);
ctx.reply_only(msg.id, format!("{}", err.render())).await?;
return Ok(Propagate::No);
}
};

View file

@ -0,0 +1,451 @@
use std::{
fmt, result,
str::FromStr,
sync::{Arc, Mutex},
time::Duration,
};
use cookie::{Cookie, CookieJar};
use euphoxide::{
api::{Auth, AuthOption, BounceEvent, Data, Nick, ParsedPacket},
client::{
conn::{ClientConn, ClientConnConfig, ClientConnHandle},
state::State,
},
};
use log::warn;
use tokio::{
select,
sync::{mpsc, oneshot},
};
use tokio_tungstenite::tungstenite::{
self,
http::{HeaderValue, StatusCode},
};
enum Error {
Stopped,
NoReferences,
AuthRequired,
InvalidPassword,
OutOfJoinAttempts,
Euphoxide(euphoxide::Error),
}
impl Error {
fn is_fatal(&self) -> bool {
match self {
Self::Stopped => true,
Self::NoReferences => true,
Self::AuthRequired => true,
Self::InvalidPassword => true,
Self::OutOfJoinAttempts => true,
Self::Euphoxide(euphoxide::Error::Tungstenite(tungstenite::Error::Http(response))) => {
response.status() == StatusCode::NOT_FOUND
}
_ => false,
}
}
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Stopped => write!(f, "the instance was stopped manually"),
Self::NoReferences => write!(f, "all references to the instance were dropped"),
Self::AuthRequired => write!(f, "authentication required but no credentials found"),
Self::InvalidPassword => write!(f, "authentication required but password is invalid"),
Self::OutOfJoinAttempts => write!(f, "failed to join within attempt limit"),
Self::Euphoxide(error) => write!(f, "{error}"),
}
}
}
impl From<euphoxide::Error> for Error {
fn from(value: euphoxide::Error) -> Self {
Self::Euphoxide(value)
}
}
type Result<T> = result::Result<T, Error>;
enum Command {
GetConn(oneshot::Sender<ClientConnHandle>),
Stop,
}
#[derive(Debug)]
pub enum InstanceEvent {
Started {
id: usize,
},
Connecting {
id: usize,
},
Connected {
id: usize,
conn: ClientConnHandle,
state: State,
},
Joined {
id: usize,
conn: ClientConnHandle,
state: State,
},
Packet {
id: usize,
conn: ClientConnHandle,
state: State,
packet: ParsedPacket,
},
Disconnected {
id: usize,
},
Stopped {
id: usize,
},
}
impl InstanceEvent {
pub fn id(&self) -> usize {
match self {
Self::Started { id } => *id,
Self::Connecting { id } => *id,
Self::Connected { id, .. } => *id,
Self::Joined { id, .. } => *id,
Self::Packet { id, .. } => *id,
Self::Disconnected { id } => *id,
Self::Stopped { id } => *id,
}
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct ServerConfig {
pub client: ClientConnConfig,
pub cookies: Arc<Mutex<CookieJar>>,
pub join_attempts: usize,
pub reconnect_delay: Duration,
pub cmd_channel_bufsize: usize,
}
impl ServerConfig {
pub fn instance(self, room: impl ToString) -> InstanceConfig {
InstanceConfig {
server: self,
room: room.to_string(),
human: false,
username: None,
force_username: false,
password: None,
}
}
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
client: ClientConnConfig::default(),
cookies: Arc::new(Mutex::new(CookieJar::new())),
join_attempts: 5,
reconnect_delay: Duration::from_secs(30),
cmd_channel_bufsize: 1,
}
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct InstanceConfig {
pub server: ServerConfig,
pub room: String,
pub human: bool,
pub username: Option<String>,
pub force_username: bool,
pub password: Option<String>,
}
impl InstanceConfig {
pub fn with_username(mut self, username: impl ToString) -> Self {
self.username = Some(username.to_string());
self
}
pub fn with_force_username(mut self, enabled: bool) -> Self {
self.force_username = enabled;
self
}
pub fn with_password(mut self, password: impl ToString) -> Self {
self.password = Some(password.to_string());
self
}
}
struct InstanceTask {
id: usize,
config: InstanceConfig,
cmd_rx: mpsc::Receiver<Command>,
event_tx: mpsc::Sender<InstanceEvent>,
attempts: usize,
never_joined: bool,
}
impl InstanceTask {
fn get_cookies(&self) -> Option<HeaderValue> {
self.config
.server
.cookies
.lock()
.unwrap()
.iter()
.map(|c| c.stripped().to_string())
.collect::<Vec<_>>()
.join("; ")
.try_into()
.ok()
}
fn set_cookies(&mut self, cookies: &[HeaderValue]) {
let mut guard = self.config.server.cookies.lock().unwrap();
for cookie in cookies {
if let Ok(cookie) = cookie.to_str() {
if let Ok(cookie) = Cookie::from_str(cookie) {
guard.add(cookie);
}
}
}
}
async fn connect(&mut self) -> Result<ClientConn> {
let (conn, cookies) = ClientConn::connect_with_config(
&self.config.room,
self.get_cookies(),
&self.config.server.client,
)
.await?;
self.set_cookies(&cookies);
Ok(conn)
}
async fn on_joined(&mut self, conn: &ClientConn) {
self.never_joined = false;
let _ = self
.event_tx
.send(InstanceEvent::Joined {
id: self.id,
conn: conn.handle(),
state: conn.state().clone(),
})
.await;
}
async fn on_packet(&mut self, conn: &mut ClientConn, packet: ParsedPacket) -> Result<()> {
let _ = self
.event_tx
.send(InstanceEvent::Packet {
id: self.id,
conn: conn.handle(),
state: conn.state().clone(),
packet: packet.clone(),
})
.await;
match packet.into_data()? {
// Attempting to authenticate
Data::BounceEvent(BounceEvent {
auth_options: Some(auth_options),
..
}) if auth_options.contains(&AuthOption::Passcode) => {
if let Some(password) = &self.config.password {
conn.send(Auth {
r#type: AuthOption::Passcode,
passcode: Some(password.clone()),
})
.await?;
} else {
return Err(Error::AuthRequired);
}
}
// Auth attempt failed :(
Data::AuthReply(ev) if !ev.success => return Err(Error::InvalidPassword),
// Just joined
Data::SnapshotEvent(ev) => {
if let Some(username) = &self.config.username {
if ev.nick.is_none() || self.config.force_username {
conn.send(Nick {
name: username.clone(),
})
.await?;
}
}
// Maybe we should only count this as joining if we successfully
// updated the nick instead of just sending a Nick command? And
// maybe we should ensure that we're in the State::Joined state?
// Both of these would probably complicate the code a lot. On
// the other hand, InstanceEvent::Joined::state would contain
// the actual nick after joining, which feels like the right
// thing to do™. Probably not worth the increase in code
// complexity though.
self.on_joined(conn).await;
}
_ => {}
}
Ok(())
}
async fn on_cmd(&mut self, conn: &ClientConn, cmd: Command) -> Result<()> {
match cmd {
Command::GetConn(sender) => {
let _ = sender.send(conn.handle());
Ok(())
}
Command::Stop => Err(Error::Stopped),
}
}
async fn run_once(&mut self) -> Result<()> {
// If we try to connect too many times without managing to join at least
// once, the room is probably not accessible for one reason or another
// and the instance should stop.
self.attempts += 1;
if self.never_joined && self.attempts > self.config.server.join_attempts {
return Err(Error::OutOfJoinAttempts);
}
let _ = self
.event_tx
.send(InstanceEvent::Connecting { id: self.id })
.await;
let mut conn = match self.connect().await {
Ok(conn) => conn,
Err(err) => {
// When we fail to connect, we want to wait a bit before
// reconnecting in order not to spam the server. However, when
// we are connected successfully and then disconnect for
// whatever reason, we want to try to reconnect immediately. We
// might, for example, be disconnected from the server because
// we just logged in.
tokio::time::sleep(self.config.server.reconnect_delay).await;
Err(err)?
}
};
let _ = self
.event_tx
.send(InstanceEvent::Connected {
id: self.id,
conn: conn.handle(),
state: conn.state().clone(),
})
.await;
let result = loop {
let received = select! {
r = conn.recv() => Ok(r?),
r = self.cmd_rx.recv() => Err(r),
};
match received {
// We received a packet
Ok(None) => break Ok(()), // Connection closed
Ok(Some(packet)) => self.on_packet(&mut conn, packet).await?,
// We received a command
Err(None) => break Err(Error::NoReferences),
Err(Some(cmd)) => self.on_cmd(&conn, cmd).await?,
};
};
let _ = self
.event_tx
.send(InstanceEvent::Disconnected { id: self.id })
.await;
result
}
async fn run(mut self) {
let _ = self
.event_tx
.send(InstanceEvent::Started { id: self.id })
.await;
loop {
if let Err(err) = self.run_once().await {
warn!("instance {:?}: {err}", self.id);
if err.is_fatal() {
break;
}
}
}
let _ = self
.event_tx
.send(InstanceEvent::Stopped { id: self.id })
.await;
}
}
#[derive(Clone)]
pub struct Instance {
id: usize,
cmd_tx: mpsc::Sender<Command>,
}
impl fmt::Debug for Instance {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Instance")
.field("id", &self.id)
.finish_non_exhaustive()
}
}
impl Instance {
pub fn new(id: usize, config: InstanceConfig, event_tx: mpsc::Sender<InstanceEvent>) -> Self {
let (cmd_tx, cmd_rx) = mpsc::channel(config.server.cmd_channel_bufsize);
let task = InstanceTask {
id,
config,
attempts: 0,
never_joined: false,
cmd_rx,
event_tx,
};
tokio::task::spawn(task.run());
Self { id, cmd_tx }
}
pub fn id(&self) -> usize {
self.id
}
pub fn stopped(&self) -> bool {
self.cmd_tx.is_closed()
}
pub async fn stop(&self) {
let _ = self.cmd_tx.send(Command::Stop).await;
}
pub async fn handle(&self) -> Option<ClientConnHandle> {
let (tx, rx) = oneshot::channel();
let _ = self.cmd_tx.send(Command::GetConn(tx)).await;
rx.await.ok()
}
}

3
euphoxide-bot/src/lib.rs Normal file
View file

@ -0,0 +1,3 @@
pub mod bot;
pub mod command;
pub mod instance;

24
euphoxide/Cargo.toml Normal file
View file

@ -0,0 +1,24 @@
[package]
name = "euphoxide"
edition = { workspace = true }
version = { workspace = true }
[dependencies]
caseless = { workspace = true }
futures-util = { workspace = true }
jiff = { workspace = true }
log = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
tokio-tungstenite = { workspace = true }
unicode-normalization = { workspace = true }
[dev-dependencies]
anyhow = { workspace = true }
rustls = { workspace = true }
tokio = { workspace = true, features = ["full"] }
tokio-tungstenite = { workspace = true, features = ["rustls-tls-native-roots"] }
[lints]
workspace = true

View file

@ -0,0 +1,90 @@
use std::time::Duration;
use euphoxide::{
api::{Data, Message, Nick, Send},
client::conn::{ClientConn, ClientConnHandle},
};
async fn set_nick(conn: &ClientConnHandle) -> anyhow::Result<()> {
conn.send_only(Nick {
name: "examplebot".to_string(),
})
.await?;
Ok(())
}
async fn send_pong(conn: &ClientConnHandle, msg: Message) -> anyhow::Result<()> {
conn.send_only(Send {
content: "Pong!".to_string(),
parent: Some(msg.id),
})
.await?;
Ok(())
}
async fn send_pyramid(conn: &ClientConnHandle, msg: Message) -> anyhow::Result<()> {
let mut parent = msg.id;
for _ in 0..3 {
let first = conn
.send(Send {
content: "brick".to_string(),
parent: Some(parent),
})
.await?;
conn.send_only(Send {
content: "brick".to_string(),
parent: Some(parent),
})
.await?;
parent = first.await?.0.id;
tokio::time::sleep(Duration::from_secs(1)).await;
}
conn.send_only(Send {
content: "brick".to_string(),
parent: Some(parent),
})
.await?;
Ok(())
}
async fn on_data(conn: ClientConnHandle, data: Data) {
let result = match data {
Data::SnapshotEvent(_) => set_nick(&conn).await,
Data::SendEvent(event) if event.0.content == "!ping" => send_pong(&conn, event.0).await,
Data::SendEvent(event) if event.0.content == "!pyramid" => {
send_pyramid(&conn, event.0).await
}
_ => Ok(()),
};
if let Err(err) = result {
println!("Error while responding: {err}");
}
}
async fn run() -> anyhow::Result<()> {
let (mut conn, _) = ClientConn::connect("test", None).await?;
while let Some(packet) = conn.recv().await? {
let data = packet.into_data()?;
tokio::task::spawn(on_data(conn.handle(), data));
}
Ok(())
}
#[tokio::main]
async fn main() {
loop {
if let Err(err) = run().await {
println!("Error while running: {err}");
}
}
}

12
euphoxide/src/api.rs Normal file
View file

@ -0,0 +1,12 @@
//! Models the [euphoria.leet.nu API][0].
//!
//! [0]: https://euphoria.leet.nu/heim/api
pub mod account_cmds;
pub mod events;
pub mod packets;
pub mod room_cmds;
pub mod session_cmds;
pub mod types;
pub use self::{account_cmds::*, events::*, packets::*, room_cmds::*, session_cmds::*, types::*};

View file

@ -1,8 +1,10 @@
//! Account commands.
//! Models [account commands][0] and their replies.
//!
//! These commands enable a client to register, associate, and dissociate with
//! an account. An account allows an identity to be shared across browsers and
//! devices, and is a prerequisite for room management
//!
//! [0]: https://euphoria.leet.nu/heim/api#account-commands
use serde::{Deserialize, Serialize};
@ -11,6 +13,8 @@ use super::AccountId;
/// Change the primary email address associated with the signed in account.
///
/// The email address may need to be verified before the change is fully applied.
///
/// <https://euphoria.leet.nu/heim/api#change-email>
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChangeEmail {
/// The new primary email address for the account.
@ -32,6 +36,8 @@ pub struct ChangeEmailReply {
}
/// Change the name associated with the signed in account.
///
/// <https://euphoria.leet.nu/heim/api#change-name>
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChangeName {
/// The name to associate with the account.
@ -46,6 +52,8 @@ pub struct ChangeNameReply {
}
/// Change the password of the signed in account.
///
/// <https://euphoria.leet.nu/heim/api#change-password>
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChangePassword {
/// The current (and soon-to-be former) password.
@ -65,6 +73,8 @@ pub struct ChangePasswordReply {}
/// If the login succeeds, the client should expect to receive a
/// [`DisconnectEvent`](super::DisconnectEvent) shortly after. The next
/// connection the client makes will be a logged in session.
///
/// <https://euphoria.leet.nu/heim/api#login>
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Login {
/// The namespace of a personal identifier.
@ -98,6 +108,8 @@ pub struct LoginReply {
/// If the logout is successful, the client should expect to receive a
/// [`DisconnectEvent`](super::DisconnectEvent) shortly after. The next
/// connection the client makes will be a logged out session.
///
/// <https://euphoria.leet.nu/heim/api#logout>
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Logout {}
@ -113,6 +125,8 @@ pub struct LogoutReply {}
/// [`DisconnectEvent`](super::DisconnectEvent) shortly after. The next
/// connection the client makes will be a logged in session using the new
/// account.
///
/// <https://euphoria.leet.nu/heim/api#register-account>
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegisterAccount {
/// The namespace of a personal identifier.
@ -145,6 +159,8 @@ pub struct RegisterAccountReply {
///
/// An error will be returned if the account has no unverified email addresses
/// associated with it.
///
/// <https://euphoria.leet.nu/heim/api#resend-verification-email>
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResendVerificationEmail {}
@ -156,6 +172,8 @@ pub struct ResendVerificationEmailReply {}
///
/// An email will be sent to the owner of the given personal identifier, with
/// instructions and a confirmation code for resetting the password.
///
/// <https://euphoria.leet.nu/heim/api#reset-password>
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResetPassword {
pub namespace: String,

View file

@ -1,4 +1,6 @@
//! Asynchronous events.
//! Models [asynchronous events][0].
//!
//! [0]: https://euphoria.leet.nu/heim/api#asynchronous-events
use serde::{Deserialize, Serialize};
@ -8,6 +10,8 @@ use super::{
};
/// Indicates that access to a room is denied.
///
/// <https://euphoria.leet.nu/heim/api#bounce-event>
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BounceEvent {
/// The reason why access was denied.
@ -25,16 +29,37 @@ pub struct BounceEvent {
///
/// If the disconnect reason is `authentication changed`, the client should
/// immediately reconnect.
///
/// <https://euphoria.leet.nu/heim/api#disconnect-event>
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DisconnectEvent {
/// The reason for disconnection.
pub reason: String,
}
/// Indicates that a message in the room has been modified or deleted.
///
/// If the client offers a user interface and the indicated message is currently
/// displayed, it should update its display accordingly.
///
/// The event packet includes a snapshot of the message post-edit.
///
/// <https://euphoria.leet.nu/heim/api#edit-message-event>
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EditMessageEvent {
/// The id of the edit.
pub edit_id: Snowflake,
/// The snapshot of the message post-edit.
#[serde(flatten)]
pub message: Message,
}
/// Sent by the server to the client when a session is started.
///
/// It includes information about the client's authentication and associated
/// identity.
///
/// <https://euphoria.leet.nu/heim/api#hello-event>
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HelloEvent {
/// The id of the agent or account logged into this session.
@ -55,11 +80,15 @@ pub struct HelloEvent {
}
/// Indicates a session just joined the room.
///
/// <https://euphoria.leet.nu/heim/api#join-event>
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JoinEvent(pub SessionView);
/// Sent to all sessions of an agent when that agent is logged in (except for
/// the session that issued the login command).
///
/// <https://euphoria.leet.nu/heim/api#login-event>
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoginEvent {
pub account_id: AccountId,
@ -67,6 +96,8 @@ pub struct LoginEvent {
/// Sent to all sessions of an agent when that agent is logged out (except for
/// the session that issued the logout command).
///
/// <https://euphoria.leet.nu/heim/api#logout-event>
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LogoutEvent {}
@ -75,6 +106,8 @@ pub struct LogoutEvent {}
///
/// If the network event type is `partition`, then this should be treated as a
/// [`PartEvent`] for all sessions connected to the same server id/era combo.
///
/// <https://euphoria.leet.nu/heim/api#network-event>
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NetworkEvent {
/// The type of network event; for now, always `partition`.
@ -86,6 +119,8 @@ pub struct NetworkEvent {
}
/// Announces a nick change by another session in the room.
///
/// <https://euphoria.leet.nu/heim/api#nick-event>
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NickEvent {
/// The id of the session this name applies to.
@ -98,22 +133,9 @@ pub struct NickEvent {
pub to: String,
}
/// Indicates that a message in the room has been modified or deleted.
///
/// If the client offers a user interface and the indicated message is currently
/// displayed, it should update its display accordingly.
///
/// The event packet includes a snapshot of the message post-edit.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EditMessageEvent {
/// The id of the edit.
pub edit_id: Snowflake,
/// The snapshot of the message post-edit.
#[serde(flatten)]
pub message: Message,
}
/// Indicates a session just disconnected from the room.
///
/// <https://euphoria.leet.nu/heim/api#part-event>
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PartEvent(pub SessionView);
@ -121,6 +143,8 @@ pub struct PartEvent(pub SessionView);
///
/// The client should send back a ping-reply with the same value for the time
/// field as soon as possible (or risk disconnection).
///
/// <https://euphoria.leet.nu/heim/api#ping-event>
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PingEvent {
/// A unix timestamp according to the server's clock.
@ -131,6 +155,8 @@ pub struct PingEvent {
}
/// Informs the client that another user wants to chat with them privately.
///
/// <https://euphoria.leet.nu/heim/api#pm-initiate-event>
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PmInitiateEvent {
/// The id of the user inviting the client to chat privately.
@ -144,12 +170,16 @@ pub struct PmInitiateEvent {
}
/// Indicates a message received by the room from another session.
///
/// <https://euphoria.leet.nu/heim/api#send-event>
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SendEvent(pub Message);
/// Indicates that a session has successfully joined a room.
///
/// It also offers a snapshot of the rooms state and recent history.
///
/// <https://euphoria.leet.nu/heim/api#snapshot-event>
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SnapshotEvent {
/// The id of the agent or account logged into this session.
@ -158,7 +188,7 @@ pub struct SnapshotEvent {
pub session_id: SessionId,
/// The servers version identifier.
pub version: String,
/// The list of all other sessions joined to the room (excluding this
/// The list of all other sessions joined to the room (excluding our
/// session).
pub listing: Vec<SessionView>,
/// The most recent messages posted to the room (currently up to 100).

View file

@ -0,0 +1,319 @@
//! Models the [packets][0] sent between the server and client.
//!
//! [0]: https://euphoria.leet.nu/heim/api#packets
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::Error;
use super::PacketType;
/// A "raw" packet.
///
/// This packet closely matches the [packet representation defined in the
/// API][0]. It can contain arbitrary data in the form of a JSON [`Value`]. It
/// can also contain both data and an error at the same time.
///
/// In order to interpret this packet, you probably want to convert it to a
/// [`ParsedPacket`] using [`ParsedPacket::from_packet`].
///
/// [0]: https://euphoria.leet.nu/heim/api#packets
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Packet {
/// Client-generated id for associating replies with commands.
pub id: Option<String>,
/// The type of the command, reply, or event.
pub r#type: PacketType,
/// The payload of the command, reply, or event.
pub data: Option<Value>,
/// This field appears in replies if a command fails.
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
/// This field appears in replies to warn the client that it may be
/// flooding.
///
/// The client should slow down its command rate.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub throttled: bool,
/// If throttled is true, this field describes why.
#[serde(skip_serializing_if = "Option::is_none")]
pub throttled_reason: Option<String>,
}
/// Models the relationship between command and reply types.
///
/// This trait is useful for type-safe command-reply APIs.
pub trait Command {
/// The type of reply one can expect from the server when sending this
/// command.
type Reply;
}
macro_rules! packets {
( $( $mod:ident::$name:ident, )*) => {
/// A big enum containing most types of packet data.
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum Data {
$( $name(super::$mod::$name), )*
/// A valid type of packet data that this library does not model as
/// a struct.
Unimplemented(PacketType, Value),
}
impl Data {
/// Interpret a JSON [`Value`] as packet data of a specific [`PacketType`].
///
/// This method may fail if the data is invalid.
pub fn from_value(ptype: PacketType, value: Value) -> serde_json::Result<Self> {
Ok(match ptype {
$( PacketType::$name => Self::$name(serde_json::from_value(value)?), )*
_ => Self::Unimplemented(ptype, value),
})
}
/// Convert the packet data into a JSON [`Value`].
///
/// This method may fail if the data fails to serialize.
pub fn into_value(self) -> serde_json::Result<Value> {
Ok(match self {
$( Self::$name(p) => serde_json::to_value(p)?, )*
Self::Unimplemented(_, value) => value,
})
}
/// The [`PacketType`] of this packet data.
pub fn packet_type(&self) -> PacketType {
match self {
$( Self::$name(_) => PacketType::$name, )*
Self::Unimplemented(ptype, _) => *ptype,
}
}
}
$(
impl From<super::$mod::$name> for Data {
fn from(p: super::$mod::$name) -> Self {
Self::$name(p)
}
}
impl TryFrom<Data> for super::$mod::$name{
type Error = ();
fn try_from(value: Data) -> Result<Self, Self::Error> {
match value {
Data::$name(p) => Ok(p),
_ => Err(())
}
}
}
)*
};
}
macro_rules! commands {
( $( $cmd:ident => $rpl:ident, )* ) => {
$(
impl Command for super::$cmd {
type Reply = super::$rpl;
}
)*
};
}
packets! {
// Events
events::BounceEvent,
events::DisconnectEvent,
events::EditMessageEvent,
events::HelloEvent,
events::JoinEvent,
events::LoginEvent,
events::LogoutEvent,
events::NetworkEvent,
events::NickEvent,
events::PartEvent,
events::PingEvent,
events::PmInitiateEvent,
events::SendEvent,
events::SnapshotEvent,
// Session commands
session_cmds::Auth,
session_cmds::AuthReply,
session_cmds::Ping,
session_cmds::PingReply,
// Chat room commands
room_cmds::GetMessage,
room_cmds::GetMessageReply,
room_cmds::Log,
room_cmds::LogReply,
room_cmds::Nick,
room_cmds::NickReply,
room_cmds::PmInitiate,
room_cmds::PmInitiateReply,
room_cmds::Send,
room_cmds::SendReply,
room_cmds::Who,
room_cmds::WhoReply,
// Account commands
account_cmds::ChangeEmail,
account_cmds::ChangeEmailReply,
account_cmds::ChangeName,
account_cmds::ChangeNameReply,
account_cmds::ChangePassword,
account_cmds::ChangePasswordReply,
account_cmds::Login,
account_cmds::LoginReply,
account_cmds::Logout,
account_cmds::LogoutReply,
account_cmds::RegisterAccount,
account_cmds::RegisterAccountReply,
account_cmds::ResendVerificationEmail,
account_cmds::ResendVerificationEmailReply,
account_cmds::ResetPassword,
account_cmds::ResetPasswordReply,
}
commands! {
// Session commands
Auth => AuthReply,
Ping => PingReply,
// Chat room commands
GetMessage => GetMessageReply,
Log => LogReply,
Nick => NickReply,
PmInitiate => PmInitiateReply,
Send => SendReply,
Who => WhoReply,
// Account commands
ChangeEmail => ChangeEmailReply,
ChangeName => ChangeNameReply,
ChangePassword => ChangePasswordReply,
Login => LoginReply,
Logout => LogoutReply,
RegisterAccount => RegisterAccountReply,
ResendVerificationEmail => ResendVerificationEmailReply,
ResetPassword => ResetPasswordReply,
}
/// A fully parsed and interpreted packet.
///
/// Compared to [`Packet`], this packet's representation more closely matches
/// the actual use of packets.
#[derive(Debug, Clone)]
pub struct ParsedPacket {
/// Client-generated id for associating replies with commands.
pub id: Option<String>,
/// The type of the command, reply, or event.
pub r#type: PacketType,
/// The payload of the command, reply, or event, or an error message if the
/// command failed.
pub content: Result<Data, String>,
/// A warning to the client that it may be flooding.
///
/// The client should slow down its command rate.
pub throttled: Option<String>,
}
impl ParsedPacket {
/// Convert a [`Data`]-compatible value into a [`ParsedPacket`].
pub fn from_data(id: Option<String>, data: impl Into<Data>) -> Self {
let data = data.into();
Self {
id,
r#type: data.packet_type(),
content: Ok(data),
throttled: None,
}
}
pub fn into_data(self) -> crate::Result<Data> {
self.content.map_err(Error::Euph)
}
/// Convert a [`Packet`] into a [`ParsedPacket`].
///
/// This method may fail if the packet data is invalid.
pub fn from_packet(packet: Packet) -> serde_json::Result<Self> {
let id = packet.id;
let r#type = packet.r#type;
let content = if let Some(error) = packet.error {
Err(error)
} else {
let data = packet.data.unwrap_or_default();
Ok(Data::from_value(r#type, data)?)
};
let throttled = if packet.throttled {
let reason = packet
.throttled_reason
.unwrap_or_else(|| "no reason given".to_string());
Some(reason)
} else {
None
};
Ok(Self {
id,
r#type,
content,
throttled,
})
}
/// Convert a [`ParsedPacket`] into a [`Packet`].
///
/// This method may fail if the packet data fails to serialize.
pub fn into_packet(self) -> serde_json::Result<Packet> {
let id = self.id;
let r#type = self.r#type;
let throttled = self.throttled.is_some();
let throttled_reason = self.throttled;
Ok(match self.content {
Ok(data) => Packet {
id,
r#type,
data: Some(data.into_value()?),
error: None,
throttled,
throttled_reason,
},
Err(error) => Packet {
id,
r#type,
data: None,
error: Some(error),
throttled,
throttled_reason,
},
})
}
}
impl TryFrom<Packet> for ParsedPacket {
type Error = serde_json::Error;
fn try_from(value: Packet) -> Result<Self, Self::Error> {
Self::from_packet(value)
}
}
impl TryFrom<ParsedPacket> for Packet {
type Error = serde_json::Error;
fn try_from(value: ParsedPacket) -> Result<Self, Self::Error> {
value.into_packet()
}
}
impl TryFrom<ParsedPacket> for Data {
type Error = Error;
fn try_from(value: ParsedPacket) -> Result<Self, Self::Error> {
value.into_data()
}
}

View file

@ -1,13 +1,17 @@
//! Chat room commands.
//! Models [chat room commands][0] and their replies.
//!
//! These commands are available to the client once a session successfully joins
//! a room.
//!
//! [0]: https://euphoria.leet.nu/heim/api#chat-room-commands
use serde::{Deserialize, Serialize};
use super::{Message, MessageId, PmId, SessionId, SessionView, UserId};
/// Retrieve the full content of a single message in the room.
///
/// <https://euphoria.leet.nu/heim/api#get-message>
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GetMessage {
/// The id of the message to retrieve.
@ -23,6 +27,8 @@ pub struct GetMessageReply(pub Message);
/// This can be used to supplement the log provided by
/// [`SnapshotEvent`](super::SnapshotEvent) (for example, when scrolling back
/// further in history).
///
/// <https://euphoria.leet.nu/heim/api#log>
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Log {
/// Maximum number of messages to return (up to 1000).
@ -44,6 +50,8 @@ pub struct LogReply {
///
/// This name applies to all messages sent during this session, until the nick
/// command is called again.
///
/// <https://euphoria.leet.nu/heim/api#nick>
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Nick {
/// The requested name (maximum length 36 bytes).
@ -68,6 +76,8 @@ pub struct NickReply {
/// Constructs a virtual room for private messaging between the client and the
/// given [`UserId`].
///
/// <https://euphoria.leet.nu/heim/api#pm-initiate>
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PmInitiate {
/// The id of the user to invite to chat privately.
@ -94,6 +104,8 @@ pub struct PmInitiateReply {
/// The caller of this command will not receive the corresponding
/// [`SendEvent`](super::SendEvent), but will receive the same information in
/// the [`SendReply`].
///
/// <https://euphoria.leet.nu/heim/api#send>
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Send {
/// The content of the message (client-defined).
@ -109,12 +121,14 @@ pub struct Send {
pub struct SendReply(pub Message);
/// Request a list of sessions currently joined in the room.
///
/// <https://euphoria.leet.nu/heim/api#who>
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Who {}
/// Lists the sessions currently joined in the room.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WhoReply {
/// A list of session views.
/// A list of session views (including our session).
pub listing: Vec<SessionView>,
}

View file

@ -1,7 +1,9 @@
//! Session commands.
//! Models [session commands][0] and their replies.
//!
//! Session management commands are involved in the initial handshake and
//! maintenance of a session.
//!
//! [0]: https://euphoria.leet.nu/heim/api#session-commands
use serde::{Deserialize, Serialize};
@ -11,6 +13,8 @@ use super::{AuthOption, Time};
///
/// This should be sent in response to a [`BounceEvent`](super::BounceEvent) at
/// the beginning of a session.
///
/// <https://euphoria.leet.nu/heim/api#auth>
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Auth {
/// The method of authentication.
@ -32,6 +36,8 @@ pub struct AuthReply {
///
/// The server will send back a [`PingReply`] with the same timestamp as soon as
/// possible.
///
/// <https://euphoria.leet.nu/heim/api#ping>
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Ping {
/// An arbitrary value, intended to be a unix timestamp.

View file

@ -1,20 +1,16 @@
//! Field types.
//! Models the [field types][0].
//!
//! [0]: https://euphoria.leet.nu/heim/api#field-types
// TODO Add newtype wrappers for different kinds of IDs?
// Serde's derive macros generate this warning and I can't turn it off locally,
// so I'm turning it off for the entire module.
#![allow(clippy::use_self)]
use std::num::ParseIntError;
use std::str::FromStr;
use std::{error, fmt};
use std::{error, fmt, num::ParseIntError, str::FromStr};
use jiff::Timestamp;
use serde::{de, ser, Deserialize, Serialize};
use serde_json::Value;
/// Describes an account and its preferred name.
///
/// <https://euphoria.leet.nu/heim/api#accountview>
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccountView {
/// The id of the account.
@ -24,7 +20,9 @@ pub struct AccountView {
}
/// Mode of authentication.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
///
/// <https://euphoria.leet.nu/heim/api#authoption>
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum AuthOption {
/// Authentication with a passcode, where a key is derived from the passcode
@ -36,6 +34,8 @@ pub enum AuthOption {
///
/// It corresponds to a chat message, or a post, or any broadcasted event in a
/// room that should appear in the log.
///
/// <https://euphoria.leet.nu/heim/api#message>
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Message {
/// The id of the message (unique within a room).
@ -72,6 +72,8 @@ pub struct Message {
/// The type of a packet.
///
/// Not all of these types have their corresponding data modeled as a struct.
///
/// <https://euphoria.leet.nu/heim/api#packettype>
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum PacketType {
@ -250,6 +252,8 @@ impl fmt::Display for PacketType {
}
/// Describes an account to its owner.
///
/// <https://euphoria.leet.nu/heim/api#personalaccountview>
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PersonalAccountView {
/// The id of the account.
@ -261,6 +265,8 @@ pub struct PersonalAccountView {
}
/// Describes a session and its identity.
///
/// <https://euphoria.leet.nu/heim/api#sessionview>
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionView {
/// The id of an agent or account (or bot).
@ -290,6 +296,8 @@ pub struct SessionView {
/// A 13-character string, usually used as aunique identifier for some type of object.
///
/// It is the base-36 encoding of an unsigned, 64-bit integer.
///
/// <https://euphoria.leet.nu/heim/api#snowflake>
#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord)]
pub struct Snowflake(pub u64);
@ -307,7 +315,7 @@ impl Snowflake {
/// representation of message ids to suddenly use the upper parts of the
/// range, and since message ids mostly consist of a timestamp, this
/// approach should last until at least 2075.
pub const MAX: Self = Snowflake(i64::MAX as u64);
pub const MAX: Self = Self(i64::MAX as u64);
}
impl fmt::Display for Snowflake {
@ -324,6 +332,7 @@ impl fmt::Display for Snowflake {
}
}
/// An error that occurred while parsing a [`Snowflake`].
#[derive(Debug)]
pub enum ParseSnowflakeError {
InvalidLength(usize),
@ -365,7 +374,7 @@ impl FromStr for Snowflake {
return Err(ParseSnowflakeError::InvalidLength(s.len()));
}
let n = u64::from_str_radix(s, 36)?;
Ok(Snowflake(n))
Ok(Self(n))
}
}
@ -402,6 +411,8 @@ impl<'de> Deserialize<'de> for Snowflake {
/// Time is specified as a signed 64-bit integer, giving the number of seconds
/// since the Unix Epoch.
///
/// <https://euphoria.leet.nu/heim/api#time>
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Time(pub i64);
@ -426,6 +437,8 @@ impl Time {
///
/// It is possible for this value to have no prefix and colon, and there is no
/// fixed format for the unique value.
///
/// <https://euphoria.leet.nu/heim/api#userid>
#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct UserId(pub String);
@ -435,21 +448,27 @@ impl fmt::Display for UserId {
}
}
/// What kind of user a [`UserId`] is.
#[derive(Debug, PartialEq, Eq)]
pub enum SessionType {
pub enum UserType {
Agent,
Account,
Bot,
}
impl UserId {
pub fn session_type(&self) -> Option<SessionType> {
/// Retrieve the [`UserType`] of this user.
///
/// This method can return [`None`] because user IDs used to have no
/// associated type. Such user IDs can still occur in old room logs, so
/// euphoxide supports them.
pub fn user_type(&self) -> Option<UserType> {
if self.0.starts_with("agent:") {
Some(SessionType::Agent)
Some(UserType::Agent)
} else if self.0.starts_with("account:") {
Some(SessionType::Account)
Some(UserType::Account)
} else if self.0.starts_with("bot:") {
Some(SessionType::Bot)
Some(UserType::Bot)
} else {
None
}

4
euphoxide/src/client.rs Normal file
View file

@ -0,0 +1,4 @@
//! A connection from a client's perspective.
pub mod conn;
pub mod state;

View file

@ -0,0 +1,342 @@
//! Client-specific connection with a more expressive API.
use std::{future::Future, time::Duration};
use log::debug;
use tokio::{
select,
sync::{mpsc, oneshot},
};
use tokio_tungstenite::tungstenite::{
client::IntoClientRequest,
http::{header, HeaderValue},
};
use crate::{
api::{Command, Data, LoginReply, ParsedPacket},
conn::{Conn, ConnConfig, Side},
replies::{self, PendingReply, Replies},
Error, Result,
};
use super::state::State;
enum ConnCommand {
SendCmd(Data, oneshot::Sender<Result<PendingReply<ParsedPacket>>>),
GetState(oneshot::Sender<State>),
}
/// Configuration options for a [`ClientConn`].
#[derive(Debug, Clone)]
pub struct ClientConnConfig {
/// The domain where the server is hosted.
pub domain: String,
/// Whether the client should present itself as a human to the server.
///
/// This should only be set if the client is directly acting on behalf of a
/// human, similar to the web client.
pub human: bool,
/// The size of the [`mpsc::channel`] for communication between
/// [`ClientConn`] and [`ClientConnHandle`].
pub channel_bufsize: usize,
/// Timeout for opening a websocket connection.
pub connect_timeout: Duration,
/// Timeout for server replies when sending euphoria commands, i.e. packets
/// implementing [`Command`].
pub command_timeout: Duration,
/// How long to wait in-between sending pings.
///
/// See also [`ConnConfig::ping_interval`].
pub ping_interval: Duration,
}
impl Default for ClientConnConfig {
fn default() -> Self {
Self {
domain: "euphoria.leet.nu".to_string(),
human: false,
channel_bufsize: 1,
connect_timeout: Duration::from_secs(10),
command_timeout: Duration::from_secs(30),
ping_interval: Duration::from_secs(30),
}
}
}
/// A client connection to an euphoria server.
///
/// This struct is a wrapper around [`Conn`] with a more client-centric API. It
/// tracks the connection state, including room information sent by the server.
/// It also provides [`ClientConnHandle`], which can be used to asynchronously
/// send commands and await their replies.
pub struct ClientConn {
rx: mpsc::Receiver<ConnCommand>,
tx: mpsc::Sender<ConnCommand>,
conn: Conn,
state: State,
next_id: usize,
replies: Replies<String, ParsedPacket>,
}
impl ClientConn {
/// Retrieve the current [`State`] of the connection.
pub fn state(&self) -> &State {
&self.state
}
/// Create a new handle for this connection.
pub fn handle(&self) -> ClientConnHandle {
ClientConnHandle {
tx: self.tx.clone(),
}
}
/// Start closing the connection.
///
/// To finish closing the connection gracefully, continue calling
/// [`Self::recv`] until it returns [`None`].
pub async fn close(&mut self) -> Result<()> {
self.conn.close().await
}
/// Receive a [`ParsedPacket`] over the connection.
///
/// This method also maintains the connection by listening and responding to
/// pings as well as managing [`ClientConnHandle`]s. Thus, it must be called
/// regularly.
///
/// Returns [`None`] if the connection is closed.
pub async fn recv(&mut self) -> Result<Option<ParsedPacket>> {
loop {
self.replies.purge();
// There's always at least one tx end (self.tx), so self.rx.recv()
// should never return None.
let packet = select! {
packet = self.conn.recv() => packet?,
Some(cmd) = self.rx.recv() => {
self.on_cmd(cmd).await;
continue;
},
};
if let Some(packet) = &packet {
self.on_packet(packet).await?;
}
break Ok(packet);
}
}
/// Send a packet over the connection.
///
/// A packet id is automatically generated and returned. When the server
/// replies to the packet, it will use this id as its [`ParsedPacket::id`].
pub async fn send(&mut self, data: impl Into<Data>) -> Result<String> {
let id = self.next_id.to_string();
self.next_id += 1;
self.conn
.send(ParsedPacket::from_data(Some(id.clone()), data.into()))
.await?;
Ok(id)
}
async fn on_packet(&mut self, packet: &ParsedPacket) -> Result<()> {
if let Ok(data) = &packet.content {
self.state.on_data(data);
// The euphoria server doesn't always disconnect the client when it
// would make sense to do so or when the API specifies it should.
// This ensures we always disconnect when it makes sense to do so.
if matches!(
data,
Data::DisconnectEvent(_)
| Data::LoginEvent(_)
| Data::LogoutEvent(_)
| Data::LoginReply(LoginReply { success: true, .. })
| Data::LogoutReply(_)
) {
self.close().await?;
}
}
if let Some(id) = &packet.id {
let id = id.clone();
self.replies.complete(&id, packet.clone());
}
Ok(())
}
async fn on_cmd(&mut self, cmd: ConnCommand) {
match cmd {
ConnCommand::SendCmd(data, sender) => {
let result = self.send(data).await.map(|id| self.replies.wait_for(id));
let _ = sender.send(result);
}
ConnCommand::GetState(sender) => {
let _ = sender.send(self.state.clone());
}
}
}
/// Connect to a room.
///
/// See [`Self::connect_with_config`] for more details.
pub async fn connect(
room: &str,
cookies: Option<HeaderValue>,
) -> Result<(Self, Vec<HeaderValue>)> {
Self::connect_with_config(room, cookies, &ClientConnConfig::default()).await
}
/// Connect to a room with a specific configuration.
///
/// Cookies to be sent to the server can be specified as a [`HeaderValue`]
/// in the format of a [`Cookie` request header][0]. If the connection
/// attempt was successful, cookies set by the server will be returned
/// alongside the connection itself as one [`HeaderValue`] per [`Set-Cookie`
/// response header][1].
///
/// The tasks of cookie parsing and storage are not handled by this library.
///
/// [0]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cookie
/// [1]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie
pub async fn connect_with_config(
room: &str,
cookies: Option<HeaderValue>,
config: &ClientConnConfig,
) -> Result<(Self, Vec<HeaderValue>)> {
// Prepare URL
let human = if config.human { "?h=1" } else { "" };
let uri = format!("wss://{}/room/{room}/ws{human}", config.domain);
debug!("Connecting to {uri} with cookies: {cookies:?}");
// Prepare request
let mut request = uri.into_client_request().expect("valid request");
if let Some(cookies) = cookies {
request.headers_mut().append(header::COOKIE, cookies);
}
// Connect to server
let (ws, response) = tokio::time::timeout(
config.connect_timeout,
tokio_tungstenite::connect_async(request),
)
.await
.map_err(|_| Error::ConnectionTimeout)??;
// Extract response cookies
let (mut parts, _) = response.into_parts();
let cookies_set = match parts.headers.entry(header::SET_COOKIE) {
header::Entry::Occupied(entry) => entry.remove_entry_mult().1.collect(),
header::Entry::Vacant(_) => vec![],
};
debug!("Received cookies {cookies_set:?}");
// Prepare EuphConn
let conn_config = ConnConfig {
ping_interval: config.ping_interval,
};
let conn = Conn::wrap_with_config(ws, Side::Client, conn_config);
// Prepare client
let (tx, rx) = mpsc::channel(config.channel_bufsize);
let client = Self {
rx,
tx,
conn,
state: State::new(),
next_id: 0,
replies: Replies::new(config.command_timeout),
};
Ok((client, cookies_set))
}
}
/// Asynchronous access to a [`ClientConn`].
///
/// Handle methods are only processed while [`ClientConn::recv`] is being
/// called. They may return before they were processed by the associated
/// [`ClientConn`], or they may block until processed. Methods are processed in
/// the order they are called.
///
/// The handle is cheap to clone.
#[derive(Debug, Clone)]
pub struct ClientConnHandle {
tx: mpsc::Sender<ConnCommand>,
}
impl ClientConnHandle {
/// Send a command to the server.
///
/// When awaited, returns either an error if something went wrong while
/// sending the command, or a second future with the server's reply (the
/// *reply future*).
///
/// When awaited, the *reply future* returns either an error if something
/// was wrong with the reply, or the data returned by the server. The *reply
/// future* can be safely ignored and doesn't have to be awaited.
pub async fn send<C>(&self, cmd: C) -> Result<impl Future<Output = Result<C::Reply>>>
where
C: Command + Into<Data>,
C::Reply: TryFrom<Data>,
{
let (tx, rx) = oneshot::channel();
self.tx
.send(ConnCommand::SendCmd(cmd.into(), tx))
.await
.map_err(|_| Error::ConnectionClosed)?;
Ok(async {
let data = rx
.await
.map_err(|_| Error::ConnectionClosed)??
.get()
.await
.map_err(|err| match err {
replies::Error::TimedOut => Error::CommandTimeout,
replies::Error::Canceled => Error::ConnectionClosed,
})?
.content
.map_err(Error::Euph)?;
let ptype = data.packet_type();
data.try_into()
.map_err(|_| Error::ReceivedUnexpectedPacket(ptype))
})
}
/// Send a command to the server without waiting for a reply.
///
/// This method is equivalent to calling and awaiting [`Self::send`] but
/// ignoring the *reply future*. The reason it exists is that clippy gets
/// really annoying when you try to ignore a future (which is usually the
/// right call).
pub async fn send_only<C>(&self, cmd: C) -> Result<()>
where
C: Command + Into<Data>,
C::Reply: TryFrom<Data>,
{
let _ignore = self.send(cmd).await?;
Ok(())
}
/// Retrieve the current connection [`State`].
pub async fn state(&self) -> Result<State> {
let (tx, rx) = oneshot::channel();
self.tx
.send(ConnCommand::GetState(tx))
.await
.map_err(|_| Error::ConnectionClosed)?;
rx.await.map_err(|_| Error::ConnectionClosed)
}
}

View file

@ -0,0 +1,307 @@
//! Models the client's connection state.
use std::collections::HashMap;
use jiff::Timestamp;
use log::debug;
use crate::api::{
BounceEvent, Data, HelloEvent, NickEvent, PersonalAccountView, SessionId, SessionView,
SnapshotEvent, UserId,
};
/// Information about a session in the room.
///
/// For quite a while before finally going down altogether, the euphoria.io
/// instance had an unreliable nick list: Listings returned by the server were
/// usually incomplete. Because of this, the bot library uses any observable
/// action by a session (including nick changes) to update the listing. Since
/// nick events don't include full session info though, the [`SessionInfo`] enum
/// can contain partial information.
///
/// This level of paranioa probably isn't required any more now that the only
/// euphoria instance is working correctly. However, the code already works and
/// users who don't want to worry about it can just ignore partial session
/// infos.
#[derive(Debug, Clone)]
pub enum SessionInfo {
Full(SessionView),
Partial(NickEvent),
}
impl SessionInfo {
/// Retrieve the user id of the session.
pub fn id(&self) -> &UserId {
match self {
Self::Full(sess) => &sess.id,
Self::Partial(nick) => &nick.id,
}
}
/// Retrieve the session id of the session.
pub fn session_id(&self) -> &SessionId {
match self {
Self::Full(sess) => &sess.session_id,
Self::Partial(nick) => &nick.session_id,
}
}
/// Retrieve the user name of the session.
pub fn name(&self) -> &str {
match self {
Self::Full(sess) => &sess.name,
Self::Partial(nick) => &nick.to,
}
}
}
impl From<SessionView> for SessionInfo {
fn from(value: SessionView) -> Self {
Self::Full(value)
}
}
impl From<NickEvent> for SessionInfo {
fn from(value: NickEvent) -> Self {
Self::Partial(value)
}
}
/// The state of the connection before the client has joined the room.
///
/// Depending on the room, the client may need to authenticate or log in in
/// order to join.
#[derive(Debug, Clone)]
pub struct Joining {
/// Since when the connection has been in this state.
pub since: Timestamp,
/// A [`HelloEvent`], if one has been received.
///
/// Contains information about the client's own session.
pub hello: Option<HelloEvent>,
/// A [`SnapshotEvent`], if one has been received.
pub snapshot: Option<SnapshotEvent>,
/// A [`BounceEvent`], if one has been received.
pub bounce: Option<BounceEvent>,
}
impl Joining {
fn new() -> Self {
Self {
since: Timestamp::now(),
hello: None,
snapshot: None,
bounce: None,
}
}
fn on_data(&mut self, data: &Data) {
match data {
Data::BounceEvent(p) => self.bounce = Some(p.clone()),
Data::HelloEvent(p) => self.hello = Some(p.clone()),
Data::SnapshotEvent(p) => self.snapshot = Some(p.clone()),
_ => {}
}
}
fn to_joined(&self) -> Option<Joined> {
let hello = self.hello.as_ref()?;
let snapshot = self.snapshot.as_ref()?;
let mut session = hello.session.clone();
if let Some(nick) = &snapshot.nick {
session.name = nick.clone();
}
let listing = snapshot
.listing
.iter()
.cloned()
.map(|s| (s.session_id.clone(), SessionInfo::Full(s)))
.collect::<HashMap<_, _>>();
Some(Joined {
since: Timestamp::now(),
session,
account: hello.account.clone(),
listing,
})
}
}
/// The state of the connection after the client has successfully joined the
/// room.
///
/// The client may need to set a nick in order to be able to send messages.
/// However, it can access the room history without nick.
#[derive(Debug, Clone)]
pub struct Joined {
/// Since when the connection has been in this state.
pub since: Timestamp,
/// The client's own session.
pub session: SessionView,
/// Account information, if the client is logged in.
pub account: Option<PersonalAccountView>,
/// All sessions currently connected to the room (except the client's own
/// session).
pub listing: HashMap<SessionId, SessionInfo>,
}
impl Joined {
fn on_data(&mut self, data: &Data) {
match data {
Data::JoinEvent(p) => {
debug!("Updating listing after join-event");
self.listing
.insert(p.0.session_id.clone(), SessionInfo::Full(p.0.clone()));
}
Data::PartEvent(p) => {
debug!("Updating listing after part-event");
self.listing.remove(&p.0.session_id);
}
Data::NetworkEvent(p) => {
if p.r#type == "partition" {
debug!("Updating listing after network-event with type partition");
self.listing.retain(|_, s| match s {
SessionInfo::Full(s) => {
s.server_id != p.server_id && s.server_era != p.server_era
}
// We can't know if the session was disconnected by the
// partition or not, so we're erring on the side of
// caution and assuming they were kicked. If we're
// wrong, we'll re-add the session as soon as it
// performs another visible action.
//
// If we always kept such sessions, we might keep
// disconnected ones indefinitely, thereby keeping them
// from moving on, instead forever tethering them to the
// digital realm.
SessionInfo::Partial(_) => false,
});
}
}
Data::SendEvent(p) => {
debug!("Updating listing after send-event");
self.listing.insert(
p.0.sender.session_id.clone(),
SessionInfo::Full(p.0.sender.clone()),
);
}
Data::NickEvent(p) => {
debug!("Updating listing after nick-event");
self.listing
.entry(p.session_id.clone())
.and_modify(|s| match s {
SessionInfo::Full(session) => session.name = p.to.clone(),
SessionInfo::Partial(_) => *s = SessionInfo::Partial(p.clone()),
})
.or_insert_with(|| SessionInfo::Partial(p.clone()));
}
Data::NickReply(p) => {
debug!("Updating own session after nick-reply");
assert_eq!(self.session.id, p.id);
self.session.name = p.to.clone();
}
Data::WhoReply(p) => {
debug!("Updating listing after who-reply");
self.listing.clear();
for session in p.listing.clone() {
if session.session_id == self.session.session_id {
self.session = session;
} else {
self.listing
.insert(session.session_id.clone(), session.into());
}
}
}
_ => {}
}
}
}
/// The state of a connection to the server, from a client's perspective.
#[derive(Debug, Clone)]
#[allow(clippy::large_enum_variant)]
pub enum State {
/// The client has not joined the room yet.
Joining(Joining),
/// The client has successfully joined the room.
Joined(Joined),
}
impl State {
/// Create a new state for a fresh connection.
///
/// Assumes that no packets have been received yet. See also
/// [`Self::on_data`].
pub fn new() -> Self {
Joining::new().into()
}
/// If the state consists of a [`Joining`], return a reference to it.
pub fn as_joining(&self) -> Option<&Joining> {
match self {
Self::Joining(joining) => Some(joining),
Self::Joined(_) => None,
}
}
/// If the state consists of a [`Joined`], return a reference to it.
pub fn as_joined(&self) -> Option<&Joined> {
match self {
Self::Joining(_) => None,
Self::Joined(joined) => Some(joined),
}
}
/// If the state consists of a [`Joining`], return it.
pub fn into_joining(self) -> Option<Joining> {
match self {
Self::Joining(joining) => Some(joining),
Self::Joined(_) => None,
}
}
/// If the state consists of a [`Joined`], return it.
pub fn into_joined(self) -> Option<Joined> {
match self {
Self::Joining(_) => None,
Self::Joined(joined) => Some(joined),
}
}
/// Update the state with a packet received from the server.
///
/// This method should be called whenever any packet is received from the
/// server. Skipping packets may cause the state to become inconsistent.
pub fn on_data(&mut self, data: &Data) {
match self {
Self::Joining(joining) => {
joining.on_data(data);
if let Some(joined) = joining.to_joined() {
*self = joined.into();
}
}
Self::Joined(joined) => joined.on_data(data),
}
}
}
impl Default for State {
fn default() -> Self {
Self::new()
}
}
impl From<Joining> for State {
fn from(value: Joining) -> Self {
Self::Joining(value)
}
}
impl From<Joined> for State {
fn from(value: Joined) -> Self {
Self::Joined(value)
}
}

312
euphoxide/src/conn.rs Normal file
View file

@ -0,0 +1,312 @@
//! Basic connection between client and server.
use std::{fmt, time::Duration};
use futures_util::{SinkExt, StreamExt};
use jiff::Timestamp;
use log::debug;
use tokio::{
net::TcpStream,
select,
time::{self, Instant},
};
use tokio_tungstenite::{
tungstenite::{client::IntoClientRequest, handshake::client::Response, Message},
MaybeTlsStream, WebSocketStream,
};
use crate::{
api::{Data, Packet, PacketType, ParsedPacket, Ping, PingEvent, PingReply, Time},
Error, Result,
};
/// Which side of the connection we're on.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Side {
/// We're the client and are talking to a server.
Client,
/// We're the server and are talking to a client.
Server,
}
/// Configuration options for a [`Conn`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConnConfig {
/// How long to wait in-between sending pings.
///
/// This includes both websocket and euphoria pings ([`Ping`] or
/// [`PingEvent`]).
pub ping_interval: Duration,
}
impl Default for ConnConfig {
fn default() -> Self {
Self {
ping_interval: Duration::from_secs(30),
}
}
}
/// A basic connection between a client and a server.
///
/// The connection can be used both from a server's and from a client's
/// perspective. In both cases, it performs regular websocket *and* euphoria
/// pings and terminates the connection if the other side does not reply before
/// the next ping is sent.
pub struct Conn {
ws: WebSocketStream<MaybeTlsStream<TcpStream>>,
side: Side,
config: ConnConfig,
// The websocket server may send a pong frame with arbitrary payload
// unprompted at any time (see RFC 6455 5.5.3). Because of this, we can't
// just remember the last pong payload.
last_ping: Instant,
last_ws_ping_payload: Option<Vec<u8>>,
last_ws_ping_replied_to: bool,
last_euph_ping_payload: Option<Time>,
last_euph_ping_replied_to: bool,
}
impl Conn {
/// The connection's side.
pub fn side(&self) -> Side {
self.side
}
/// The connection's config.
pub fn config(&self) -> &ConnConfig {
&self.config
}
/// Connect to a given URL.
pub async fn connect<R>(request: R) -> Result<(Self, Response)>
where
R: IntoClientRequest + Unpin,
{
Self::connect_with_config(request, ConnConfig::default()).await
}
/// Connect to a given URL with a specific configuration.
pub async fn connect_with_config<R>(request: R, config: ConnConfig) -> Result<(Self, Response)>
where
R: IntoClientRequest + Unpin,
{
let (ws, response) = tokio_tungstenite::connect_async(request).await?;
let conn = Self::wrap_with_config(ws, Side::Client, config);
Ok((conn, response))
}
/// Wrap an existing websocket stream.
pub fn wrap(ws: WebSocketStream<MaybeTlsStream<TcpStream>>, side: Side) -> Self {
Self::wrap_with_config(ws, side, ConnConfig::default())
}
/// Wrap an existing websocket stream with a specific configuration.
pub fn wrap_with_config(
ws: WebSocketStream<MaybeTlsStream<TcpStream>>,
side: Side,
config: ConnConfig,
) -> Self {
Self {
ws,
side,
config,
last_ping: Instant::now(),
last_ws_ping_payload: None,
last_ws_ping_replied_to: false,
last_euph_ping_payload: None,
last_euph_ping_replied_to: false,
}
}
/// Start closing the connection.
///
/// To finish closing the connection gracefully, continue calling
/// [`Self::recv_raw`] or [`Self::recv`] until they return [`None`].
pub async fn close(&mut self) -> Result<()> {
self.ws.close(None).await?;
Ok(())
}
/// Send a [`Packet`] over the connection.
pub async fn send_raw(&mut self, packet: &Packet) -> Result<()> {
debug!(target: "euphoxide::conn::full", "Sending {packet:?}");
let text = serde_json::to_string(&packet).map_err(Error::MalformedPacket)?;
self.ws.send(Message::Text(text)).await?;
Ok(())
}
/// Send a [`ParsedPacket`] over the connection.
pub async fn send(&mut self, packet: ParsedPacket) -> Result<()> {
let packet = packet.into_packet().map_err(Error::MalformedPacket)?;
self.send_raw(&packet).await
}
/// Receive a [`Packet`] over the connection.
///
/// This method also listens for and sends pings in regular intervals as
/// specified by [`ConnConfig::ping_interval`]. Thus, this method must be
/// called regularly.
///
/// Returns [`None`] if the connection is closed.
pub async fn recv_raw(&mut self) -> Result<Option<Packet>> {
loop {
let next_ping = self.last_ping + self.config.ping_interval;
let result = select! {
_ = time::sleep_until(next_ping) => None,
r = self.ws.next() => Some(r),
};
match result {
None => self.check_and_send_pings().await?,
Some(None) => break Ok(None),
Some(Some(result)) => {
if let Some(packet) = self.on_message(result?).await? {
debug!(target: "euphoxide::conn::full", "Received {packet:?}");
break Ok(Some(packet));
}
}
}
}
}
/// Receive a [`ParsedPacket`] over the connection.
///
/// For more details, see [`Self::recv_raw`].
pub async fn recv(&mut self) -> Result<Option<ParsedPacket>> {
let Some(packet) = self.recv_raw().await? else {
return Ok(None);
};
let packet = ParsedPacket::from_packet(packet).map_err(Error::ReceivedMalformedPacket)?;
Ok(Some(packet))
}
async fn check_and_send_pings(&mut self) -> Result<()> {
debug!("Checking ping replies and sending new pings");
// Check previous ws ping
if self.last_ws_ping_payload.is_some() && !self.last_ws_ping_replied_to {
debug!("No response to websocket ping, disconnecting");
self.close().await?;
return Err(Error::PingTimeout);
}
// Check previous euph ping
if self.last_euph_ping_payload.is_some() && !self.last_euph_ping_replied_to {
debug!("No response to euph ping, disconnecting");
self.close().await?;
return Err(Error::PingTimeout);
}
let now = Timestamp::now();
// Send new ws ping
let ws_payload = now.as_millisecond().to_be_bytes().to_vec();
self.last_ws_ping_payload = Some(ws_payload.clone());
self.last_ws_ping_replied_to = false;
self.ws.send(Message::Ping(ws_payload)).await?;
// Send new euph ping
let euph_payload = Time::from_timestamp(now);
self.last_euph_ping_payload = Some(euph_payload);
self.last_euph_ping_replied_to = false;
let data: Data = match self.side {
Side::Client => Ping { time: euph_payload }.into(),
Side::Server => PingEvent {
time: euph_payload,
next: Time::from_timestamp(now + self.config.ping_interval),
}
.into(),
};
self.send(ParsedPacket::from_data(None, data)).await?;
self.last_ping = Instant::now();
Ok(())
}
async fn on_message(&mut self, message: Message) -> Result<Option<Packet>> {
match message {
Message::Pong(payload) => {
if self.last_ws_ping_payload == Some(payload) {
debug!("Received valid ws pong");
self.last_ws_ping_replied_to = true
}
Ok(None)
}
Message::Text(text) => {
let packet = serde_json::from_str(&text).map_err(Error::ReceivedMalformedPacket)?;
self.on_packet(&packet).await?;
Ok(Some(packet))
}
Message::Binary(_) => {
self.close().await?;
Err(Error::ReceivedBinaryMessage)
}
Message::Close(_) => Err(Error::ConnectionClosed),
// We don't have to manually respond to pings.
_ => Ok(None),
}
}
async fn on_packet(&mut self, packet: &Packet) -> Result<()> {
match packet.r#type {
PacketType::PingEvent => self.on_ping_event(packet).await,
PacketType::Ping => self.on_ping(packet).await,
PacketType::PingReply => self.on_ping_reply(packet),
_ => Ok(()),
}
}
async fn on_ping_event(&mut self, packet: &Packet) -> Result<()> {
debug!("Responding to ping-event");
let data = packet.data.clone().unwrap_or_default();
let data =
serde_json::from_value::<PingEvent>(data).map_err(Error::ReceivedMalformedPacket)?;
let time = Some(data.time);
let reply = ParsedPacket::from_data(packet.id.clone(), PingReply { time });
self.send(reply).await?;
Ok(())
}
async fn on_ping(&mut self, packet: &Packet) -> Result<()> {
debug!("Responding to ping");
let data = packet.data.clone().unwrap_or_default();
let data = serde_json::from_value::<Ping>(data).map_err(Error::ReceivedMalformedPacket)?;
let time = Some(data.time);
let reply = ParsedPacket::from_data(packet.id.clone(), PingReply { time });
self.send(reply).await?;
Ok(())
}
fn on_ping_reply(&mut self, packet: &Packet) -> Result<()> {
let data = packet.data.clone().unwrap_or_default();
let data =
serde_json::from_value::<PingReply>(data).map_err(Error::ReceivedMalformedPacket)?;
let Some(time) = data.time else { return Ok(()) };
if self.last_euph_ping_payload == Some(time) {
debug!("Received valid euph pong");
self.last_euph_ping_replied_to = true;
}
Ok(())
}
}
impl fmt::Debug for Conn {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Conn")
.field("side", &self.side)
.field("config", &self.config)
.finish_non_exhaustive()
}
}

View file

@ -878,7 +878,7 @@
"fist_raised": "270a",
"fist_right": "1f91c",
"five": "35-fe0f-20e3",
"fjafjkldskf7jkfdj": "1f577-fe0f",
"fjafjkldskf7jkfdj": "1f577",
"flags": "1f38f",
"flamingo": "1f9a9",
"flashlight": "1f526",
@ -958,7 +958,6 @@
"georgia": "1f1ec-1f1ea",
"ghana": "1f1ec-1f1ed",
"ghost": "1f47b",
"ghoti": "1f41f",
"gibraltar": "1f1ec-1f1ee",
"gift": "1f381",
"gift_heart": "1f49d",
@ -2986,7 +2985,7 @@
"speaking_head": "1f5e3-fe0f",
"speech_balloon": "1f4ac",
"speedboat": "1f6a4",
"spider": "1f577-fe0f",
"spider": "1f577",
"spider_web": "1f578-fe0f",
"spiral_calendar": "1f5d3-fe0f",
"spiral_notepad": "1f5d2-fe0f",

312
euphoxide/src/emoji.rs Normal file
View file

@ -0,0 +1,312 @@
use std::{borrow::Cow, collections::HashMap, ops::Range};
/// Emoji list from euphoria.leet.nu, obtainable via shell command:
///
/// ```bash
/// curl 'https://euphoria.leet.nu/static/emoji.json' \
/// | jq 'to_entries | sort_by(.key) | from_entries' \
/// > emoji.json
/// ```
const EMOJI_JSON: &str = include_str!("emoji.json");
/// A database of emoji names and their unicode representation.
///
/// Some emoji are rendered with custom icons in the web client and don't
/// correspond to an emoji in the unicode standard. These emoji don't have an
/// unicode representation.
pub struct Emoji(HashMap<String, Option<String>>);
fn parse_hex_to_char(hex: &str) -> Option<char> {
u32::from_str_radix(hex, 16).ok()?.try_into().ok()
}
fn parse_code_points(code_points: &str) -> Option<String> {
code_points
.split('-')
.map(parse_hex_to_char)
.collect::<Option<String>>()
}
impl Emoji {
/// Load the list of emoji compiled into the library.
///
/// # Example
///
/// ```
/// use euphoxide::Emoji;
/// let emoji = Emoji::load();
///
/// assert_eq!(emoji.get("robot"), Some(Some("🤖")));
/// ```
pub fn load() -> Self {
Self::load_from_json(EMOJI_JSON).unwrap()
}
/// Load a list of emoji from a string containing a JSON object.
///
/// The object keys are the emoji names (without colons `:`). The object
/// values are the emoji code points encoded as hexadecimal numbers and
/// separated by a dash `-` (e.g. `"34-fe0f-20e3"`). Emoji whose values
/// don't match this schema are interpreted as emoji without unicode
/// representation.
///
/// This is the format used by the [euphoria.leet.nu emoji listing][0].
///
/// [0]: https://euphoria.leet.nu/static/emoji.json
///
/// # Example
///
/// ```
/// use euphoxide::Emoji;
///
/// const EMOJI: &str = r#" {"Roboter": "1f916", "foo": "~bar"} "#;
/// let emoji = Emoji::load_from_json(EMOJI).unwrap();
///
/// assert_eq!(emoji.get("Roboter"), Some(Some("🤖")));
/// assert_eq!(emoji.get("foo"), Some(None));
/// assert_eq!(emoji.get("robot"), None);
/// ```
pub fn load_from_json(json: &str) -> Option<Self> {
let map = serde_json::from_str::<HashMap<String, String>>(json)
.ok()?
.into_iter()
.map(|(k, v)| (k, parse_code_points(&v)))
.collect::<HashMap<_, _>>();
Some(Self(map))
}
/// Retrieve an emoji's unicode representation by name.
///
/// Returns `None` if the emoji could not be found. Returns `Some(None)` if
/// the emoji could be found but does not have a unicode representation.
///
/// The name is not colon-delimited.
///
/// # Example
///
/// ```
/// use euphoxide::Emoji;
/// let emoji = Emoji::load();
///
/// assert_eq!(emoji.get("robot"), Some(Some("🤖")));
/// assert_eq!(emoji.get("+1"), Some(None));
/// assert_eq!(emoji.get("foobar"), None);
///
/// assert_eq!(emoji.get(":robot:"), None);
/// ```
pub fn get(&self, name: &str) -> Option<Option<&str>> {
match self.0.get(name) {
Some(Some(replace)) => Some(Some(replace)),
Some(None) => Some(None),
None => None,
}
}
/// All known emoji and their unicode representation.
///
/// The emoji are not in any particular order.
///
/// # Example
///
/// ```
/// use euphoxide::Emoji;
/// let emoji = Emoji::load();
///
/// // List all emoji that don't have a unicode representation
/// let custom_emoji = emoji
/// .all()
/// .filter(|(_, unicode)| unicode.is_none())
/// .map(|(name, _)| name)
/// .collect::<Vec<_>>();
///
/// assert!(!custom_emoji.is_empty());
/// ```
pub fn all(&self) -> impl Iterator<Item = (&str, Option<&str>)> {
self.0
.iter()
.map(|(k, v)| (k as &str, v.as_ref().map(|v| v as &str)))
}
/// Find all colon-delimited emoji in a string.
///
/// Returns a list of emoji locations (colons are included in the range) and
/// corresponding unicode representations.
///
/// # Example
///
/// ```
/// use euphoxide::Emoji;
/// let emoji = Emoji::load();
///
/// let found = emoji.find("Hello :globe_with_meridians:!");
/// assert_eq!(found, vec![(6..28, Some("🌐"))]);
///
/// // Ignores nonexistent emoji
/// let found = emoji.find("Hello :sparkly_wizard:!");
/// assert!(found.is_empty());
/// ```
pub fn find(&self, text: &str) -> Vec<(Range<usize>, Option<&str>)> {
let mut result = vec![];
let mut prev_colon_idx = None;
for (colon_idx, _) in text.match_indices(':') {
if let Some(prev_idx) = prev_colon_idx {
let name = &text[prev_idx + 1..colon_idx];
if let Some(replace) = self.get(name) {
let range = prev_idx..colon_idx + 1;
result.push((range, replace));
prev_colon_idx = None;
continue;
}
}
prev_colon_idx = Some(colon_idx);
}
result
}
/// Replace all colon-delimited emoji in a string.
///
/// # Example
///
/// ```
/// use euphoxide::Emoji;
/// let emoji = Emoji::load();
///
/// let replaced = emoji.replace("Hello :globe_with_meridians:!");
/// assert_eq!(replaced, "Hello 🌐!");
///
/// // Ignores nonexistent emoji
/// let replaced = emoji.replace("Hello :sparkly_wizard:!");
/// assert_eq!(replaced, "Hello :sparkly_wizard:!");
/// ```
pub fn replace<'a>(&self, text: &'a str) -> Cow<'a, str> {
let emoji = self.find(text);
if emoji.is_empty() {
return Cow::Borrowed(text);
}
let mut result = String::new();
let mut after_last_emoji = 0;
for (range, replace) in emoji {
// Only replace emoji with a replacement
if let Some(replace) = replace {
if range.start > after_last_emoji {
// There were non-emoji characters between the last and the
// current emoji.
result.push_str(&text[after_last_emoji..range.start]);
}
result.push_str(replace);
after_last_emoji = range.end;
}
}
if after_last_emoji < text.len() {
result.push_str(&text[after_last_emoji..]);
}
Cow::Owned(result)
}
/// Remove all colon-delimited emoji in a string.
///
/// # Example
///
/// ```
/// use euphoxide::Emoji;
/// let emoji = Emoji::load();
///
/// let removed = emoji.remove("Hello :globe_with_meridians:!");
/// assert_eq!(removed, "Hello !");
///
/// // Ignores nonexistent emoji
/// let removed = emoji.replace("Hello :sparkly_wizard:!");
/// assert_eq!(removed, "Hello :sparkly_wizard:!");
/// ```
pub fn remove<'a>(&self, text: &'a str) -> Cow<'a, str> {
let emoji = self.find(text);
if emoji.is_empty() {
return Cow::Borrowed(text);
}
let mut result = String::new();
let mut after_last_emoji = 0;
for (range, _) in emoji {
if range.start > after_last_emoji {
// There were non-emoji characters between the last and the
// current emoji.
result.push_str(&text[after_last_emoji..range.start]);
}
after_last_emoji = range.end;
}
if after_last_emoji < text.len() {
result.push_str(&text[after_last_emoji..]);
}
Cow::Owned(result)
}
}
#[cfg(test)]
mod test {
use super::Emoji;
#[test]
fn load_without_panic() {
Emoji::load();
}
#[test]
fn find() {
let emoji = Emoji::load();
// :bad: does not exist, while :x: and :o: do.
assert_eq!(emoji.find(":bad:x:o:"), vec![(4..7, Some(""))]);
assert_eq!(
emoji.find(":x:bad:o:"),
vec![(0..3, Some("")), (6..9, Some(""))]
);
assert_eq!(emoji.find("ab:bad:x:o:cd"), vec![(6..9, Some(""))]);
assert_eq!(
emoji.find("ab:x:bad:o:cd"),
vec![(2..5, Some("")), (8..11, Some(""))]
);
}
#[test]
fn replace() {
let emoji = Emoji::load();
assert_eq!(emoji.replace("no:emo:ji:here"), "no:emo:ji:here");
assert_eq!(emoji.replace(":bad:x:o:"), ":bad❌o:");
assert_eq!(emoji.replace(":x:bad:o:"), "❌bad⭕");
assert_eq!(emoji.replace("ab:bad:x:o:cd"), "ab:bad❌o:cd");
assert_eq!(emoji.replace("ab:x:bad:o:cd"), "ab❌bad⭕cd");
assert_eq!(emoji.replace("chm:crown::ant:"), "chm👑🐜");
assert_eq!(
emoji.replace(":waning_crescent_moon: (2% full)"),
"🌘 (2% full)"
);
assert_eq!(emoji.replace("Jan-20 17:58 Z"), "Jan-20 17:58 Z");
}
#[test]
fn remove() {
let emoji = Emoji::load();
assert_eq!(emoji.remove("no:emo:ji:here"), "no:emo:ji:here");
assert_eq!(emoji.remove(":bad:x:o:"), ":bado:");
assert_eq!(emoji.remove(":x:bad:o:"), "bad");
assert_eq!(emoji.remove("ab:bad:x:o:cd"), "ab:bado:cd");
assert_eq!(emoji.remove("ab:x:bad:o:cd"), "abbadcd");
assert_eq!(emoji.remove("chm:crown::ant:"), "chm");
assert_eq!(
emoji.remove(":waning_crescent_moon: (2% full)"),
" (2% full)"
);
assert_eq!(emoji.remove("Jan-20 17:58 Z"), "Jan-20 17:58 Z");
}
}

82
euphoxide/src/error.rs Normal file
View file

@ -0,0 +1,82 @@
//! Error handling.
use std::fmt;
use tokio_tungstenite::tungstenite;
use crate::api::PacketType;
/// Possible euphoria communication errors.
#[derive(Debug)]
pub enum Error {
/// The connection is closed.
ConnectionClosed,
/// A ping was not replied to in time.
PingTimeout,
/// A packet was not sent because it was malformed.
MalformedPacket(serde_json::Error),
/// A binary message was received.
ReceivedBinaryMessage,
/// A malformed packet was received.
ReceivedMalformedPacket(serde_json::Error),
/// An unexpected packet was received.
ReceivedUnexpectedPacket(PacketType),
/// A tungstenite error.
Tungstenite(tungstenite::Error),
/// A timeout occurred while opening a connection.
///
/// This is a higher-level error that only occurs in the
/// [`ClientConn`](crate::client::conn::ClientConn).
ConnectionTimeout,
/// The server did not reply to a command in time.
///
/// This is a higher-level error that only occurs with
/// [`Command`](crate::api::Command)-based APIs in
/// [`ClientConn`](crate::client::conn::ClientConn).
CommandTimeout,
/// The server replied with an error string.
///
/// This is a higher-level error that only occurs with
/// [`Command`](crate::api::Command)-based APIs in
/// [`ClientConn`](crate::client::conn::ClientConn).
Euph(String),
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::ConnectionClosed => write!(f, "connection closed"),
Self::PingTimeout => write!(f, "ping timed out"),
Self::MalformedPacket(err) => write!(f, "malformed packet: {err}"),
Self::ReceivedBinaryMessage => write!(f, "received binary message"),
Self::ReceivedMalformedPacket(err) => write!(f, "received malformed packet: {err}"),
Self::ReceivedUnexpectedPacket(ptype) => {
write!(f, "received packet of unexpected type: {ptype}")
}
Self::Tungstenite(err) => write!(f, "{err}"),
Self::ConnectionTimeout => write!(f, "connection timed out while connecting"),
Self::CommandTimeout => write!(f, "command timed out"),
Self::Euph(msg) => write!(f, "{msg}"),
}
}
}
impl std::error::Error for Error {}
impl From<tungstenite::Error> for Error {
fn from(err: tungstenite::Error) -> Self {
Self::Tungstenite(err)
}
}
/// An alias of [`Result`](std::result::Result) for [`Error`].
pub type Result<T> = std::result::Result<T, Error>;

View file

@ -1,9 +1,9 @@
pub mod api;
#[cfg(feature = "bot")]
pub mod bot;
pub mod client;
pub mod conn;
mod emoji;
mod error;
pub mod nick;
mod replies;
pub use emoji::Emoji;
pub use crate::{emoji::*, error::*};

View file

@ -5,10 +5,9 @@ use unicode_normalization::UnicodeNormalization;
use crate::emoji::Emoji;
fn hue_normalize(emoji: &Emoji, text: &str) -> String {
emoji
.remove(text)
.chars()
/// Does not remove emoji.
fn hue_normalize(text: &str) -> String {
text.chars()
.filter(|&c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
.map(|c| c.to_ascii_lowercase())
.collect()
@ -16,7 +15,7 @@ fn hue_normalize(emoji: &Emoji, text: &str) -> String {
/// A re-implementation of [euphoria's nick hue hashing algorithm][0].
///
/// [0]: https://github.com/CylonicRaider/heim/blob/097a1fde89ada53de2b70e51e635257f27956e4e/client/lib/heim/hueHash.js
/// [0]: https://github.com/CylonicRaider/heim/blob/master/client/lib/hueHash.js
fn hue_hash(text: &str, offset: i64) -> u8 {
let mut val = 0_i32;
for bibyte in text.encode_utf16() {
@ -36,13 +35,7 @@ const GREENIE_OFFSET: i64 = 148 - 192; // 148 - hue_hash("greenie", 0)
/// This should be slightly faster than [`hue`] but produces incorrect results
/// if any colon-delimited emoji are present.
pub fn hue_without_removing_emoji(nick: &str) -> u8 {
// An emoji-less version of hue_normalize
let normalized = nick
.chars()
.filter(|&c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
.map(|c| c.to_ascii_lowercase())
.collect::<String>();
let normalized = hue_normalize(nick);
if normalized.is_empty() {
hue_hash(nick, GREENIE_OFFSET)
} else {
@ -55,14 +48,9 @@ pub fn hue_without_removing_emoji(nick: &str) -> u8 {
/// This is a reimplementation of [euphoria's nick hue hashing algorithm][0]. It
/// should always return the same value as the official client's implementation.
///
/// [0]: https://github.com/CylonicRaider/heim/blob/097a1fde89ada53de2b70e51e635257f27956e4e/client/lib/heim/hueHash.js
/// [0]: https://github.com/CylonicRaider/heim/blob/978c921063e6b06012fc8d16d9fbf1b3a0be1191/client/lib/hueHash.js
pub fn hue(emoji: &Emoji, nick: &str) -> u8 {
let normalized = hue_normalize(emoji, nick);
if normalized.is_empty() {
hue_hash(nick, GREENIE_OFFSET)
} else {
hue_hash(&normalized, GREENIE_OFFSET)
}
hue_without_removing_emoji(&emoji.remove(nick))
}
/// Normalize a nick to a form that can be compared against other nicks.
@ -81,17 +69,18 @@ pub fn hue(emoji: &Emoji, nick: &str) -> u8 {
/// 1. Apply [`mention`]
/// 2. Convert to NFKC
/// 3. Case fold
/// 4. Convert to NFC
///
/// Steps 2 and 3 are meant to be an alternative to the NKFC_Casefold derived
/// property that's easier to implement, even though it may be incorrect in some
/// edge cases.
///
/// [0]: https://github.com/CylonicRaider/heim/blob/978c921063e6b06012fc8d16d9fbf1b3a0be1191/client/lib/stores/chat.js#L14
/// Steps 2 to 4 are meant to emulate NKFC_Casefold, but may differ in some edge
/// cases. Most notably, they don't ignore default ignorable code points. Maybe
/// there are also other edge cases I don't know about.
pub fn normalize(nick: &str) -> String {
mention(nick) // Step 1
.nfkc() // Step 2
.default_case_fold() // Step 3
.collect()
.collect::<String>()
.nfc() // Step 4
.collect::<String>()
}
fn is_non_whitespace_delimiter(c: char) -> bool {
@ -108,12 +97,14 @@ fn is_non_whitespace_delimiter(c: char) -> bool {
/// highlight as a mention in the official euphoria client. It should ping any
/// people using the original nick. It might also ping other people.
///
/// In the official euphoria client, mentions are non-whitespace characters
/// [In the official euphoria client][0], mentions are non-whitespace characters
/// delimited by whitespace and any of the following characters:
///
/// `,`, `.`, `!`, `?`, `;`, `&`, `<`, `>`, `'`, `"`.
///
/// The first character of a mention may be a delimiting character.
///
/// [0]: https://github.com/CylonicRaider/heim/blob/978c921063e6b06012fc8d16d9fbf1b3a0be1191/client/lib/stores/chat.js#L14
pub fn mention(nick: &str) -> String {
let mut nick = nick.chars().filter(|c| !c.is_whitespace());
let mut result = String::new();

View file

@ -1,10 +1,6 @@
use std::collections::HashMap;
use std::fmt;
use std::hash::Hash;
use std::time::Duration;
use std::{error, result};
use std::{collections::HashMap, error, fmt, hash::Hash, result, time::Duration};
use tokio::sync::oneshot::{self, Receiver, Sender};
use tokio::sync::oneshot;
#[derive(Debug)]
pub enum Error {
@ -28,7 +24,7 @@ pub type Result<T> = result::Result<T, Error>;
#[derive(Debug)]
pub struct PendingReply<R> {
timeout: Duration,
result: Receiver<R>,
result: oneshot::Receiver<R>,
}
impl<R> PendingReply<R> {
@ -44,7 +40,7 @@ impl<R> PendingReply<R> {
#[derive(Debug)]
pub struct Replies<I, R> {
timeout: Duration,
pending: HashMap<I, Sender<R>>,
pending: HashMap<I, oneshot::Sender<R>>,
}
impl<I, R> Replies<I, R> {
@ -55,10 +51,6 @@ impl<I, R> Replies<I, R> {
}
}
pub fn timeout(&self) -> Duration {
self.timeout
}
pub fn wait_for(&mut self, id: I) -> PendingReply<R>
where
I: Eq + Hash,

View file

@ -1,150 +0,0 @@
// TODO Add description
// TODO Clean up and unify test bots
use std::sync::Arc;
use async_trait::async_trait;
use clap::Parser;
use euphoxide::api::Message;
use euphoxide::bot::botrulez::{FullHelp, HasDescriptions, HasStartTime, Ping, ShortHelp, Uptime};
use euphoxide::bot::command::{Clap, ClapCommand, Context, General, Global, Hidden, Specific};
use euphoxide::bot::commands::Commands;
use euphoxide::bot::instance::{Event, ServerConfig};
use euphoxide::bot::instances::Instances;
use euphoxide::conn;
use jiff::Timestamp;
use log::error;
use tokio::sync::mpsc;
const HELP: &str = "I'm an example bot for https://github.com/Garmelon/euphoxide";
/// Kill this bot.
#[derive(Parser)]
struct KillArgs;
struct Kill;
#[async_trait]
impl ClapCommand<Bot, conn::Error> for Kill {
type Args = KillArgs;
async fn execute(
&self,
_args: Self::Args,
msg: &Message,
ctx: &Context,
bot: &mut Bot,
) -> Result<bool, conn::Error> {
bot.stop = true;
ctx.reply(msg.id, "/me dies").await?;
Ok(true)
}
}
/// Do some testing.
#[derive(Parser)]
struct TestArgs {
/// How much testing to do.
#[arg(default_value_t = 1)]
amount: u64,
}
struct Test;
#[async_trait]
impl ClapCommand<Bot, conn::Error> for Test {
type Args = TestArgs;
async fn execute(
&self,
args: Self::Args,
msg: &Message,
ctx: &Context,
_bot: &mut Bot,
) -> Result<bool, conn::Error> {
let content = if args.amount == 1 {
format!("/me did {} test", args.amount)
} else {
format!("/me did {} tests", args.amount)
};
ctx.reply(msg.id, content).await?;
Ok(true)
}
}
struct Bot {
commands: Arc<Commands<Self, conn::Error>>,
start_time: Timestamp,
stop: bool,
}
impl HasDescriptions for Bot {
fn descriptions(&self, ctx: &Context) -> Vec<String> {
self.commands.descriptions(ctx)
}
}
impl HasStartTime for Bot {
fn start_time(&self) -> Timestamp {
self.start_time
}
}
#[tokio::main]
async fn main() {
// https://github.com/snapview/tokio-tungstenite/issues/353#issuecomment-2455247837
rustls::crypto::aws_lc_rs::default_provider()
.install_default()
.unwrap();
let (tx, mut rx) = mpsc::unbounded_channel();
let mut instances = Instances::new(ServerConfig::default());
let mut cmds = Commands::new();
cmds.add(Hidden(General::new("ping", Clap(Ping::default()))));
cmds.add(Specific::new("ping", Clap(Ping::default())));
cmds.add(Hidden(General::new("help", Clap(ShortHelp::new(HELP)))));
cmds.add(Specific::new("help", Clap(FullHelp::new(HELP, ""))));
cmds.add(Specific::new("uptime", Clap(Uptime)));
cmds.add(Specific::new("kill", Clap(Kill)));
cmds.add(Global::new("test", Clap(Test)));
let cmds = Arc::new(cmds);
let mut bot = Bot {
commands: cmds.clone(),
start_time: Timestamp::now(),
stop: false,
};
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 {
let result = cmds
.handle_packet(&config, &packet, &snapshot, &mut bot)
.await;
if let Err(err) = result {
error!("{err}");
}
if bot.stop {
break;
}
}
}
}

View file

@ -1,146 +0,0 @@
//! 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::botrulez;
use euphoxide::bot::instance::{ConnSnapshot, Event, ServerConfig};
use jiff::Timestamp;
use tokio::sync::mpsc;
const NICK: &str = "TestBot";
const HELP: &str = "I'm an example bot for https://github.com/Garmelon/euphoxide";
async fn on_packet(packet: ParsedPacket, snapshot: ConnSnapshot) -> 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 = Timestamp::now() - joined.since;
reply = Some(format!(
"/me has been up for {}",
botrulez::format_duration(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...");
snapshot.conn_tx.send_only(Send {
content: reply,
parent: Some(event.0.id),
});
println!("Reply sent!");
}
}
_ => {}
}
Ok(())
}
#[tokio::main]
async fn main() {
// https://github.com/snapview/tokio-tungstenite/issues/353#issuecomment-2455247837
rustls::crypto::aws_lc_rs::default_provider()
.install_default()
.unwrap();
let (tx, mut rx) = mpsc::unbounded_channel();
let _instance = ServerConfig::default()
.room("test")
.username(Some("TestBot"))
.build(move |e| {
let _ = tx.send(e);
});
while let Some(event) = rx.recv().await {
if let Event::Packet(_config, packet, snapshot) = event {
if on_packet(packet, snapshot).await.is_err() {
break;
}
}
}
}

View file

@ -1,159 +0,0 @@
//! 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::botrulez;
use euphoxide::bot::instance::{ConnSnapshot, Event, ServerConfig};
use euphoxide::bot::instances::Instances;
use jiff::Timestamp;
use tokio::sync::mpsc;
const NICK: &str = "TestBot";
const HELP: &str = "I'm an example bot for https://github.com/Garmelon/euphoxide";
async fn on_packet(packet: ParsedPacket, snapshot: ConnSnapshot) -> 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 = Timestamp::now() - joined.since;
reply = Some(format!(
"/me has been up for {}",
botrulez::format_duration(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...");
snapshot.conn_tx.send_only(Send {
content: reply,
parent: Some(event.0.id),
});
println!("Reply sent!");
}
}
_ => {}
}
Ok(())
}
#[tokio::main]
async fn main() {
// https://github.com/snapview/tokio-tungstenite/issues/353#issuecomment-2455247837
rustls::crypto::aws_lc_rs::default_provider()
.install_default()
.unwrap();
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;
}
}
}
}

View file

@ -1,142 +0,0 @@
//! A small bot that doesn't use the `bot` submodule. Meant to show how the main
//! parts of the API fit together.
use std::error::Error;
use std::time::Duration;
use euphoxide::api::packet::ParsedPacket;
use euphoxide::api::{Data, Nick, Send};
use euphoxide::bot::botrulez;
use euphoxide::conn::{Conn, ConnTx, State};
use jiff::Timestamp;
const TIMEOUT: Duration = Duration::from_secs(10);
const DOMAIN: &str = "euphoria.leet.nu";
const ROOM: &str = "test";
const NICK: &str = "TestBot";
const HELP: &str = "I'm an example bot for https://github.com/Garmelon/euphoxide";
async fn on_packet(packet: ParsedPacket, conn_tx: &ConnTx, state: &State) -> Result<(), ()> {
let data = match packet.content {
Ok(data) => data,
Err(err) => {
println!("Error for {}: {err}", packet.r#type);
return Err(());
}
};
match data {
Data::HelloEvent(event) => println!("Connected with id {}", event.session.id),
Data::SnapshotEvent(event) => {
for session in event.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 = 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) = state.joined() {
let delta = Timestamp::now() - joined.since;
reply = Some(format!(
"/me has been up for {}",
botrulez::format_duration(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 _ = 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...");
conn_tx.send_only(Send {
content: reply,
parent: Some(event.0.id),
});
println!("Reply sent!");
}
}
_ => {}
}
Ok(())
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
// https://github.com/snapview/tokio-tungstenite/issues/353#issuecomment-2455247837
rustls::crypto::aws_lc_rs::default_provider()
.install_default()
.unwrap();
let (mut conn, _) = Conn::connect(DOMAIN, ROOM, false, None, TIMEOUT).await?;
while let Ok(packet) = conn.recv().await {
if on_packet(packet, conn.tx(), conn.state()).await.is_err() {
break;
}
}
Ok(())
}

View file

@ -1,17 +0,0 @@
//! Models the [euphoria API][0].
//!
//! [0]: https://euphoria.leet.nu/heim/api
mod account_cmds;
mod events;
pub mod packet;
mod room_cmds;
mod session_cmds;
mod types;
pub use account_cmds::*;
pub use events::*;
pub use packet::Data;
pub use room_cmds::*;
pub use session_cmds::*;
pub use types::*;

View file

@ -1,223 +0,0 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use super::PacketType;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Packet {
pub id: Option<String>,
pub r#type: PacketType,
pub data: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub throttled: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub throttled_reason: Option<String>,
}
pub trait Command {
type Reply;
}
macro_rules! packets {
( $( $name:ident, )*) => {
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum Data {
$( $name(super::$name), )*
Unimplemented,
}
impl Data {
pub fn from_value(ptype: PacketType, value: Value) -> serde_json::Result<Self> {
Ok(match ptype {
$( PacketType::$name => Self::$name(serde_json::from_value(value)?), )*
_ => Self::Unimplemented,
})
}
pub fn into_value(self) -> serde_json::Result<Value> {
Ok(match self{
$( Self::$name(p) => serde_json::to_value(p)?, )*
Self::Unimplemented => panic!("using unimplemented data"),
})
}
pub fn packet_type(&self) -> PacketType {
match self {
$( Self::$name(_) => PacketType::$name, )*
Self::Unimplemented => panic!("using unimplemented data"),
}
}
}
$(
impl From<super::$name> for Data {
fn from(p: super::$name) -> Self {
Self::$name(p)
}
}
impl TryFrom<Data> for super::$name{
type Error = ();
fn try_from(value: Data) -> Result<Self, Self::Error> {
match value {
Data::$name(p) => Ok(p),
_ => Err(())
}
}
}
)*
};
}
macro_rules! commands {
( $( $cmd:ident => $rpl:ident, )* ) => {
$(
impl Command for super::$cmd {
type Reply = super::$rpl;
}
)*
};
}
packets! {
// Events
BounceEvent,
DisconnectEvent,
HelloEvent,
JoinEvent,
LoginEvent,
LogoutEvent,
NetworkEvent,
NickEvent,
EditMessageEvent,
PartEvent,
PingEvent,
PmInitiateEvent,
SendEvent,
SnapshotEvent,
// Session commands
Auth,
AuthReply,
Ping,
PingReply,
// Chat room commands
GetMessage,
GetMessageReply,
Log,
LogReply,
Nick,
NickReply,
PmInitiate,
PmInitiateReply,
Send,
SendReply,
Who,
WhoReply,
// Account commands
ChangeEmail,
ChangeEmailReply,
ChangeName,
ChangeNameReply,
ChangePassword,
ChangePasswordReply,
Login,
LoginReply,
Logout,
LogoutReply,
RegisterAccount,
RegisterAccountReply,
ResendVerificationEmail,
ResendVerificationEmailReply,
ResetPassword,
ResetPasswordReply,
}
commands! {
// Session commands
Auth => AuthReply,
Ping => PingReply,
// Chat room commands
GetMessage => GetMessageReply,
Log => LogReply,
Nick => NickReply,
PmInitiate => PmInitiateReply,
Send => SendReply,
Who => WhoReply,
// Account commands
ChangeEmail => ChangeEmailReply,
ChangeName => ChangeNameReply,
ChangePassword => ChangePasswordReply,
Login => LoginReply,
Logout => LogoutReply,
RegisterAccount => RegisterAccountReply,
ResendVerificationEmail => ResendVerificationEmailReply,
ResetPassword => ResetPasswordReply,
}
#[derive(Debug, Clone)]
pub struct ParsedPacket {
pub id: Option<String>,
pub r#type: PacketType,
pub content: Result<Data, String>,
pub throttled: Option<String>,
}
impl ParsedPacket {
pub fn from_packet(packet: Packet) -> serde_json::Result<Self> {
let id = packet.id;
let r#type = packet.r#type;
let content = if let Some(error) = packet.error {
Err(error)
} else {
let data = packet.data.unwrap_or_default();
Ok(Data::from_value(r#type, data)?)
};
let throttled = if packet.throttled {
let reason = packet
.throttled_reason
.unwrap_or_else(|| "no reason given".to_string());
Some(reason)
} else {
None
};
Ok(Self {
id,
r#type,
content,
throttled,
})
}
pub fn into_packet(self) -> serde_json::Result<Packet> {
let id = self.id;
let r#type = self.r#type;
let throttled = self.throttled.is_some();
let throttled_reason = self.throttled;
Ok(match self.content {
Ok(data) => Packet {
id,
r#type,
data: Some(data.into_value()?),
error: None,
throttled,
throttled_reason,
},
Err(error) => Packet {
id,
r#type,
data: None,
error: Some(error),
throttled,
throttled_reason,
},
})
}
}

View file

@ -1,7 +0,0 @@
//! Building blocks for bots.
pub mod botrulez;
pub mod command;
pub mod commands;
pub mod instance;
pub mod instances;

View file

@ -1,10 +0,0 @@
//! The main [botrulez](https://github.com/jedevc/botrulez) commands.
pub mod full_help;
pub mod ping;
pub mod short_help;
pub mod uptime;
pub use self::full_help::{FullHelp, HasDescriptions};
pub use self::ping::Ping;
pub use self::short_help::ShortHelp;
pub use self::uptime::{format_duration, format_relative_time, format_time, HasStartTime, Uptime};

View file

@ -1,93 +0,0 @@
use async_trait::async_trait;
use clap::Parser;
use crate::api::Message;
use crate::bot::command::{ClapCommand, Command, Context};
use crate::conn;
pub struct FullHelp {
pub before: String,
pub after: String,
}
pub trait HasDescriptions {
fn descriptions(&self, ctx: &Context) -> Vec<String>;
}
impl FullHelp {
pub fn new<S1: ToString, S2: ToString>(before: S1, after: S2) -> Self {
Self {
before: before.to_string(),
after: after.to_string(),
}
}
fn formulate_reply<B: HasDescriptions>(&self, ctx: &Context, bot: &B) -> String {
let mut result = String::new();
if !self.before.is_empty() {
result.push_str(&self.before);
result.push('\n');
}
for description in bot.descriptions(ctx) {
result.push_str(&description);
result.push('\n');
}
if !self.after.is_empty() {
result.push_str(&self.after);
result.push('\n');
}
result
}
}
#[async_trait]
impl<B, E> Command<B, E> for FullHelp
where
B: HasDescriptions + Send,
E: From<conn::Error>,
{
async fn execute(
&self,
arg: &str,
msg: &Message,
ctx: &Context,
bot: &mut B,
) -> Result<bool, E> {
if arg.trim().is_empty() {
let reply = self.formulate_reply(ctx, bot);
ctx.reply(msg.id, reply).await?;
Ok(true)
} else {
Ok(false)
}
}
}
/// Show full bot help.
#[derive(Parser)]
pub struct Args {}
#[async_trait]
impl<B, E> ClapCommand<B, E> for FullHelp
where
B: HasDescriptions + Send,
E: From<conn::Error>,
{
type Args = Args;
async fn execute(
&self,
_args: Self::Args,
msg: &Message,
ctx: &Context,
bot: &mut B,
) -> Result<bool, E> {
let reply = self.formulate_reply(ctx, bot);
ctx.reply(msg.id, reply).await?;
Ok(true)
}
}

View file

@ -1,64 +0,0 @@
mod bang;
mod clap;
mod hidden;
mod prefixed;
use std::future::Future;
use async_trait::async_trait;
use crate::api::{self, Message, MessageId};
use crate::conn::{self, ConnTx, Joined};
pub use self::bang::*;
pub use self::clap::*;
pub use self::hidden::*;
pub use self::prefixed::*;
use super::instance::InstanceConfig;
pub struct Context {
pub config: InstanceConfig,
pub conn_tx: ConnTx,
pub joined: Joined,
}
impl Context {
pub fn send<S: ToString>(&self, content: S) -> impl Future<Output = conn::Result<Message>> {
let cmd = api::Send {
content: content.to_string(),
parent: None,
};
let reply = self.conn_tx.send(cmd);
async move { reply.await.map(|r| r.0) }
}
pub fn reply<S: ToString>(
&self,
parent: MessageId,
content: S,
) -> impl Future<Output = conn::Result<Message>> {
let cmd = api::Send {
content: content.to_string(),
parent: Some(parent),
};
let reply = self.conn_tx.send(cmd);
async move { reply.await.map(|r| r.0) }
}
}
#[allow(unused_variables)]
#[async_trait]
pub trait Command<B, E> {
fn description(&self, ctx: &Context) -> Option<String> {
None
}
async fn execute(
&self,
arg: &str,
msg: &Message,
ctx: &Context,
bot: &mut B,
) -> Result<bool, E>;
}

View file

@ -1,29 +0,0 @@
use async_trait::async_trait;
use crate::api::Message;
use super::{Command, Context};
pub struct Hidden<C>(pub C);
#[async_trait]
impl<B, E, C> Command<B, E> for Hidden<C>
where
B: Send,
C: Command<B, E> + Send + Sync,
{
fn description(&self, _ctx: &Context) -> Option<String> {
// Default implementation, repeated here for emphasis.
None
}
async fn execute(
&self,
arg: &str,
msg: &Message,
ctx: &Context,
bot: &mut B,
) -> Result<bool, E> {
self.0.execute(arg, msg, ctx, bot).await
}
}

View file

@ -1,45 +0,0 @@
use async_trait::async_trait;
use crate::api::Message;
use super::{Command, Context};
pub struct Prefixed<C> {
prefix: String,
inner: C,
}
impl<C> Prefixed<C> {
pub fn new<S: ToString>(prefix: S, inner: C) -> Self {
Self {
prefix: prefix.to_string(),
inner,
}
}
}
#[async_trait]
impl<B, E, C> Command<B, E> for Prefixed<C>
where
B: Send,
C: Command<B, E> + Send + Sync,
{
fn description(&self, ctx: &Context) -> Option<String> {
let inner = self.inner.description(ctx)?;
Some(format!("{} - {inner}", self.prefix))
}
async fn execute(
&self,
arg: &str,
msg: &Message,
ctx: &Context,
bot: &mut B,
) -> Result<bool, E> {
if let Some(rest) = arg.trim_start().strip_prefix(&self.prefix) {
self.inner.execute(rest, msg, ctx, bot).await
} else {
Ok(false)
}
}
}

View file

@ -1,93 +0,0 @@
use crate::api::packet::ParsedPacket;
use crate::api::{Data, SendEvent};
use crate::conn;
use super::command::{Command, Context};
use super::instance::{ConnSnapshot, InstanceConfig};
pub struct Commands<B, E> {
commands: Vec<Box<dyn Command<B, E> + Send + Sync>>,
fallthrough: bool,
}
impl<B, E> Commands<B, E> {
pub fn new() -> Self {
Self {
commands: vec![],
fallthrough: false,
}
}
/// Whether further commands should be executed after a command returns
/// `true`.
///
/// If disabled, commands are run until the first command that returns
/// `true`. If enabled, all commands are run irrespective of their return
/// values.
pub fn fallthrough(&self) -> bool {
self.fallthrough
}
/// Set whether fallthrough is active.
///
/// See [`Self::fallthrough`] for more details.
pub fn set_fallthrough(&mut self, active: bool) {
self.fallthrough = active;
}
pub fn add<C>(&mut self, command: C)
where
C: Command<B, E> + Send + Sync + 'static,
{
self.commands.push(Box::new(command));
}
pub fn descriptions(&self, ctx: &Context) -> Vec<String> {
self.commands
.iter()
.filter_map(|c| c.description(ctx))
.collect::<Vec<_>>()
}
/// Returns `true` if one or more commands returned `true`, `false`
/// otherwise.
pub async fn handle_packet(
&self,
config: &InstanceConfig,
packet: &ParsedPacket,
snapshot: &ConnSnapshot,
bot: &mut B,
) -> Result<bool, E> {
let msg = match &packet.content {
Ok(Data::SendEvent(SendEvent(msg))) => msg,
_ => return Ok(false),
};
let joined = match &snapshot.state {
conn::State::Joining(_) => return Ok(false),
conn::State::Joined(joined) => joined.clone(),
};
let ctx = Context {
config: config.clone(),
conn_tx: snapshot.conn_tx.clone(),
joined,
};
let mut handled = false;
for command in &self.commands {
handled = handled || command.execute(&msg.content, msg, &ctx, bot).await?;
if !self.fallthrough && handled {
break;
}
}
Ok(handled)
}
}
impl<B, E> Default for Commands<B, E> {
fn default() -> Self {
Self::new()
}
}

View file

@ -1,534 +0,0 @@
//! A single instance of a bot in a single room.
//!
//! See [`Instance`] for more details.
use std::convert::Infallible;
use std::fmt;
use std::str::FromStr;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use cookie::{Cookie, CookieJar};
use tokio::select;
use tokio::sync::{mpsc, oneshot};
use tokio_tungstenite::tungstenite;
use tokio_tungstenite::tungstenite::http::{HeaderValue, StatusCode};
use crate::api::packet::ParsedPacket;
use crate::api::{Auth, AuthOption, Data, Nick};
use crate::conn::{self, Conn, ConnTx, State};
macro_rules! ilog {
( $conf:expr, $target:expr, $($arg:tt)+ ) => {
::log::log!(
target: &format!("euphoxide::live::{}", $conf.name),
$target,
$($arg)+
);
};
}
macro_rules! idebug {
( $conf:expr, $($arg:tt)+ ) => {
ilog!($conf, ::log::Level::Debug, $($arg)+);
};
}
macro_rules! iinfo {
( $conf:expr, $($arg:tt)+ ) => {
ilog!($conf, ::log::Level::Info, $($arg)+);
};
}
macro_rules! iwarn {
( $conf:expr, $($arg:tt)+ ) => {
ilog!($conf, ::log::Level::Warn, $($arg)+);
};
}
/// Settings that are usually shared between all instances connecting to a
/// specific server.
#[derive(Clone)]
pub struct ServerConfig {
/// How long to wait for the server until an operation is considered timed
/// out.
///
/// This timeout applies to waiting for reply packets to command packets
/// sent by the client, as well as operations like connecting or closing a
/// connection.
pub timeout: Duration,
/// How long to wait until reconnecting after an unsuccessful attempt to
/// connect.
pub reconnect_delay: Duration,
/// Domain name, to be used with [`Conn::connect`].
pub domain: String,
/// Cookies to use when connecting. They are updated with the server's reply
/// after successful connection attempts.
pub cookies: Arc<Mutex<CookieJar>>,
}
impl ServerConfig {
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
pub fn reconnect_delay(mut self, reconnect_delay: Duration) -> Self {
self.reconnect_delay = reconnect_delay;
self
}
pub fn domain<S: ToString>(mut self, domain: S) -> Self {
self.domain = domain.to_string();
self
}
pub fn cookies(mut self, cookies: Arc<Mutex<CookieJar>>) -> Self {
self.cookies = cookies;
self
}
pub fn room<S: ToString>(self, room: S) -> InstanceConfig {
InstanceConfig::new(self, room)
}
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
timeout: Duration::from_secs(30),
reconnect_delay: Duration::from_secs(30),
domain: "euphoria.leet.nu".to_string(),
cookies: Arc::new(Mutex::new(CookieJar::new())),
}
}
}
struct Hidden;
impl fmt::Debug for Hidden {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "<hidden>")
}
}
impl fmt::Debug for ServerConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ServerConfig")
.field("timeout", &self.timeout)
.field("reconnect_delay", &self.reconnect_delay)
.field("domain", &self.domain)
.field("cookies", &Hidden)
.finish()
}
}
/// Settings that are usually specific to a single instance.
#[derive(Debug, Clone)]
pub struct InstanceConfig {
pub server: ServerConfig,
/// Unique name of this instance.
pub name: String,
/// Room name, to be used with [`Conn::connect`].
pub room: String,
/// Whether the instance should connect as human or bot.
pub human: bool,
/// Username to set upon connecting.
pub username: Option<String>,
/// Whether to set the username even if the server reports that the session
/// already has a username set.
pub force_username: bool,
/// Password to use if room requires authentication.
pub password: Option<String>,
}
impl InstanceConfig {
pub fn new<S: ToString>(server: ServerConfig, room: S) -> Self {
Self {
server,
name: room.to_string(),
room: room.to_string(),
human: false,
username: None,
force_username: false,
password: None,
}
}
pub fn name<S: ToString>(mut self, name: S) -> Self {
self.name = name.to_string();
self
}
pub fn human(mut self, human: bool) -> Self {
self.human = human;
self
}
pub fn username<S: ToString>(mut self, username: Option<S>) -> Self {
self.username = username.map(|s| s.to_string());
self
}
pub fn force_username(mut self, force_username: bool) -> Self {
self.force_username = force_username;
self
}
pub fn password<S: ToString>(mut self, password: Option<S>) -> Self {
self.password = password.map(|s| s.to_string());
self
}
/// Create a new instance using this config.
///
/// See [`Instance::new`] for more details.
pub fn build<F>(self, on_event: F) -> Instance
where
F: Fn(Event) + Send + Sync + 'static,
{
Instance::new(self, on_event)
}
}
/// Snapshot of a [`Conn`]'s state immediately after receiving a packet.
#[derive(Debug, Clone)]
pub struct ConnSnapshot {
pub conn_tx: ConnTx,
pub state: State,
}
impl ConnSnapshot {
fn from_conn(conn: &Conn) -> Self {
Self {
conn_tx: conn.tx().clone(),
state: conn.state().clone(),
}
}
}
// Most of the time, the largest variant (`Packet`) is sent. The size of this
// enum is not critical anyways since it's not constructed that often.
#[allow(clippy::large_enum_variant)]
/// An event emitted by an [`Instance`].
///
/// Events are emitted by a single instance following this schema, written in
/// pseudo-regex syntax:
/// ```text
/// (Connecting (Connected Packet*)? Disconnected)* Stopped
/// ```
///
/// In particular, this means that every [`Self::Connecting`] is always followed
/// by exactly one [`Self::Disconnected`], and that [`Self::Stopped`] is always
/// the last event and is always sent exactly once per instance.
#[derive(Debug)]
pub enum Event {
Connecting(InstanceConfig),
Connected(InstanceConfig, ConnSnapshot),
Packet(InstanceConfig, ParsedPacket, ConnSnapshot),
Disconnected(InstanceConfig),
Stopped(InstanceConfig),
}
impl Event {
pub fn config(&self) -> &InstanceConfig {
match self {
Self::Connecting(config) => config,
Self::Connected(config, _) => config,
Self::Packet(config, _, _) => config,
Self::Disconnected(config) => config,
Self::Stopped(config) => config,
}
}
}
enum Request {
GetConnTx(oneshot::Sender<ConnTx>),
Stop,
}
/// An error that occurred inside an [`Instance`] while it was running.
enum RunError {
StoppedManually,
InstanceDropped,
CouldNotConnect(conn::Error),
Conn(conn::Error),
}
/// A single instance of a bot in a single room.
///
/// The instance automatically connects to its room once it is created, and it
/// reconnects when it loses connection. If the room requires authentication and
/// a password is given, the instance automatically authenticates. If a nick is
/// given, the instance sets its nick upon joining the room.
///
/// An instance has a unique name used for logging and identifying the instance.
/// The room name can be used as the instance name if there is never more than
/// one instance per room.
///
/// An instance can be created using [`Instance::new`] or using
/// [`InstanceConfig::build`].
///
/// An instance can be stopped using [`Instance::stop`] or by dropping it. In
/// either case, the last event the instance sends will be an
/// [`Event::Stopped`]. If it is not stopped using one of these two ways, it
/// will continue to run and reconnect indefinitely.
#[derive(Debug, Clone)]
pub struct Instance {
config: InstanceConfig,
request_tx: mpsc::UnboundedSender<Request>,
// In theory, request_tx should be sufficient as canary, but I'm not sure
// exactly how to check it during the reconnect timeout.
_canary_tx: mpsc::UnboundedSender<Infallible>,
}
impl Instance {
// Previously, the event callback was asynchronous and would return a result. It
// was called in-line to calling Conn::recv. The idea was that the instance
// would stop if the event handler returned Err. This was, however, not even
// implemented correctly and the instance would just reconnect.
//
// The new event handler is synchronous. This way, it becomes harder to
// accidentally block Conn::recv, for example by waiting for a channel with
// limited capacity. If async code must be executed upon receiving a command,
// the user can start a task from inside the handler.
//
// The new event handler does not return anything. This makes the code nicer. In
// the use cases I'm thinking of, it should not be a problem: If the event
// handler encounters errors, there's usually other ways to tell the same. Make
// the event handler ignore the errors and stop the instance in that other way.
/// Create a new instance based on an [`InstanceConfig`].
///
/// The `on_event` parameter is called whenever the instance wants to emit
/// an [`Event`]. It must not block for long. See [`Event`] for more details
/// on the events and the order in which they are emitted.
///
/// [`InstanceConfig::build`] can be used in place of this function.
pub fn new<F>(config: InstanceConfig, on_event: F) -> Self
where
F: Fn(Event) + Send + Sync + 'static,
{
idebug!(config, "Created with config {config:?}");
let (request_tx, request_rx) = mpsc::unbounded_channel();
let (canary_tx, canary_rx) = mpsc::unbounded_channel();
tokio::spawn(Self::run::<F>(
config.clone(),
on_event,
request_rx,
canary_rx,
));
Self {
config,
request_tx,
_canary_tx: canary_tx,
}
}
pub fn config(&self) -> &InstanceConfig {
&self.config
}
/// Retrieve the instance's current connection.
///
/// Returns `None` if the instance is currently not connected, or has
/// stopped running.
pub async fn conn_tx(&self) -> Option<ConnTx> {
let (tx, rx) = oneshot::channel();
let _ = self.request_tx.send(Request::GetConnTx(tx));
rx.await.ok()
}
/// Stop the instance.
///
/// For more info on stopping instances, see [`Instance`].
pub fn stop(&self) {
let _ = self.request_tx.send(Request::Stop);
}
/// Whether this instance is stopped.
///
/// For more info on stopping instances, see [`Instance`].
pub fn stopped(&self) -> bool {
self.request_tx.is_closed()
}
async fn run<F: Fn(Event)>(
config: InstanceConfig,
on_event: F,
request_rx: mpsc::UnboundedReceiver<Request>,
mut canary_rx: mpsc::UnboundedReceiver<Infallible>,
) {
select! {
_ = Self::stay_connected(&config, &on_event, request_rx) => (),
_ = canary_rx.recv() => { idebug!(config, "Instance dropped"); },
}
on_event(Event::Stopped(config))
}
async fn stay_connected<F: Fn(Event)>(
config: &InstanceConfig,
on_event: &F,
mut request_rx: mpsc::UnboundedReceiver<Request>,
) {
loop {
idebug!(config, "Connecting...");
on_event(Event::Connecting(config.clone()));
let result = Self::run_once::<F>(config, on_event, &mut request_rx).await;
on_event(Event::Disconnected(config.clone()));
let connected = match result {
Ok(()) => {
idebug!(config, "Connection closed normally");
true
}
Err(RunError::StoppedManually) => {
idebug!(config, "Instance stopped manually");
break;
}
Err(RunError::InstanceDropped) => {
idebug!(config, "Instance dropped");
break;
}
Err(RunError::CouldNotConnect(conn::Error::Tungstenite(
tungstenite::Error::Http(response),
))) if response.status() == StatusCode::NOT_FOUND => {
iwarn!(config, "Failed to connect: room does not exist");
break;
}
Err(RunError::CouldNotConnect(err)) => {
iwarn!(config, "Failed to connect: {err}");
false
}
Err(RunError::Conn(err)) => {
iwarn!(config, "An error occurred: {err}");
true
}
};
if !connected {
let s = config.server.reconnect_delay.as_secs();
idebug!(config, "Waiting {s} seconds before reconnecting");
tokio::time::sleep(config.server.reconnect_delay).await;
}
}
}
fn get_cookies(config: &InstanceConfig) -> HeaderValue {
let guard = config.server.cookies.lock().unwrap();
let cookies = guard
.iter()
.map(|c| format!("{}", c.stripped()))
.collect::<Vec<_>>()
.join("; ");
drop(guard);
cookies.try_into().unwrap()
}
fn set_cookies(config: &InstanceConfig, cookies: Vec<HeaderValue>) {
idebug!(config, "Updating cookies");
let mut guard = config.server.cookies.lock().unwrap();
for cookie in cookies {
if let Ok(cookie) = cookie.to_str() {
if let Ok(cookie) = Cookie::from_str(cookie) {
guard.add(cookie);
}
}
}
}
async fn run_once<F: Fn(Event)>(
config: &InstanceConfig,
on_event: &F,
request_rx: &mut mpsc::UnboundedReceiver<Request>,
) -> Result<(), RunError> {
let (mut conn, cookies) = Conn::connect(
&config.server.domain,
&config.room,
config.human,
Some(Self::get_cookies(config)),
config.server.timeout,
)
.await
.map_err(RunError::CouldNotConnect)?;
Self::set_cookies(config, cookies);
on_event(Event::Connected(
config.clone(),
ConnSnapshot::from_conn(&conn),
));
let conn_tx = conn.tx().clone();
select! {
r = Self::receive::<F>(config, &mut conn, on_event) => r,
r = Self::handle_requests(request_rx, &conn_tx) => Err(r),
}
}
async fn receive<F: Fn(Event)>(
config: &InstanceConfig,
conn: &mut Conn,
on_event: &F,
) -> Result<(), RunError> {
loop {
let packet = conn.recv().await.map_err(RunError::Conn)?;
let snapshot = ConnSnapshot::from_conn(conn);
match &packet.content {
Ok(Data::SnapshotEvent(snapshot)) => {
if let Some(username) = &config.username {
if config.force_username || snapshot.nick.is_none() {
idebug!(config, "Setting nick to username {username}");
let name = username.to_string();
conn.tx().send_only(Nick { name });
} else if let Some(nick) = &snapshot.nick {
idebug!(config, "Not setting nick, already set to {nick}");
}
}
}
Ok(Data::BounceEvent(_)) => {
if let Some(password) = &config.password {
idebug!(config, "Authenticating with password");
let cmd = Auth {
r#type: AuthOption::Passcode,
passcode: Some(password.to_string()),
};
conn.tx().send_only(cmd);
} else {
iwarn!(config, "Auth required but no password configured");
}
}
Ok(Data::DisconnectEvent(ev)) => {
if ev.reason == "authentication changed" {
iinfo!(config, "Disconnected because {}", ev.reason);
} else {
iwarn!(config, "Disconnected because {}", ev.reason);
}
}
_ => {}
}
on_event(Event::Packet(config.clone(), packet, snapshot));
}
}
async fn handle_requests(
request_rx: &mut mpsc::UnboundedReceiver<Request>,
conn_tx: &ConnTx,
) -> RunError {
while let Some(request) = request_rx.recv().await {
match request {
Request::GetConnTx(tx) => {
let _ = tx.send(conn_tx.clone());
}
Request::Stop => return RunError::StoppedManually,
}
}
RunError::InstanceDropped
}
}

View file

@ -1,70 +0,0 @@
//! A convenient way to keep a [`ServerConfig`] and some [`Instance`]s.
use std::collections::HashMap;
use super::instance::{self, Instance, ServerConfig};
/// A convenient way to keep a [`ServerConfig`] and some [`Instance`]s.
pub struct Instances {
server_config: ServerConfig,
instances: HashMap<String, Instance>,
}
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<Item = &Instance> {
self.instances.values()
}
/// Check if an event comes from an instance whose name is known.
///
/// Assuming every instance has a unique name, events from unknown instances
/// should be discarded. This helps prevent "ghost instances" that were
/// stopped but haven't yet disconnected properly from influencing your
/// bot's state.
///
/// The user is responsible for ensuring that instances' names are unique.
pub fn is_from_known_instance(&self, event: &instance::Event) -> bool {
self.instances.contains_key(&event.config().name)
}
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<Instance> {
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());
}
}

View file

@ -1,644 +0,0 @@
//! Connection state modeling.
use std::collections::HashMap;
use std::convert::Infallible;
use std::future::Future;
use std::time::{Duration, Instant};
use std::{error, fmt, result};
use futures_util::SinkExt;
use jiff::Timestamp;
use log::debug;
use tokio::net::TcpStream;
use tokio::select;
use tokio::sync::{mpsc, oneshot};
use tokio_stream::StreamExt;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::http::{header, HeaderValue};
use tokio_tungstenite::{tungstenite, MaybeTlsStream, WebSocketStream};
use crate::api::packet::{Command, ParsedPacket};
use crate::api::{
BounceEvent, Data, HelloEvent, LoginReply, NickEvent, PersonalAccountView, Ping, PingReply,
SessionId, SessionView, SnapshotEvent, Time, UserId,
};
use crate::replies::{self, PendingReply, Replies};
pub type WsStream = WebSocketStream<MaybeTlsStream<TcpStream>>;
#[derive(Debug)]
pub enum Error {
/// The connection is now closed.
ConnectionClosed,
/// The connection was not opened in time.
ConnectionTimedOut,
/// The server didn't reply to one of our commands in time.
CommandTimedOut,
/// The server did something that violated the api specification.
ProtocolViolation(&'static str),
/// An error returned by the euphoria server.
Euph(String),
Tungstenite(tungstenite::Error),
SerdeJson(serde_json::Error),
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::ConnectionClosed => write!(f, "connection closed"),
Self::ConnectionTimedOut => write!(f, "connection did not open in time"),
Self::CommandTimedOut => write!(f, "server did not reply to command in time"),
Self::ProtocolViolation(msg) => write!(f, "{msg}"),
Self::Euph(msg) => write!(f, "{msg}"),
Self::Tungstenite(err) => write!(f, "{err}"),
Self::SerdeJson(err) => write!(f, "{err}"),
}
}
}
impl From<tungstenite::Error> for Error {
fn from(err: tungstenite::Error) -> Self {
Self::Tungstenite(err)
}
}
impl From<serde_json::Error> for Error {
fn from(err: serde_json::Error) -> Self {
Self::SerdeJson(err)
}
}
impl error::Error for Error {}
pub type Result<T> = result::Result<T, Error>;
#[derive(Debug, Clone)]
pub struct Joining {
pub since: Timestamp,
pub hello: Option<HelloEvent>,
pub snapshot: Option<SnapshotEvent>,
pub bounce: Option<BounceEvent>,
}
impl Joining {
fn new() -> Self {
Self {
since: Timestamp::now(),
hello: None,
snapshot: None,
bounce: None,
}
}
fn on_data(&mut self, data: &Data) -> Result<()> {
match data {
Data::BounceEvent(p) => self.bounce = Some(p.clone()),
Data::HelloEvent(p) => self.hello = Some(p.clone()),
Data::SnapshotEvent(p) => self.snapshot = Some(p.clone()),
// TODO Check and maybe expand list of unexpected packet types
Data::JoinEvent(_)
| Data::NetworkEvent(_)
| Data::NickEvent(_)
| Data::EditMessageEvent(_)
| Data::PartEvent(_)
| Data::PmInitiateEvent(_)
| Data::SendEvent(_) => return Err(Error::ProtocolViolation("unexpected packet type")),
_ => {}
}
Ok(())
}
fn joined(&self) -> Option<Joined> {
if let (Some(hello), Some(snapshot)) = (&self.hello, &self.snapshot) {
let mut session = hello.session.clone();
if let Some(nick) = &snapshot.nick {
session.name = nick.clone();
}
let listing = snapshot
.listing
.iter()
.cloned()
.map(|s| (s.session_id.clone(), SessionInfo::Full(s)))
.collect::<HashMap<_, _>>();
Some(Joined {
since: Timestamp::now(),
session,
account: hello.account.clone(),
listing,
})
} else {
None
}
}
}
#[derive(Debug, Clone)]
pub enum SessionInfo {
Full(SessionView),
Partial(NickEvent),
}
impl SessionInfo {
pub fn id(&self) -> &UserId {
match self {
Self::Full(sess) => &sess.id,
Self::Partial(nick) => &nick.id,
}
}
pub fn session_id(&self) -> &SessionId {
match self {
Self::Full(sess) => &sess.session_id,
Self::Partial(nick) => &nick.session_id,
}
}
pub fn name(&self) -> &str {
match self {
Self::Full(sess) => &sess.name,
Self::Partial(nick) => &nick.to,
}
}
}
#[derive(Debug, Clone)]
pub struct Joined {
pub since: Timestamp,
pub session: SessionView,
pub account: Option<PersonalAccountView>,
pub listing: HashMap<SessionId, SessionInfo>,
}
impl Joined {
fn on_data(&mut self, data: &Data) {
match data {
Data::JoinEvent(p) => {
debug!("Updating listing after join-event");
self.listing
.insert(p.0.session_id.clone(), SessionInfo::Full(p.0.clone()));
}
Data::SendEvent(p) => {
debug!("Updating listing after send-event");
self.listing.insert(
p.0.sender.session_id.clone(),
SessionInfo::Full(p.0.sender.clone()),
);
}
Data::PartEvent(p) => {
debug!("Updating listing after part-event");
self.listing.remove(&p.0.session_id);
}
Data::NetworkEvent(p) => {
if p.r#type == "partition" {
debug!("Updating listing after network-event with type partition");
self.listing.retain(|_, s| match s {
SessionInfo::Full(s) => {
s.server_id != p.server_id && s.server_era != p.server_era
}
// We can't know if the session was disconnected by the
// partition or not, so we're erring on the side of
// caution and assuming they were kicked. If we're
// wrong, we'll re-add the session as soon as it
// performs another visible action.
//
// If we always kept such sessions, we might keep
// disconnected ones indefinitely, thereby keeping them
// from moving on, instead forever tethering them to the
// digital realm.
SessionInfo::Partial(_) => false,
});
}
}
Data::NickEvent(p) => {
debug!("Updating listing after nick-event");
self.listing
.entry(p.session_id.clone())
.and_modify(|s| match s {
SessionInfo::Full(session) => session.name = p.to.clone(),
SessionInfo::Partial(_) => *s = SessionInfo::Partial(p.clone()),
})
.or_insert_with(|| SessionInfo::Partial(p.clone()));
}
Data::NickReply(p) => {
debug!("Updating own session after nick-reply");
assert_eq!(self.session.id, p.id);
self.session.name = p.to.clone();
}
// The who reply is broken and can't be trusted right now, so we'll
// not even look at it.
_ => {}
}
}
}
#[derive(Debug, Clone)]
#[allow(clippy::large_enum_variant)]
pub enum State {
Joining(Joining),
Joined(Joined),
}
impl State {
pub fn into_joining(self) -> Option<Joining> {
match self {
Self::Joining(joining) => Some(joining),
Self::Joined(_) => None,
}
}
pub fn into_joined(self) -> Option<Joined> {
match self {
Self::Joining(_) => None,
Self::Joined(joined) => Some(joined),
}
}
pub fn joining(&self) -> Option<&Joining> {
match self {
Self::Joining(joining) => Some(joining),
Self::Joined(_) => None,
}
}
pub fn joined(&self) -> Option<&Joined> {
match self {
Self::Joining(_) => None,
Self::Joined(joined) => Some(joined),
}
}
}
#[allow(clippy::large_enum_variant)]
enum ConnCommand {
SendCmd(Data, oneshot::Sender<PendingReply<ParsedPacket>>),
GetState(oneshot::Sender<State>),
}
#[derive(Debug, Clone)]
pub struct ConnTx {
cmd_tx: mpsc::UnboundedSender<ConnCommand>,
}
impl ConnTx {
/// The async part of sending a command.
///
/// This is split into a separate function so that [`Self::send`] can be
/// fully synchronous (you can safely throw away the returned future) while
/// still guaranteeing that the packet was sent.
async fn finish_send<C>(rx: oneshot::Receiver<PendingReply<ParsedPacket>>) -> Result<C::Reply>
where
C: Command,
C::Reply: TryFrom<Data>,
{
let pending_reply = rx
.await
// This should only happen if something goes wrong during encoding
// of the packet or while sending it through the websocket. Assuming
// the first doesn't happen, the connection is probably closed.
.map_err(|_| Error::ConnectionClosed)?;
let data = pending_reply
.get()
.await
.map_err(|e| match e {
replies::Error::TimedOut => Error::CommandTimedOut,
replies::Error::Canceled => Error::ConnectionClosed,
})?
.content
.map_err(Error::Euph)?;
data.try_into()
.map_err(|_| Error::ProtocolViolation("incorrect command reply type"))
}
/// Send a command to the server.
///
/// Returns a future containing the server's reply. This future does not
/// have to be awaited and can be safely ignored if you are not interested
/// in the reply.
///
/// This function may return before the command was sent. To ensure that it
/// was sent before doing something else, await the returned future first.
///
/// When called multiple times, this function guarantees that the commands
/// are sent in the order that the function is called.
pub fn send<C>(&self, cmd: C) -> impl Future<Output = Result<C::Reply>>
where
C: Command + Into<Data>,
C::Reply: TryFrom<Data>,
{
let (tx, rx) = oneshot::channel();
let _ = self.cmd_tx.send(ConnCommand::SendCmd(cmd.into(), tx));
Self::finish_send::<C>(rx)
}
/// Like [`Self::send`] but ignoring the server's reply.
pub fn send_only<C: Into<Data>>(&self, cmd: C) {
let (tx, _) = oneshot::channel();
let _ = self.cmd_tx.send(ConnCommand::SendCmd(cmd.into(), tx));
}
pub async fn state(&self) -> Result<State> {
let (tx, rx) = oneshot::channel();
self.cmd_tx
.send(ConnCommand::GetState(tx))
.map_err(|_| Error::ConnectionClosed)?;
rx.await.map_err(|_| Error::ConnectionClosed)
}
}
#[derive(Debug)]
pub struct Conn {
ws: WsStream,
last_id: usize,
replies: Replies<String, ParsedPacket>,
conn_tx: ConnTx,
cmd_rx: mpsc::UnboundedReceiver<ConnCommand>,
// The websocket server may send a pong frame with arbitrary payload
// unprompted at any time (see RFC 6455 5.5.3). Because of this, we can't
// just remember the last pong payload.
last_ping: Instant,
last_ws_ping_payload: Option<Vec<u8>>,
last_ws_ping_replied_to: bool,
last_euph_ping_payload: Option<Time>,
last_euph_ping_replied_to: bool,
state: State,
}
enum ConnEvent {
Ws(Option<tungstenite::Result<tungstenite::Message>>),
Cmd(Option<ConnCommand>),
Ping,
}
impl Conn {
pub fn tx(&self) -> &ConnTx {
&self.conn_tx
}
pub fn state(&self) -> &State {
&self.state
}
pub async fn recv(&mut self) -> Result<ParsedPacket> {
loop {
self.replies.purge();
let timeout = self.replies.timeout();
// All of these functions are cancel-safe.
let event = select! {
msg = self.ws.next() => ConnEvent::Ws(msg),
cmd = self.cmd_rx.recv() => ConnEvent::Cmd(cmd),
_ = Self::await_next_ping(self.last_ping, timeout) => ConnEvent::Ping,
};
match event {
ConnEvent::Ws(msg) => {
if let Some(packet) = self.on_ws(msg).await? {
break Ok(packet);
}
}
ConnEvent::Cmd(Some(cmd)) => self.on_cmd(cmd).await?,
ConnEvent::Cmd(None) => unreachable!("self contains a ConnTx"),
ConnEvent::Ping => self.on_ping().await?,
}
}
}
async fn on_ws(
&mut self,
msg: Option<tungstenite::Result<tungstenite::Message>>,
) -> Result<Option<ParsedPacket>> {
let msg = msg.ok_or(Error::ConnectionClosed)??;
match msg {
tungstenite::Message::Text(text) => {
let packet = serde_json::from_str(&text)?;
debug!(target: "euphoxide::conn::full", "Received {packet:?}");
let packet = ParsedPacket::from_packet(packet)?;
self.on_packet(&packet).await?;
return Ok(Some(packet));
}
tungstenite::Message::Binary(_) => {
return Err(Error::ProtocolViolation("unexpected binary ws message"));
}
tungstenite::Message::Ping(_) => {}
tungstenite::Message::Pong(payload) => {
if self.last_ws_ping_payload == Some(payload.to_vec()) {
self.last_ws_ping_replied_to = true;
}
}
tungstenite::Message::Close(_) => {}
tungstenite::Message::Frame(_) => {}
}
Ok(None)
}
async fn on_packet(&mut self, packet: &ParsedPacket) -> Result<()> {
// Complete pending replies if the packet has an id
if let Some(id) = &packet.id {
debug!("Resolving pending reply for id {id}");
self.replies.complete(id, packet.clone());
}
if let Ok(data) = &packet.content {
self.on_data(&packet.id, data).await?;
}
Ok(())
}
async fn on_data(&mut self, id: &Option<String>, data: &Data) -> Result<()> {
// Play a game of table tennis
match data {
Data::PingReply(p) => {
if self.last_euph_ping_payload.is_some() && self.last_euph_ping_payload == p.time {
self.last_euph_ping_replied_to = true;
}
}
Data::PingEvent(p) => {
let reply = PingReply { time: Some(p.time) };
self.send_rpl(id.clone(), reply.into()).await?;
}
_ => {}
}
// Update internal state
match &mut self.state {
State::Joining(joining) => {
joining.on_data(data)?;
if let Some(joined) = joining.joined() {
self.state = State::Joined(joined);
}
}
State::Joined(joined) => joined.on_data(data),
}
// The euphoria server doesn't always disconnect the client when it
// would make sense to do so or when the API specifies it should. This
// ensures we always disconnect when it makes sense to do so.
if matches!(
data,
Data::DisconnectEvent(_)
| Data::LoginEvent(_)
| Data::LogoutEvent(_)
| Data::LoginReply(LoginReply { success: true, .. })
| Data::LogoutReply(_)
) {
self.disconnect().await?;
}
Ok(())
}
async fn on_cmd(&mut self, cmd: ConnCommand) -> Result<()> {
match cmd {
ConnCommand::SendCmd(data, reply_tx) => self.send_cmd(data, reply_tx).await?,
ConnCommand::GetState(reply_tx) => {
let _ = reply_tx.send(self.state.clone());
}
}
Ok(())
}
async fn await_next_ping(last_ping: Instant, timeout: Duration) {
let next_ping = last_ping + timeout;
tokio::time::sleep_until(next_ping.into()).await;
}
async fn on_ping(&mut self) -> Result<()> {
debug!("Checking ping replies and sending new pings");
// Check previous pings
if self.last_ws_ping_payload.is_some() && !self.last_ws_ping_replied_to {
debug!("Server did not respond to websocket ping, disconnecting");
self.disconnect().await?;
}
if self.last_euph_ping_payload.is_some() && !self.last_euph_ping_replied_to {
debug!("Server did not respond to euph ping, disconnecting");
self.disconnect().await?;
}
let now = Timestamp::now();
// Send new ws ping
let ws_payload = now.as_millisecond().to_be_bytes().to_vec();
self.last_ws_ping_payload = Some(ws_payload.clone());
self.last_ws_ping_replied_to = false;
self.ws
.send(tungstenite::Message::Ping(ws_payload.into()))
.await?;
// Send new euph ping
let euph_payload = Time::from_timestamp(now);
self.last_euph_ping_payload = Some(euph_payload);
self.last_euph_ping_replied_to = false;
let (tx, _) = oneshot::channel();
self.send_cmd(Ping { time: euph_payload }.into(), tx)
.await?;
self.last_ping = Instant::now();
Ok(())
}
async fn send_cmd(
&mut self,
data: Data,
reply_tx: oneshot::Sender<PendingReply<ParsedPacket>>,
) -> Result<()> {
// Overkill of universe-heat-death-like proportions
self.last_id = self.last_id.wrapping_add(1);
let id = format!("{}", self.last_id);
let packet = ParsedPacket {
id: Some(id.clone()),
r#type: data.packet_type(),
content: Ok(data),
throttled: None,
}
.into_packet()?;
debug!(target: "euphoxide::conn::full", "Sending {packet:?}");
let msg = tungstenite::Message::Text(serde_json::to_string(&packet)?.into());
self.ws.send(msg).await?;
let _ = reply_tx.send(self.replies.wait_for(id));
Ok(())
}
async fn send_rpl(&mut self, id: Option<String>, data: Data) -> Result<()> {
let packet = ParsedPacket {
id,
r#type: data.packet_type(),
content: Ok(data),
throttled: None,
}
.into_packet()?;
debug!(target: "euphoxide::conn::full", "Sending {packet:?}");
let msg = tungstenite::Message::Text(serde_json::to_string(&packet)?.into());
self.ws.send(msg).await?;
Ok(())
}
async fn disconnect(&mut self) -> Result<Infallible> {
let _ = tokio::time::timeout(self.replies.timeout(), self.ws.close(None)).await;
debug!("Closed connection");
Err(Error::ConnectionClosed)
}
pub fn wrap(ws: WsStream, timeout: Duration) -> Self {
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel();
Self {
ws,
last_id: 0,
replies: Replies::new(timeout),
conn_tx: ConnTx { cmd_tx },
cmd_rx,
last_ping: Instant::now(), // Wait a bit before first pings
last_ws_ping_payload: None,
last_ws_ping_replied_to: false,
last_euph_ping_payload: None,
last_euph_ping_replied_to: false,
state: State::Joining(Joining::new()),
}
}
pub async fn connect(
domain: &str,
room: &str,
human: bool,
cookies: Option<HeaderValue>,
timeout: Duration,
) -> Result<(Self, Vec<HeaderValue>)> {
let human = if human { "?h=1" } else { "" };
let uri = format!("wss://{domain}/room/{room}/ws{human}");
debug!("Connecting to {uri} with cookies: {cookies:?}");
let mut request = uri.into_client_request().expect("valid request");
if let Some(cookies) = cookies {
request.headers_mut().append(header::COOKIE, cookies);
}
let (ws, response) =
tokio::time::timeout(timeout, tokio_tungstenite::connect_async(request))
.await
.map_err(|_| Error::ConnectionTimedOut)??;
let (mut parts, _) = response.into_parts();
let cookies_set = match parts.headers.entry(header::SET_COOKIE) {
header::Entry::Occupied(entry) => entry.remove_entry_mult().1.collect(),
header::Entry::Vacant(_) => vec![],
};
debug!("Received cookies {cookies_set:?}");
let rx = Self::wrap(ws, timeout);
Ok((rx, cookies_set))
}
}

View file

@ -1,195 +0,0 @@
//! All emoji the euphoria.leet.nu client knows.
use std::borrow::Cow;
use std::collections::HashMap;
use std::ops::RangeInclusive;
/// Euphoria.leet.nu emoji list, obtainable via shell command:
///
/// ```bash
/// curl 'https://euphoria.leet.nu/static/emoji.json' \
/// | jq 'to_entries | sort_by(.key) | from_entries' \
/// > emoji.json
/// ```
const EMOJI_JSON: &str = include_str!("emoji.json");
/// A map from emoji names to their unicode representation. Not all emojis have
/// such a representation.
pub struct Emoji(pub HashMap<String, Option<String>>);
fn parse_hex_to_char(hex: &str) -> Option<char> {
u32::from_str_radix(hex, 16).ok()?.try_into().ok()
}
fn parse_code_points(code_points: &str) -> Option<String> {
code_points
.split('-')
.map(parse_hex_to_char)
.collect::<Option<String>>()
}
impl Emoji {
/// Load a list of emoji compiled into the library.
pub fn load() -> Self {
Self::load_from_json(EMOJI_JSON).unwrap()
}
/// Load a list of emoji from a string containing a JSON object.
///
/// The object keys are the emoji names (without colons `:`). The object
/// values are the emoji code points encoded as hexadecimal numbers and
/// separated by a dash `-` (e.g. `"34-fe0f-20e3"`). Emojis whose values
/// don't match this schema are interpreted as emojis without unicode
/// representation.
pub fn load_from_json(json: &str) -> Option<Self> {
let map = serde_json::from_str::<HashMap<String, String>>(json)
.ok()?
.into_iter()
.map(|(k, v)| (k, parse_code_points(&v)))
.collect::<HashMap<_, _>>();
Some(Self(map))
}
pub fn get(&self, name: &str) -> Option<Option<&str>> {
match self.0.get(name) {
Some(Some(replace)) => Some(Some(replace)),
Some(None) => Some(None),
None => None,
}
}
pub fn find(&self, text: &str) -> Vec<(RangeInclusive<usize>, Option<&str>)> {
let mut result = vec![];
let mut prev_colon_idx = None;
for (colon_idx, _) in text.match_indices(':') {
if let Some(prev_idx) = prev_colon_idx {
let name = &text[prev_idx + 1..colon_idx];
if let Some(replace) = self.get(name) {
let range = prev_idx..=colon_idx;
result.push((range, replace));
prev_colon_idx = None;
continue;
}
}
prev_colon_idx = Some(colon_idx);
}
result
}
pub fn replace<'a>(&self, text: &'a str) -> Cow<'a, str> {
let emoji = self.find(text);
if emoji.is_empty() {
return Cow::Borrowed(text);
}
let mut result = String::new();
let mut after_last_emoji = 0;
for (range, replace) in emoji {
// Only replace emoji with a replacement
if let Some(replace) = replace {
if *range.start() > after_last_emoji {
// There were non-emoji characters between the last and the
// current emoji.
result.push_str(&text[after_last_emoji..*range.start()]);
}
result.push_str(replace);
after_last_emoji = range.end() + 1;
}
}
if after_last_emoji < text.len() {
result.push_str(&text[after_last_emoji..]);
}
Cow::Owned(result)
}
pub fn remove<'a>(&self, text: &'a str) -> Cow<'a, str> {
let emoji = self.find(text);
if emoji.is_empty() {
return Cow::Borrowed(text);
}
let mut result = String::new();
let mut after_last_emoji = 0;
for (range, _) in emoji {
if *range.start() > after_last_emoji {
// There were non-emoji characters between the last and the
// current emoji.
result.push_str(&text[after_last_emoji..*range.start()]);
}
after_last_emoji = range.end() + 1;
}
if after_last_emoji < text.len() {
result.push_str(&text[after_last_emoji..]);
}
Cow::Owned(result)
}
}
#[cfg(test)]
mod test {
use super::Emoji;
#[test]
fn load_without_panic() {
Emoji::load();
}
#[test]
fn find() {
let emoji = Emoji::load();
// :bad: does not exist, while :x: and :o: do.
assert_eq!(emoji.find(":bad:x:o:"), vec![(4..=6, Some(""))]);
assert_eq!(
emoji.find(":x:bad:o:"),
vec![(0..=2, Some("")), (6..=8, Some(""))]
);
assert_eq!(emoji.find("ab:bad:x:o:cd"), vec![(6..=8, Some(""))]);
assert_eq!(
emoji.find("ab:x:bad:o:cd"),
vec![(2..=4, Some("")), (8..=10, Some(""))]
);
}
#[test]
fn replace() {
let emoji = Emoji::load();
assert_eq!(emoji.replace("no:emo:ji:here"), "no:emo:ji:here");
assert_eq!(emoji.replace(":bad:x:o:"), ":bad❌o:");
assert_eq!(emoji.replace(":x:bad:o:"), "❌bad⭕");
assert_eq!(emoji.replace("ab:bad:x:o:cd"), "ab:bad❌o:cd");
assert_eq!(emoji.replace("ab:x:bad:o:cd"), "ab❌bad⭕cd");
assert_eq!(emoji.replace("chm:crown::ant:"), "chm👑🐜");
assert_eq!(
emoji.replace(":waning_crescent_moon: (2% full)"),
"🌘 (2% full)"
);
assert_eq!(emoji.replace("Jan-20 17:58 Z"), "Jan-20 17:58 Z");
}
#[test]
fn remove() {
let emoji = Emoji::load();
assert_eq!(emoji.remove("no:emo:ji:here"), "no:emo:ji:here");
assert_eq!(emoji.remove(":bad:x:o:"), ":bado:");
assert_eq!(emoji.remove(":x:bad:o:"), "bad");
assert_eq!(emoji.remove("ab:bad:x:o:cd"), "ab:bado:cd");
assert_eq!(emoji.remove("ab:x:bad:o:cd"), "abbadcd");
assert_eq!(emoji.remove("chm:crown::ant:"), "chm");
assert_eq!(
emoji.remove(":waning_crescent_moon: (2% full)"),
" (2% full)"
);
assert_eq!(emoji.remove("Jan-20 17:58 Z"), "Jan-20 17:58 Z");
}
}