Compare commits

..

36 commits

Author SHA1 Message Date
7a292c429a Bump version to 0.6.1 2025-02-23 23:34:12 +01:00
095d2cea86 Fix nick hue hashing algorithm in some edge cases
When the nick consisted entirely of non-alphanumeric characters and
included at least one colon-delimited emoji, the hue hashing
reimplementation would produce an incorrect result because
colon-delimited emoji were removed at the wrong point in the hashing
process.
2025-02-23 22:34:23 +01:00
6eea194d52 Update emoji 2025-02-23 21:40:39 +01:00
4f7cc49b63 Bump version to 0.6.0 2025-02-21 00:40:15 +01:00
1d444684f7 Update dependencies 2025-02-20 20:16:50 +01:00
bc3e3b1e13 Update tokio-tungstenite 2025-02-20 20:16:11 +01:00
fe68694932 Fix time and duration formatting 2024-12-04 20:01:32 +01:00
58b55ef433 Fix link to euph api in docs 2024-12-04 18:56:52 +01:00
cdcf80ab9a Fix timestamps with too much precision being representable 2024-12-04 18:56:03 +01:00
7360bf96f8 Fix rustls panic in example bots 2024-12-04 18:52:29 +01:00
61f5559370 Document rustls panic in changelog 2024-12-04 18:24:18 +01:00
f973a819b6 Make botrulez submodules public
This allows users of the library to use the commands' Args structs.
2024-12-04 17:21:53 +01:00
8506a231dd Add more lints 2024-12-04 17:21:53 +01:00
85c93ee01d Update dependencies 2024-12-04 17:10:42 +01:00
4314a24e78 Switch to jiff from time 2024-12-04 17:08:52 +01:00
0256329f65 Bump version to 0.5.1 2024-05-20 18:54:48 +02:00
20b2aab209 Update dependencies 2024-05-20 18:53:55 +02:00
69a4a2c07f Update emoji
The new set was organically sourced from free-range JSON:

https://euphoria.leet.nu/static/emoji.json

Since I'm loading emoji from JSON anyways, the corresponding function
is also exposed. Clients may want to load the emoji list dynamically at
runtime instead of using the built-in emoji.
2024-05-20 18:49:40 +02:00
276ff68512 Bump version to 0.5.0 2023-12-27 00:18:44 +01:00
97fe80bca1 Update dependencies 2023-12-27 00:18:44 +01:00
37934b3af8 Format changelog with more whitespace 2023-12-27 00:06:40 +01:00
96743e26e2 Update emoji 2023-12-26 15:24:15 +01:00
2decee83e9 Switch euphoria.io (RIP) to euphoria.leet.nu
euphoria.io is dead for good now, and euphoria.leet.nu is the blessed
clone/fork that will take its place. May this crevice of the internet
survive another day.
2023-12-26 13:57:18 +01:00
fa6c8cdce9 Bump version to 0.4.0 2023-05-14 15:55:58 +02:00
ebdbb52f0d Update dependencies 2023-05-14 15:54:20 +02:00
09aed3181e Add whitespace todo 2023-05-14 14:55:47 +02:00
34f33ff038 Rename Snapshot to ConnSnapshot 2023-05-14 14:44:49 +02:00
0f217a6279 Fix euph errors always turning into conn::Errors 2023-04-08 20:22:19 +02:00
0c135844a4 Fix deserializing empty events and replies 2023-04-08 20:05:41 +02:00
c479cbd687 Mark all backwards-incompatible changes 2023-04-08 20:03:56 +02:00
768a259f02 Add timeout while opening connection
This should hopefully fix an issue where instances would get stuck in a
"Connecting" state after suspending the host machine or maybe just
connecting it to a different wifi. I've not managed to reliably
reproduce the bug, so this may not fix it at all.
2023-03-12 16:31:58 +01:00
373a98c26c Make Instance cloneable 2023-03-04 00:07:13 +01:00
5005e56881 Fix phone and mobile emoji 2023-03-01 01:22:25 +01:00
3aaef7ab11 Make command parsing helper function public 2023-02-27 14:07:06 +01:00
4479126500 Return whether command handled message 2023-02-27 12:41:49 +01:00
a6331d50b8 Implement Command for Uptime 2023-02-27 12:04:00 +01:00
29 changed files with 4387 additions and 1272 deletions

View file

@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
Procedure when bumping the version number:
1. Update dependencies in a separate commit
2. Set version number in `Cargo.toml`
3. Add new section in this changelog
@ -13,19 +14,108 @@ 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`
- `api::Time::as_timestamp`
- `bot::botrulez::full_help`
- `bot::botrulez::ping`
- `bot::botrulez::short_help`
- `bot::botrulez::uptime`
- `bot::botrulez::format_relative_time`
### Changed
- **(breaking)** Switched to `jiff` from `time`
- **(breaking)** `api::Time` contents are now an `i64`
- **(breaking)** Bumped `tokio-tungstenite` dependency from `0.18` to `0.24`. If
this causes a panic while using euphoxide, consider following the steps
mentioned in the [tokio-tungstenite README]. If I'm reading the [rustls docs]
correctly, it is on the users of the libraries to set the required features.
- `bot::botrulez::format_duration` now no longer mentions "since" or "ago", but
instead has a sign (`-`) if the duration is negative.
[tokio-tungstenite README]: https://github.com/snapview/tokio-tungstenite?tab=readme-ov-file#features
[rustls docs]: https://docs.rs/rustls/0.23.19/rustls/crypto/struct.CryptoProvider.html#using-the-per-process-default-cryptoprovider
### Removed
- `api::Time::new`
## v0.5.1 - 2024-05-20
### Added
- `Emoji::load_from_json`
### Changed
- Updated set of emoji names
## v0.5.0 - 2023-12-27
### Changed
- **(breaking)** `bot::instance::ServerConfig::default` now points to `euphoria.leet.nu`
- **(breaking)** Bumped `cookie` dependency from `0.17` to `0.18`
- **(breaking)** Bumped `tokio-tungstenite` dependency from `0.18` to `0.21`
- Updated set of emoji names
- Documentation now references `euphoria.leet.nu` instead of `euphoria.io`
## v0.4.0 - 2023-05-14
### Added
- `bot::botrulez::Uptime` now implements `bot::command::Command`
- `bot::command::parse_prefix_initiated`
- `bot::commands::Commands::fallthrough`
- `bot::commands::Commands::set_fallthrough`
- `conn::Error::ConnectionTimedOut`
### Changed
- **(breaking)** `bot::command::ClapCommand::execute` now returns a `Result<bool, E>` instead of a `Result<(), E>`
- **(breaking)** `bot::command::Command::execute` now returns a `Result<bool, E>` instead of a `Result<(), E>`
- **(breaking)** `bot::commands::Commands::handle_packet` now returns a `Result<bool, E>` instead of a `Result<(), E>`
- **(breaking)** `bot::instance::Snapshot` renamed to `ConnSnapshot`
- **(breaking)** `conn::Conn::connect` now returns `conn::Result`
- `bot::instance::Instance` now implements `Clone`
### Fixed
- **(breaking)** Deserializing empty events and replies by turning unit structs into empty structs
- `phone` and `mobile` emoji
- Instances getting stuck in "Connecting" state
- Euph errors always turning into `conn::Error`s
## v0.3.1 - 2023-02-26
### Added
- `bot::botrulez::FullHelp` now implements `bot::command::Command`
- `bot::botrulez::Ping` now implements `bot::command::Command`
- `bot::botrulez::ShortHelp` now implements `bot::command::Command`
- `bot::instances::Instances::is_from_known_instance`
### Changed
- Instances log to target `euphoxide::live::<name>`
- Instances stay connected if auth is required but no password is set
### Fixed
- `!uptime` minute count
- Instance reconnecting after encountering a 404 (it now stops and logs an error)
- Instance taking too long to stop when stopped during reconnect delay
@ -33,6 +123,7 @@ Procedure when bumping the version number:
## v0.3.0 - 2023-02-11
### Added
- `bot` feature
- `bot` module (enable the `bot` feature to use)
- `Emoji` for finding, replacing and removing colon-delimited emoji in text
@ -45,21 +136,25 @@ Procedure when bumping the version number:
- VSCode project settings
### Changed
- `conn` module redesigned and rewritten (backwards-incompatible)
- `nick_hue` moved to `nick::hue_without_removing_emoji`
- **(breaking)** `conn` module redesigned and rewritten
- **(breaking)** `nick_hue` moved to `nick::hue_without_removing_emoji`
- Renamed `testbot` example to `testbot_manual`
### Removed
- `connect` (see `conn::Conn::connect`)
- `wrap` (see `conn::Conn::wrap`)
- **(breaking)** `connect` (see `conn::Conn::connect`)
- **(breaking)** `wrap` (see `conn::Conn::wrap`)
## v0.2.0 - 2022-12-10
### Added
- `connect`
### Changed
- Updated dependencies (backwards-incompatible)
- **(breaking)** Updated dependencies
## v0.1.0 - 2022-10-23

View file

@ -1,33 +1,34 @@
[package]
name = "euphoxide"
version = "0.3.1"
version = "0.6.1"
edition = "2021"
[features]
bot = ["dep:async-trait", "dep:clap", "dep:cookie"]
[dependencies]
async-trait = { version = "0.1.64", optional = true }
caseless = "0.2.1"
cookie = { version = "0.17.0", optional = true }
futures-util = { version = "0.3.26", default-features = false, features = ["sink"] }
log = "0.4.17"
serde = { version = "1.0.152", features = ["derive"] }
serde_json = "1.0.93"
time = { version = "0.3.20", features = ["serde"] }
tokio = { version = "1.25.0", features = ["time", "sync", "macros", "rt"] }
tokio-stream = "0.1.12"
tokio-tungstenite = { version = "0.18.0", features = ["rustls-tls-native-roots"] }
unicode-normalization = "0.1.22"
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"] }
unicode-normalization = "0.1.24"
[dependencies.clap]
version = "4.1.6"
version = "4.5.30"
optional = true
default-features = false
features = ["std", "derive", "deprecated"]
[dev-dependencies] # For example bot
tokio = { version = "1.25.0", features = ["rt-multi-thread"] }
rustls = "0.23.23"
tokio = { version = "1.43.0", features = ["rt-multi-thread"] }
[[example]]
name = "testbot_instance"
@ -40,3 +41,24 @@ required-features = ["bot"]
[[example]]
name = "testbot_commands"
required-features = ["bot"]
[lints]
rust.unsafe_code = { level = "forbid", priority = 1 }
# Lint groups
rust.deprecated_safe = "warn"
rust.future_incompatible = "warn"
rust.keyword_idents = "warn"
rust.rust_2018_idioms = "warn"
rust.unused = "warn"
# Individual lints
rust.non_local_definitions = "warn"
rust.redundant_imports = "warn"
rust.redundant_lifetimes = "warn"
rust.single_use_lifetimes = "warn"
rust.unit_bindings = "warn"
rust.unnameable_types = "warn"
rust.unused_import_braces = "warn"
rust.unused_lifetimes = "warn"
rust.unused_qualifications = "warn"
# Clippy
clippy.use_self = "warn"

View file

@ -12,8 +12,8 @@ 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 time::OffsetDateTime;
use tokio::sync::mpsc;
const HELP: &str = "I'm an example bot for https://github.com/Garmelon/euphoxide";
@ -34,10 +34,10 @@ impl ClapCommand<Bot, conn::Error> for Kill {
msg: &Message,
ctx: &Context,
bot: &mut Bot,
) -> Result<(), conn::Error> {
) -> Result<bool, conn::Error> {
bot.stop = true;
ctx.reply(msg.id, "/me dies").await?;
Ok(())
Ok(true)
}
}
@ -61,20 +61,20 @@ impl ClapCommand<Bot, conn::Error> for Test {
msg: &Message,
ctx: &Context,
_bot: &mut Bot,
) -> Result<(), conn::Error> {
) -> 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(())
Ok(true)
}
}
struct Bot {
commands: Arc<Commands<Self, conn::Error>>,
start_time: OffsetDateTime,
start_time: Timestamp,
stop: bool,
}
@ -85,13 +85,18 @@ impl HasDescriptions for Bot {
}
impl HasStartTime for Bot {
fn start_time(&self) -> OffsetDateTime {
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());
@ -107,7 +112,7 @@ async fn main() {
let mut bot = Bot {
commands: cmds.clone(),
start_time: OffsetDateTime::now_utc(),
start_time: Timestamp::now(),
stop: false,
};

View file

@ -3,47 +3,15 @@
use euphoxide::api::packet::ParsedPacket;
use euphoxide::api::{Data, Nick, Send};
use euphoxide::bot::instance::{Event, ServerConfig, Snapshot};
use time::OffsetDateTime;
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";
fn format_delta(delta: time::Duration) -> String {
const MINUTE: u64 = 60;
const HOUR: u64 = MINUTE * 60;
const DAY: u64 = HOUR * 24;
let mut seconds: u64 = delta.whole_seconds().try_into().unwrap();
let mut parts = vec![];
let days = seconds / DAY;
if days > 0 {
parts.push(format!("{days}d"));
seconds -= days * DAY;
}
let hours = seconds / HOUR;
if hours > 0 {
parts.push(format!("{hours}h"));
seconds -= hours * HOUR;
}
let mins = seconds / MINUTE;
if mins > 0 {
parts.push(format!("{mins}m"));
seconds -= mins * MINUTE;
}
if parts.is_empty() || seconds > 0 {
parts.push(format!("{seconds}s"));
}
parts.join(" ")
}
async fn on_packet(packet: ParsedPacket, snapshot: Snapshot) -> Result<(), ()> {
async fn on_packet(packet: ParsedPacket, snapshot: ConnSnapshot) -> Result<(), ()> {
let data = match packet.content {
Ok(data) => data,
Err(err) => {
@ -107,8 +75,11 @@ async fn on_packet(packet: ParsedPacket, snapshot: Snapshot) -> Result<(), ()> {
reply = Some(HELP.to_string());
} else if content == format!("!uptime @{NICK}") {
if let Some(joined) = snapshot.state.joined() {
let delta = OffsetDateTime::now_utc() - joined.since;
reply = Some(format!("/me has been up for {}", format_delta(delta)));
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());
@ -151,6 +122,11 @@ async fn on_packet(packet: ParsedPacket, snapshot: Snapshot) -> Result<(), ()> {
#[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()

View file

@ -3,48 +3,16 @@
use euphoxide::api::packet::ParsedPacket;
use euphoxide::api::{Data, Nick, Send};
use euphoxide::bot::instance::{Event, ServerConfig, Snapshot};
use euphoxide::bot::botrulez;
use euphoxide::bot::instance::{ConnSnapshot, Event, ServerConfig};
use euphoxide::bot::instances::Instances;
use time::OffsetDateTime;
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";
fn format_delta(delta: time::Duration) -> String {
const MINUTE: u64 = 60;
const HOUR: u64 = MINUTE * 60;
const DAY: u64 = HOUR * 24;
let mut seconds: u64 = delta.whole_seconds().try_into().unwrap();
let mut parts = vec![];
let days = seconds / DAY;
if days > 0 {
parts.push(format!("{days}d"));
seconds -= days * DAY;
}
let hours = seconds / HOUR;
if hours > 0 {
parts.push(format!("{hours}h"));
seconds -= hours * HOUR;
}
let mins = seconds / MINUTE;
if mins > 0 {
parts.push(format!("{mins}m"));
seconds -= mins * MINUTE;
}
if parts.is_empty() || seconds > 0 {
parts.push(format!("{seconds}s"));
}
parts.join(" ")
}
async fn on_packet(packet: ParsedPacket, snapshot: Snapshot) -> Result<(), ()> {
async fn on_packet(packet: ParsedPacket, snapshot: ConnSnapshot) -> Result<(), ()> {
let data = match packet.content {
Ok(data) => data,
Err(err) => {
@ -108,8 +76,11 @@ async fn on_packet(packet: ParsedPacket, snapshot: Snapshot) -> Result<(), ()> {
reply = Some(HELP.to_string());
} else if content == format!("!uptime @{NICK}") {
if let Some(joined) = snapshot.state.joined() {
let delta = OffsetDateTime::now_utc() - joined.since;
reply = Some(format!("/me has been up for {}", format_delta(delta)));
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());
@ -152,6 +123,11 @@ async fn on_packet(packet: ParsedPacket, snapshot: Snapshot) -> Result<(), ()> {
#[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());

View file

@ -6,48 +6,16 @@ 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 time::OffsetDateTime;
use jiff::Timestamp;
const TIMEOUT: Duration = Duration::from_secs(10);
const DOMAIN: &str = "euphoria.io";
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";
fn format_delta(delta: time::Duration) -> String {
const MINUTE: u64 = 60;
const HOUR: u64 = MINUTE * 60;
const DAY: u64 = HOUR * 24;
let mut seconds: u64 = delta.whole_seconds().try_into().unwrap();
let mut parts = vec![];
let days = seconds / DAY;
if days > 0 {
parts.push(format!("{days}d"));
seconds -= days * DAY;
}
let hours = seconds / HOUR;
if hours > 0 {
parts.push(format!("{hours}h"));
seconds -= hours * HOUR;
}
let mins = seconds / MINUTE;
if mins > 0 {
parts.push(format!("{mins}m"));
seconds -= mins * MINUTE;
}
if parts.is_empty() || seconds > 0 {
parts.push(format!("{seconds}s"));
}
parts.join(" ")
}
async fn on_packet(packet: ParsedPacket, conn_tx: &ConnTx, state: &State) -> Result<(), ()> {
let data = match packet.content {
Ok(data) => data,
@ -112,8 +80,11 @@ async fn on_packet(packet: ParsedPacket, conn_tx: &ConnTx, state: &State) -> Res
reply = Some(HELP.to_string());
} else if content == format!("!uptime @{NICK}") {
if let Some(joined) = state.joined() {
let delta = OffsetDateTime::now_utc() - joined.since;
reply = Some(format!("/me has been up for {}", format_delta(delta)));
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());
@ -155,6 +126,11 @@ async fn on_packet(packet: ParsedPacket, conn_tx: &ConnTx, state: &State) -> Res
#[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 {

View file

@ -1,4 +1,6 @@
//! Models the euphoria API at <http://api.euphoria.io/>.
//! Models the [euphoria API][0].
//!
//! [0]: https://euphoria.leet.nu/heim/api
mod account_cmds;
mod events;

View file

@ -56,7 +56,7 @@ pub struct ChangePassword {
/// Return the outcome of changing the password.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChangePasswordReply;
pub struct ChangePasswordReply {}
/// Attempt to log an anonymous session into an account.
///
@ -99,11 +99,11 @@ pub struct LoginReply {
/// [`DisconnectEvent`](super::DisconnectEvent) shortly after. The next
/// connection the client makes will be a logged out session.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Logout;
pub struct Logout {}
/// Confirm a logout.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LogoutReply;
pub struct LogoutReply {}
/// Create a new account and logs into it.
///
@ -146,11 +146,11 @@ pub struct RegisterAccountReply {
/// An error will be returned if the account has no unverified email addresses
/// associated with it.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResendVerificationEmail;
pub struct ResendVerificationEmail {}
/// Indicate that a verification email has been sent.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResendVerificationEmailReply;
pub struct ResendVerificationEmailReply {}
/// Generate a password reset request.
///
@ -164,4 +164,4 @@ pub struct ResetPassword {
/// Confirm that the password reset is in progress.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResetPasswordReply;
pub struct ResetPasswordReply {}

View file

@ -68,7 +68,7 @@ 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).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LogoutEvent;
pub struct LogoutEvent {}
/// Indicates some server-side event that impacts the presence of sessions in a
/// room.

View file

@ -110,7 +110,7 @@ pub struct SendReply(pub Message);
/// Request a list of sessions currently joined in the room.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Who;
pub struct Who {}
/// Lists the sessions currently joined in the room.
#[derive(Debug, Clone, Serialize, Deserialize)]

View file

@ -10,9 +10,9 @@ use std::num::ParseIntError;
use std::str::FromStr;
use std::{error, fmt};
use jiff::Timestamp;
use serde::{de, ser, Deserialize, Serialize};
use serde_json::Value;
use time::{OffsetDateTime, UtcOffset};
/// Describes an account and its preferred name.
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -403,19 +403,19 @@ impl<'de> Deserialize<'de> for Snowflake {
/// Time is specified as a signed 64-bit integer, giving the number of seconds
/// since the Unix Epoch.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Time(#[serde(with = "time::serde::timestamp")] pub OffsetDateTime);
pub struct Time(pub i64);
impl Time {
pub fn new(time: OffsetDateTime) -> Self {
let time = time
.to_offset(UtcOffset::UTC)
.replace_millisecond(0)
.unwrap();
Self(time)
pub fn from_timestamp(time: Timestamp) -> Self {
Self(time.as_second())
}
pub fn as_timestamp(&self) -> Timestamp {
Timestamp::from_second(self.0).unwrap()
}
pub fn now() -> Self {
Self::new(OffsetDateTime::now_utc())
Self::from_timestamp(Timestamp::now())
}
}

View file

@ -1,10 +1,10 @@
//! The main [botrulez](https://github.com/jedevc/botrulez) commands.
mod full_help;
mod ping;
mod short_help;
mod uptime;
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_time, HasStartTime, Uptime};
pub use self::uptime::{format_duration, format_relative_time, format_time, HasStartTime, Uptime};

View file

@ -50,12 +50,20 @@ where
B: HasDescriptions + Send,
E: From<conn::Error>,
{
async fn execute(&self, arg: &str, msg: &Message, ctx: &Context, bot: &mut B) -> Result<(), E> {
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)
}
Ok(())
}
}
@ -77,9 +85,9 @@ where
msg: &Message,
ctx: &Context,
bot: &mut B,
) -> Result<(), E> {
) -> Result<bool, E> {
let reply = self.formulate_reply(ctx, bot);
ctx.reply(msg.id, reply).await?;
Ok(())
Ok(true)
}
}

View file

@ -30,11 +30,13 @@ where
msg: &Message,
ctx: &Context,
_bot: &mut B,
) -> Result<(), E> {
) -> Result<bool, E> {
if arg.trim().is_empty() {
ctx.reply(msg.id, &self.0).await?;
Ok(true)
} else {
Ok(false)
}
Ok(())
}
}
@ -55,8 +57,8 @@ where
msg: &Message,
ctx: &Context,
_bot: &mut B,
) -> Result<(), E> {
) -> Result<bool, E> {
ctx.reply(msg.id, &self.0).await?;
Ok(())
Ok(true)
}
}

View file

@ -24,11 +24,13 @@ where
msg: &Message,
ctx: &Context,
_bot: &mut B,
) -> Result<(), E> {
) -> Result<bool, E> {
if arg.trim().is_empty() {
ctx.reply(msg.id, &self.0).await?;
Ok(true)
} else {
Ok(false)
}
Ok(())
}
}
@ -49,8 +51,8 @@ where
msg: &Message,
ctx: &Context,
_bot: &mut B,
) -> Result<(), E> {
) -> Result<bool, E> {
ctx.reply(msg.id, &self.0).await?;
Ok(())
Ok(true)
}
}

View file

@ -1,24 +1,29 @@
use async_trait::async_trait;
use clap::Parser;
use time::macros::format_description;
use time::{Duration, OffsetDateTime, UtcOffset};
use jiff::{Span, Timestamp, Unit};
use crate::api::Message;
use crate::bot::command::{ClapCommand, Context};
use crate::bot::command::{ClapCommand, Command, Context};
use crate::conn;
pub fn format_time(t: OffsetDateTime) -> String {
let t = t.to_offset(UtcOffset::UTC);
let format = format_description!("[year]-[month]-[day] [hour]:[minute]:[second] UTC");
t.format(format).unwrap()
pub fn format_time(t: Timestamp) -> String {
t.strftime("%Y-%m-%d %H:%M:%S UTC").to_string()
}
pub fn format_duration(d: Duration) -> String {
let d_abs = d.abs();
let days = d_abs.whole_days();
let hours = d_abs.whole_hours() % 24;
let mins = d_abs.whole_minutes() % 60;
let secs = d_abs.whole_seconds() % 60;
pub fn format_relative_time(d: Span) -> String {
if d.is_positive() {
format!("in {}", format_duration(d.abs()))
} else {
format!("{} ago", format_duration(d.abs()))
}
}
pub fn format_duration(d: Span) -> String {
let total = d.abs().total(Unit::Second).unwrap() as i64;
let secs = total % 60;
let mins = (total / 60) % 60;
let hours = (total / 60 / 60) % 24;
let days = total / 60 / 60 / 24;
let mut segments = vec![];
if days > 0 {
@ -39,16 +44,63 @@ pub fn format_duration(d: Duration) -> String {
let segments = segments.join(" ");
if d.is_positive() {
format!("in {segments}")
segments
} else {
format!("{segments} ago")
format!("-{segments}")
}
}
pub struct Uptime;
pub trait HasStartTime {
fn start_time(&self) -> OffsetDateTime;
fn start_time(&self) -> Timestamp;
}
impl Uptime {
fn formulate_reply<B: HasStartTime>(&self, ctx: &Context, bot: &B, connected: bool) -> String {
let start = bot.start_time();
let now = Timestamp::now();
let mut reply = format!(
"/me has been up since {} ({})",
format_time(start),
format_relative_time(start - now),
);
if connected {
let since = ctx.joined.since;
reply.push_str(&format!(
", connected since {} ({})",
format_time(since),
format_relative_time(since - now),
));
}
reply
}
}
#[async_trait]
impl<B, E> Command<B, E> for Uptime
where
B: HasStartTime + 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, false);
ctx.reply(msg.id, reply).await?;
Ok(true)
} else {
Ok(false)
}
}
}
/// Show how long the bot has been online.
@ -56,7 +108,7 @@ pub trait HasStartTime {
pub struct Args {
/// Show how long the bot has been connected without interruption.
#[arg(long, short)]
connected: bool,
pub connected: bool,
}
#[async_trait]
@ -73,26 +125,9 @@ where
msg: &Message,
ctx: &Context,
bot: &mut B,
) -> Result<(), E> {
let start = bot.start_time();
let now = OffsetDateTime::now_utc();
let mut reply = format!(
"/me has been up since {} ({})",
format_time(start),
format_duration(start - now),
);
if args.connected {
let since = ctx.joined.since;
reply.push_str(&format!(
", connected since {} ({})",
format_time(since),
format_duration(since - now),
));
}
) -> Result<bool, E> {
let reply = self.formulate_reply(ctx, bot, args.connected);
ctx.reply(msg.id, reply).await?;
Ok(())
Ok(true)
}
}

View file

@ -54,5 +54,11 @@ pub trait Command<B, E> {
None
}
async fn execute(&self, arg: &str, msg: &Message, ctx: &Context, bot: &mut B) -> Result<(), E>;
async fn execute(
&self,
arg: &str,
msg: &Message,
ctx: &Context,
bot: &mut B,
) -> Result<bool, E>;
}

View file

@ -5,11 +5,15 @@ use crate::nick;
use super::{Command, Context};
/// Parse leading whitespace followed by an `!`-initiated command.
// TODO Don't ignore leading whitespace?
// I'm not entirely happy with how commands handle whitespace, and on euphoria,
// prefixing commands with whitespace is traditionally used to not trigger them.
/// Parse leading whitespace followed by an prefix-initiated command.
///
/// Returns the command name and the remaining text with one leading whitespace
/// removed. The remaining text may be the empty string.
fn parse_command<'a>(text: &'a str, prefix: &str) -> Option<(&'a str, &'a str)> {
pub fn parse_prefix_initiated<'a>(text: &'a str, prefix: &str) -> Option<(&'a str, &'a str)> {
let text = text.trim_start();
let text = text.strip_prefix(prefix)?;
let (name, rest) = text.split_once(char::is_whitespace).unwrap_or((text, ""));
@ -19,20 +23,6 @@ fn parse_command<'a>(text: &'a str, prefix: &str) -> Option<(&'a str, &'a str)>
Some((name, rest))
}
/// Parse leading whitespace followed by an `@`-initiated nick.
///
/// Returns the nick and the remaining text with one leading whitespace removed.
/// The remaining text may be the empty string.
fn parse_specific(text: &str) -> Option<(&str, &str)> {
let text = text.trim_start();
let text = text.strip_prefix('@')?;
let (name, rest) = text.split_once(char::is_whitespace).unwrap_or((text, ""));
if name.is_empty() {
return None;
}
Some((name, rest))
}
pub struct Global<C> {
prefix: String,
name: String,
@ -65,15 +55,21 @@ where
Some(format!("{}{} - {inner}", self.prefix, self.name))
}
async fn execute(&self, arg: &str, msg: &Message, ctx: &Context, bot: &mut B) -> Result<(), E> {
async fn execute(
&self,
arg: &str,
msg: &Message,
ctx: &Context,
bot: &mut B,
) -> Result<bool, E> {
// TODO Replace with let-else
let (name, rest) = match parse_command(arg, &self.prefix) {
let (name, rest) = match parse_prefix_initiated(arg, &self.prefix) {
Some(parsed) => parsed,
None => return Ok(()),
None => return Ok(false),
};
if name != self.name {
return Ok(());
return Ok(false);
}
self.inner.execute(rest, msg, ctx, bot).await
@ -112,22 +108,28 @@ where
Some(format!("{}{} - {inner}", self.prefix, self.name))
}
async fn execute(&self, arg: &str, msg: &Message, ctx: &Context, bot: &mut B) -> Result<(), E> {
async fn execute(
&self,
arg: &str,
msg: &Message,
ctx: &Context,
bot: &mut B,
) -> Result<bool, E> {
// TODO Replace with let-else
let (name, rest) = match parse_command(arg, &self.prefix) {
let (name, rest) = match parse_prefix_initiated(arg, &self.prefix) {
Some(parsed) => parsed,
None => return Ok(()),
None => return Ok(false),
};
if name != self.name {
return Ok(());
return Ok(false);
}
if parse_specific(rest).is_some() {
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(());
return Ok(false);
}
self.inner.execute(rest, msg, ctx, bot).await
@ -167,25 +169,31 @@ where
Some(format!("{}{} @{nick} - {inner}", self.prefix, self.name))
}
async fn execute(&self, arg: &str, msg: &Message, ctx: &Context, bot: &mut B) -> Result<(), E> {
async fn execute(
&self,
arg: &str,
msg: &Message,
ctx: &Context,
bot: &mut B,
) -> Result<bool, E> {
// TODO Replace with let-else
let (name, rest) = match parse_command(arg, &self.prefix) {
let (name, rest) = match parse_prefix_initiated(arg, &self.prefix) {
Some(parsed) => parsed,
None => return Ok(()),
None => return Ok(false),
};
if name != self.name {
return Ok(());
return Ok(false);
}
// TODO Replace with let-else
let (nick, rest) = match parse_specific(rest) {
let (nick, rest) = match parse_prefix_initiated(rest, "@") {
Some(parsed) => parsed,
None => return Ok(()),
None => return Ok(false),
};
if nick::normalize(nick) != nick::normalize(&ctx.joined.session.name) {
return Ok(());
return Ok(false);
}
self.inner.execute(rest, msg, ctx, bot).await
@ -194,33 +202,34 @@ where
#[cfg(test)]
mod test {
use super::{parse_command, parse_specific};
use super::parse_prefix_initiated;
#[test]
fn test_parse_command() {
assert_eq!(parse_command("!foo", "!"), Some(("foo", "")));
assert_eq!(parse_command(" !foo", "!"), Some(("foo", "")));
assert_eq!(parse_command("!foo ", "!"), Some(("foo", " ")));
assert_eq!(parse_command(" !foo ", "!"), Some(("foo", " ")));
assert_eq!(parse_command("!foo @bar", "!"), Some(("foo", "@bar")));
assert_eq!(parse_command("!foo @bar", "!"), Some(("foo", " @bar")));
assert_eq!(parse_command("!foo @bar ", "!"), Some(("foo", "@bar ")));
assert_eq!(parse_command("! foo @bar", "!"), None);
assert_eq!(parse_command("!", "!"), None);
assert_eq!(parse_command("?foo", "!"), None);
}
#[test]
fn test_parse_specific() {
assert_eq!(parse_specific("@foo"), Some(("foo", "")));
assert_eq!(parse_specific(" @foo"), Some(("foo", "")));
assert_eq!(parse_specific("@foo "), Some(("foo", " ")));
assert_eq!(parse_specific(" @foo "), Some(("foo", " ")));
assert_eq!(parse_specific("@foo !bar"), Some(("foo", "!bar")));
assert_eq!(parse_specific("@foo !bar"), Some(("foo", " !bar")));
assert_eq!(parse_specific("@foo !bar "), Some(("foo", "!bar ")));
assert_eq!(parse_specific("@ foo !bar"), None);
assert_eq!(parse_specific("@"), None);
assert_eq!(parse_specific("?foo"), None);
fn test_parse_prefixed() {
assert_eq!(parse_prefix_initiated("!foo", "!"), Some(("foo", "")));
assert_eq!(parse_prefix_initiated(" !foo", "!"), Some(("foo", "")));
assert_eq!(
parse_prefix_initiated("!foo ", "!"),
Some(("foo", " "))
);
assert_eq!(
parse_prefix_initiated(" !foo ", "!"),
Some(("foo", " "))
);
assert_eq!(
parse_prefix_initiated("!foo @bar", "!"),
Some(("foo", "@bar"))
);
assert_eq!(
parse_prefix_initiated("!foo @bar", "!"),
Some(("foo", " @bar"))
);
assert_eq!(
parse_prefix_initiated("!foo @bar ", "!"),
Some(("foo", "@bar "))
);
assert_eq!(parse_prefix_initiated("! foo @bar", "!"), None);
assert_eq!(parse_prefix_initiated("!", "!"), None);
assert_eq!(parse_prefix_initiated("?foo", "!"), None);
}
}

View file

@ -16,7 +16,7 @@ pub trait ClapCommand<B, E> {
msg: &Message,
ctx: &Context,
bot: &mut B,
) -> Result<(), E>;
) -> Result<bool, E>;
}
/// Parse bash-like quoted arguments separated by whitespace.
@ -110,12 +110,18 @@ where
C::Args::command().get_about().map(|s| format!("{s}"))
}
async fn execute(&self, arg: &str, msg: &Message, ctx: &Context, bot: &mut B) -> Result<(), E> {
async fn execute(
&self,
arg: &str,
msg: &Message,
ctx: &Context,
bot: &mut B,
) -> Result<bool, E> {
let mut args = match parse_quoted_args(arg) {
Ok(args) => args,
Err(err) => {
ctx.reply(msg.id, err).await?;
return Ok(());
return Ok(true);
}
};
@ -127,7 +133,7 @@ where
Ok(args) => args,
Err(err) => {
ctx.reply(msg.id, format!("{}", err.render())).await?;
return Ok(());
return Ok(true);
}
};

View file

@ -17,7 +17,13 @@ where
None
}
async fn execute(&self, arg: &str, msg: &Message, ctx: &Context, bot: &mut B) -> Result<(), E> {
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

@ -29,11 +29,17 @@ where
Some(format!("{} - {inner}", self.prefix))
}
async fn execute(&self, arg: &str, msg: &Message, ctx: &Context, bot: &mut B) -> Result<(), E> {
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(())
Ok(false)
}
}
}

View file

@ -3,15 +3,36 @@ use crate::api::{Data, SendEvent};
use crate::conn;
use super::command::{Command, Context};
use super::instance::{InstanceConfig, Snapshot};
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![] }
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)
@ -28,21 +49,22 @@ impl<B, E> Commands<B, E> {
.collect::<Vec<_>>()
}
/// Returns `true` if a command was found and executed, `false` otherwise.
/// Returns `true` if one or more commands returned `true`, `false`
/// otherwise.
pub async fn handle_packet(
&self,
config: &InstanceConfig,
packet: &ParsedPacket,
snapshot: &Snapshot,
snapshot: &ConnSnapshot,
bot: &mut B,
) -> Result<(), E> {
) -> Result<bool, E> {
let msg = match &packet.content {
Ok(Data::SendEvent(SendEvent(msg))) => msg,
_ => return Ok(()),
_ => return Ok(false),
};
let joined = match &snapshot.state {
conn::State::Joining(_) => return Ok(()),
conn::State::Joining(_) => return Ok(false),
conn::State::Joined(joined) => joined.clone(),
};
@ -52,11 +74,15 @@ impl<B, E> Commands<B, E> {
joined,
};
let mut handled = false;
for command in &self.commands {
command.execute(&msg.content, msg, &ctx, bot).await?;
handled = handled || command.execute(&msg.content, msg, &ctx, bot).await?;
if !self.fallthrough && handled {
break;
}
}
Ok(())
Ok(handled)
}
}

View file

@ -98,7 +98,7 @@ impl Default for ServerConfig {
Self {
timeout: Duration::from_secs(30),
reconnect_delay: Duration::from_secs(30),
domain: "euphoria.io".to_string(),
domain: "euphoria.leet.nu".to_string(),
cookies: Arc::new(Mutex::new(CookieJar::new())),
}
}
@ -193,12 +193,12 @@ impl InstanceConfig {
/// Snapshot of a [`Conn`]'s state immediately after receiving a packet.
#[derive(Debug, Clone)]
pub struct Snapshot {
pub struct ConnSnapshot {
pub conn_tx: ConnTx,
pub state: State,
}
impl Snapshot {
impl ConnSnapshot {
fn from_conn(conn: &Conn) -> Self {
Self {
conn_tx: conn.tx().clone(),
@ -224,8 +224,8 @@ impl Snapshot {
#[derive(Debug)]
pub enum Event {
Connecting(InstanceConfig),
Connected(InstanceConfig, Snapshot),
Packet(InstanceConfig, ParsedPacket, Snapshot),
Connected(InstanceConfig, ConnSnapshot),
Packet(InstanceConfig, ParsedPacket, ConnSnapshot),
Disconnected(InstanceConfig),
Stopped(InstanceConfig),
}
@ -251,7 +251,7 @@ enum Request {
enum RunError {
StoppedManually,
InstanceDropped,
CouldNotConnect(tungstenite::Error),
CouldNotConnect(conn::Error),
Conn(conn::Error),
}
@ -273,13 +273,13 @@ enum RunError {
/// 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)]
#[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: oneshot::Sender<Infallible>,
_canary_tx: mpsc::UnboundedSender<Infallible>,
}
impl Instance {
@ -312,7 +312,7 @@ impl Instance {
idebug!(config, "Created with config {config:?}");
let (request_tx, request_rx) = mpsc::unbounded_channel();
let (canary_tx, canary_rx) = oneshot::channel();
let (canary_tx, canary_rx) = mpsc::unbounded_channel();
tokio::spawn(Self::run::<F>(
config.clone(),
@ -360,11 +360,11 @@ impl Instance {
config: InstanceConfig,
on_event: F,
request_rx: mpsc::UnboundedReceiver<Request>,
canary_rx: oneshot::Receiver<Infallible>,
mut canary_rx: mpsc::UnboundedReceiver<Infallible>,
) {
select! {
_ = Self::stay_connected(&config, &on_event, request_rx) => (),
_ = canary_rx => { idebug!(config, "Instance dropped"); },
_ = canary_rx.recv() => { idebug!(config, "Instance dropped"); },
}
on_event(Event::Stopped(config))
}
@ -394,9 +394,9 @@ impl Instance {
idebug!(config, "Instance dropped");
break;
}
Err(RunError::CouldNotConnect(tungstenite::Error::Http(response)))
if response.status() == StatusCode::NOT_FOUND =>
{
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;
}
@ -458,7 +458,10 @@ impl Instance {
.map_err(RunError::CouldNotConnect)?;
Self::set_cookies(config, cookies);
on_event(Event::Connected(config.clone(), Snapshot::from_conn(&conn)));
on_event(Event::Connected(
config.clone(),
ConnSnapshot::from_conn(&conn),
));
let conn_tx = conn.tx().clone();
select! {
@ -474,7 +477,7 @@ impl Instance {
) -> Result<(), RunError> {
loop {
let packet = conn.recv().await.map_err(RunError::Conn)?;
let snapshot = Snapshot::from_conn(conn);
let snapshot = ConnSnapshot::from_conn(conn);
match &packet.content {
Ok(Data::SnapshotEvent(snapshot)) => {

View file

@ -6,8 +6,8 @@ use std::future::Future;
use std::time::{Duration, Instant};
use std::{error, fmt, result};
use ::time::OffsetDateTime;
use futures_util::SinkExt;
use jiff::Timestamp;
use log::debug;
use tokio::net::TcpStream;
use tokio::select;
@ -30,6 +30,8 @@ pub type WsStream = WebSocketStream<MaybeTlsStream<TcpStream>>;
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.
@ -45,6 +47,7 @@ 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}"),
@ -72,7 +75,7 @@ pub type Result<T> = result::Result<T, Error>;
#[derive(Debug, Clone)]
pub struct Joining {
pub since: OffsetDateTime,
pub since: Timestamp,
pub hello: Option<HelloEvent>,
pub snapshot: Option<SnapshotEvent>,
pub bounce: Option<BounceEvent>,
@ -81,7 +84,7 @@ pub struct Joining {
impl Joining {
fn new() -> Self {
Self {
since: OffsetDateTime::now_utc(),
since: Timestamp::now(),
hello: None,
snapshot: None,
bounce: None,
@ -119,7 +122,7 @@ impl Joining {
.map(|s| (s.session_id.clone(), SessionInfo::Full(s)))
.collect::<HashMap<_, _>>();
Some(Joined {
since: OffsetDateTime::now_utc(),
since: Timestamp::now(),
session,
account: hello.account.clone(),
listing,
@ -161,7 +164,7 @@ impl SessionInfo {
#[derive(Debug, Clone)]
pub struct Joined {
pub since: OffsetDateTime,
pub since: Timestamp,
pub session: SessionView,
pub account: Option<PersonalAccountView>,
pub listing: HashMap<SessionId, SessionInfo>,
@ -424,7 +427,7 @@ impl Conn {
}
tungstenite::Message::Ping(_) => {}
tungstenite::Message::Pong(payload) => {
if self.last_ws_ping_payload == Some(payload) {
if self.last_ws_ping_payload == Some(payload.to_vec()) {
self.last_ws_ping_replied_to = true;
}
}
@ -441,10 +444,11 @@ impl Conn {
self.replies.complete(id, packet.clone());
}
match &packet.content {
Ok(data) => self.on_data(&packet.id, data).await,
Err(msg) => Err(Error::Euph(msg.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<()> {
@ -518,16 +522,18 @@ impl Conn {
self.disconnect().await?;
}
let now = OffsetDateTime::now_utc();
let now = Timestamp::now();
// Send new ws ping
let ws_payload = now.unix_timestamp_nanos().to_be_bytes().to_vec();
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)).await?;
self.ws
.send(tungstenite::Message::Ping(ws_payload.into()))
.await?;
// Send new euph ping
let euph_payload = Time::new(now);
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();
@ -557,7 +563,7 @@ impl Conn {
.into_packet()?;
debug!(target: "euphoxide::conn::full", "Sending {packet:?}");
let msg = tungstenite::Message::Text(serde_json::to_string(&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));
@ -575,7 +581,7 @@ impl Conn {
.into_packet()?;
debug!(target: "euphoxide::conn::full", "Sending {packet:?}");
let msg = tungstenite::Message::Text(serde_json::to_string(&packet)?);
let msg = tungstenite::Message::Text(serde_json::to_string(&packet)?.into());
self.ws.send(msg).await?;
Ok(())
@ -613,7 +619,7 @@ impl Conn {
human: bool,
cookies: Option<HeaderValue>,
timeout: Duration,
) -> tungstenite::Result<(Self, Vec<HeaderValue>)> {
) -> 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:?}");
@ -622,7 +628,10 @@ impl Conn {
request.headers_mut().append(header::COOKIE, cookies);
}
let (ws, response) = tokio_tungstenite::connect_async(request).await?;
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(),

3840
src/emoji.json Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,36 +1,54 @@
//! All emoji the vanilla euphoria.io client knows.
//! All emoji the euphoria.leet.nu client knows.
use std::borrow::Cow;
use std::collections::HashMap;
use std::ops::RangeInclusive;
const EMOJI_RAW: &str = include_str!("emoji.txt");
/// 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) -> char {
u32::from_str_radix(hex, 16).unwrap().try_into().unwrap()
fn parse_hex_to_char(hex: &str) -> Option<char> {
u32::from_str_radix(hex, 16).ok()?.try_into().ok()
}
fn parse_line(line: &str) -> (String, Option<String>) {
let mut line = line.split_ascii_whitespace();
let name = line.next().unwrap().to_string();
let unicode = line.map(parse_hex_to_char).collect::<String>();
let unicode = Some(unicode).filter(|u| !u.is_empty());
(name, unicode)
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 {
let map = EMOJI_RAW
.lines()
.map(|l| l.trim())
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.map(parse_line)
.collect();
Self(map)
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>> {

View file

@ -1,914 +0,0 @@
# The vanilla euphoria frontend uses version 0.2.4 of the npm package
# "emoji-annotation-to-unicode" [0]. It ignores all emoji set to null [1]. It
# removes the "iphone" emoji [2] and replaces it with the "phone" emoji [3]. It
# also adds a few more emoji names without a unicode equivalent [4].
#
# [0]: https://www.npmjs.com/package/emoji-annotation-to-unicode/v/0.2.4
# [1]: https://github.com/euphoria-io/heim/blob/978c921063e6b06012fc8d16d9fbf1b3a0be1191/client/lib/emoji.js#L37-L39
# [2]: https://github.com/euphoria-io/heim/blob/978c921063e6b06012fc8d16d9fbf1b3a0be1191/client/lib/emoji.js#L26
# [3]: https://github.com/euphoria-io/heim/blob/978c921063e6b06012fc8d16d9fbf1b3a0be1191/client/lib/emoji.js#L23
# [4]: https://github.com/euphoria-io/heim/blob/978c921063e6b06012fc8d16d9fbf1b3a0be1191/client/lib/emoji.js#L8-L22
# Any line that is empty or starts with # is ignored.
# Each emoji is represented as a single line. The line starts with the emoji
# name, followed by zero or more hexadecimal unicode code points. These elements
# are separated by one or more spaces. If mp code points are listed, the emoji
# does not correspond to any unicode code points.
#################################
## emoji-annotation-to-unicode ##
#################################
+1 1f44d
-1 1f44e
100 1f4af
1234 1f522
8ball 1f3b1
a 1f170
ab 1f18e
abc 1f524
abcd 1f521
accept 1f251
aerial_tramway 1f6a1
airplane 2708
alarm_clock 23f0
alien 1f47d
ambulance 1f691
anchor 2693
angel 1f47c
anger 1f4a2
angry 1f620
anguished 1f627
ant 1f41c
apple 1f34e
aquarius 2652
aries 2648
arrow_backward 25c0
arrow_double_down 23ec
arrow_double_up 23eb
arrow_down 2b07
arrow_down_small 1f53d
arrow_forward 25b6
arrow_heading_down 2935
arrow_heading_up 2934
arrow_left 2b05
arrow_lower_left 2199
arrow_lower_right 2198
arrow_right 27a1
arrow_right_hook 21aa
arrow_up 2b06
arrow_up_down 2195
arrow_up_small 1f53c
arrow_upper_left 2196
arrow_upper_right 2197
arrows_clockwise 1f503
arrows_counterclockwise 1f504
art 1f3a8
articulated_lorry 1f69b
astonished 1f632
athletic_shoe 1f45f
atm 1f3e7
b 1f171
baby 1f476
baby_bottle 1f37c
baby_chick 1f424
baby_symbol 1f6bc
back 1f519
baggage_claim 1f6c4
balloon 1f388
ballot_box_with_check 2611
bamboo 1f38d
banana 1f34c
bangbang 203c
bank 1f3e6
bar_chart 1f4ca
barber 1f488
baseball 26be
basketball 1f3c0
bath 1f6c0
bathtub 1f6c1
battery 1f50b
bear 1f43b
bee 1f41d
beer 1f37a
beers 1f37b
beetle 1f41e
beginner 1f530
bell 1f514
bento 1f371
bicyclist 1f6b4
bike 1f6b2
bikini 1f459
bird 1f426
birthday 1f382
black_circle 26ab
black_joker 1f0cf
black_large_square 2b1b
black_medium_small_square 25fe
black_medium_square 25fc
black_nib 2712
black_small_square 25aa
black_square_button 1f532
blossom 1f33c
blowfish 1f421
blue_book 1f4d8
blue_car 1f699
blue_heart 1f499
blush 1f60a
boar 1f417
boat 26f5
bomb 1f4a3
book 1f4d6
bookmark 1f516
bookmark_tabs 1f4d1
books 1f4da
boom 1f4a5
boot 1f462
bouquet 1f490
bow 1f647
bowling 1f3b3
boy 1f466
bread 1f35e
bride_with_veil 1f470
bridge_at_night 1f309
briefcase 1f4bc
broken_heart 1f494
bug 1f41b
bulb 1f4a1
bullettrain_front 1f685
bullettrain_side 1f684
bus 1f68c
busstop 1f68f
bust_in_silhouette 1f464
busts_in_silhouette 1f465
cactus 1f335
cake 1f370
calendar 1f4c6
calling 1f4f2
camel 1f42b
camera 1f4f7
cancer 264b
candy 1f36c
capital_abcd 1f520
capricorn 2651
car 1f697
card_index 1f4c7
carousel_horse 1f3a0
cat 1f431
cat2 1f408
cd 1f4bf
chart 1f4b9
chart_with_downwards_trend 1f4c9
chart_with_upwards_trend 1f4c8
checkered_flag 1f3c1
cherries 1f352
cherry_blossom 1f338
chestnut 1f330
chicken 1f414
children_crossing 1f6b8
chocolate_bar 1f36b
christmas_tree 1f384
church 26ea
cinema 1f3a6
circus_tent 1f3aa
city_sunrise 1f307
city_sunset 1f306
cl 1f191
clap 1f44f
clapper 1f3ac
clipboard 1f4cb
clock1 1f550
clock10 1f559
clock1030 1f565
clock11 1f55a
clock1130 1f566
clock12 1f55b
clock1230 1f567
clock130 1f55c
clock2 1f551
clock230 1f55d
clock3 1f552
clock330 1f55e
clock4 1f553
clock430 1f55f
clock5 1f554
clock530 1f560
clock6 1f555
clock630 1f561
clock7 1f556
clock730 1f562
clock8 1f557
clock830 1f563
clock9 1f558
clock930 1f564
closed_book 1f4d5
closed_lock_with_key 1f510
closed_umbrella 1f302
cloud 2601
clubs 2663
cn 1f1e8 1f1f3
cocktail 1f378
coffee 2615
cold_sweat 1f630
collision 1f4a5
computer 1f4bb
confetti_ball 1f38a
confounded 1f616
confused 1f615
congratulations 3297
construction 1f6a7
construction_worker 1f477
convenience_store 1f3ea
cookie 1f36a
cool 1f192
cop 1f46e
copyright 00a9
corn 1f33d
couple 1f46b
couple_with_heart 1f491
couplekiss 1f48f
cow 1f42e
cow2 1f404
credit_card 1f4b3
crescent_moon 1f319
crocodile 1f40a
crossed_flags 1f38c
crown 1f451
cry 1f622
crying_cat_face 1f63f
crystal_ball 1f52e
cupid 1f498
curly_loop 27b0
currency_exchange 1f4b1
curry 1f35b
custard 1f36e
customs 1f6c3
cyclone 1f300
dancer 1f483
dancers 1f46f
dango 1f361
dart 1f3af
dash 1f4a8
date 1f4c5
de 1f1e9 1f1ea
deciduous_tree 1f333
department_store 1f3ec
diamond_shape_with_a_dot_inside 1f4a0
diamonds 2666
disappointed 1f61e
disappointed_relieved 1f625
dizzy 1f4ab
dizzy_face 1f635
do_not_litter 1f6af
dog 1f436
dog2 1f415
dollar 1f4b5
dolls 1f38e
dolphin 1f42c
door 1f6aa
doughnut 1f369
dragon 1f409
dragon_face 1f432
dress 1f457
dromedary_camel 1f42a
droplet 1f4a7
dvd 1f4c0
e-mail 1f4e7
ear 1f442
ear_of_rice 1f33e
earth_africa 1f30d
earth_americas 1f30e
earth_asia 1f30f
egg 1f373
eggplant 1f346
eight 0038 20e3
eight_pointed_black_star 2734
eight_spoked_asterisk 2733
electric_plug 1f50c
elephant 1f418
email 2709
end 1f51a
envelope 2709
envelope_with_arrow 1f4e9
es 1f1ea 1f1f8
euro 1f4b6
european_castle 1f3f0
european_post_office 1f3e4
evergreen_tree 1f332
exclamation 2757
expressionless 1f611
eyeglasses 1f453
eyes 1f440
facepunch 1f44a
factory 1f3ed
fallen_leaf 1f342
family 1f46a
fast_forward 23e9
fax 1f4e0
fearful 1f628
feet 1f43e
ferris_wheel 1f3a1
file_folder 1f4c1
fire 1f525
fire_engine 1f692
fireworks 1f386
first_quarter_moon 1f313
first_quarter_moon_with_face 1f31b
fish 1f41f
fish_cake 1f365
fishing_pole_and_fish 1f3a3
fist 270a
five 0035 20e3
flags 1f38f
flashlight 1f526
flipper 1f42c
floppy_disk 1f4be
flower_playing_cards 1f3b4
flushed 1f633
foggy 1f301
football 1f3c8
footprints 1f463
fork_and_knife 1f374
fountain 26f2
four 0034 20e3
four_leaf_clover 1f340
fr 1f1eb 1f1f7
free 1f193
fried_shrimp 1f364
fries 1f35f
frog 1f438
frowning 1f626
fuelpump 26fd
full_moon 1f315
full_moon_with_face 1f31d
game_die 1f3b2
gb 1f1ec 1f1e7
gem 1f48e
gemini 264a
ghost 1f47b
gift 1f381
gift_heart 1f49d
girl 1f467
globe_with_meridians 1f310
goat 1f410
golf 26f3
grapes 1f347
green_apple 1f34f
green_book 1f4d7
green_heart 1f49a
grey_exclamation 2755
grey_question 2754
grimacing 1f62c
grin 1f601
grinning 1f600
guardsman 1f482
guitar 1f3b8
gun 1f52b
haircut 1f487
hamburger 1f354
hammer 1f528
hamster 1f439
hand 270b
handbag 1f45c
hankey 1f4a9
hash 0023 20e3
hatched_chick 1f425
hatching_chick 1f423
headphones 1f3a7
hear_no_evil 1f649
heart 2764
heart_decoration 1f49f
heart_eyes 1f60d
heart_eyes_cat 1f63b
heartbeat 1f493
heartpulse 1f497
hearts 2665
heavy_check_mark 2714
heavy_division_sign 2797
heavy_dollar_sign 1f4b2
heavy_exclamation_mark 2757
heavy_minus_sign 2796
heavy_multiplication_x 2716
heavy_plus_sign 2795
helicopter 1f681
herb 1f33f
hibiscus 1f33a
high_brightness 1f506
high_heel 1f460
hocho 1f52a
honey_pot 1f36f
honeybee 1f41d
horse 1f434
horse_racing 1f3c7
hospital 1f3e5
hotel 1f3e8
hotsprings 2668
hourglass 231b
hourglass_flowing_sand 23f3
house 1f3e0
house_with_garden 1f3e1
hushed 1f62f
ice_cream 1f368
icecream 1f366
id 1f194
ideograph_advantage 1f250
imp 1f47f
inbox_tray 1f4e5
incoming_envelope 1f4e8
information_desk_person 1f481
information_source 2139
innocent 1f607
interrobang 2049
# iphone 1f4f1
it 1f1ee 1f1f9
izakaya_lantern 1f3ee
jack_o_lantern 1f383
japan 1f5fe
japanese_castle 1f3ef
japanese_goblin 1f47a
japanese_ogre 1f479
jeans 1f456
joy 1f602
joy_cat 1f639
jp 1f1ef 1f1f5
key 1f511
keycap_ten 1f51f
kimono 1f458
kiss 1f48b
kissing 1f617
kissing_cat 1f63d
kissing_closed_eyes 1f61a
kissing_heart 1f618
kissing_smiling_eyes 1f619
knife 1f52a
koala 1f428
koko 1f201
kr 1f1f0 1f1f7
lantern 1f3ee
large_blue_circle 1f535
large_blue_diamond 1f537
large_orange_diamond 1f536
last_quarter_moon 1f317
last_quarter_moon_with_face 1f31c
laughing 1f606
leaves 1f343
ledger 1f4d2
left_luggage 1f6c5
left_right_arrow 2194
leftwards_arrow_with_hook 21a9
lemon 1f34b
leo 264c
leopard 1f406
libra 264e
light_rail 1f688
link 1f517
lips 1f444
lipstick 1f484
lock 1f512
lock_with_ink_pen 1f50f
lollipop 1f36d
loop 27bf
loud_sound 1f50a
loudspeaker 1f4e2
love_hotel 1f3e9
love_letter 1f48c
low_brightness 1f505
m 24c2
mag 1f50d
mag_right 1f50e
mahjong 1f004
mailbox 1f4eb
mailbox_closed 1f4ea
mailbox_with_mail 1f4ec
mailbox_with_no_mail 1f4ed
man 1f468
man_with_gua_pi_mao 1f472
man_with_turban 1f473
mans_shoe 1f45e
maple_leaf 1f341
mask 1f637
massage 1f486
meat_on_bone 1f356
mega 1f4e3
melon 1f348
memo 1f4dd
mens 1f6b9
metro 1f687
microphone 1f3a4
microscope 1f52c
milky_way 1f30c
minibus 1f690
minidisc 1f4bd
mobile_phone_off 1f4f4
money_with_wings 1f4b8
moneybag 1f4b0
monkey 1f412
monkey_face 1f435
monorail 1f69d
moon 1f314
mortar_board 1f393
mount_fuji 1f5fb
mountain_bicyclist 1f6b5
mountain_cableway 1f6a0
mountain_railway 1f69e
mouse 1f42d
mouse2 1f401
movie_camera 1f3a5
moyai 1f5ff
muscle 1f4aa
mushroom 1f344
musical_keyboard 1f3b9
musical_note 1f3b5
musical_score 1f3bc
mute 1f507
nail_care 1f485
name_badge 1f4db
necktie 1f454
negative_squared_cross_mark 274e
neutral_face 1f610
new 1f195
new_moon 1f311
new_moon_with_face 1f31a
newspaper 1f4f0
ng 1f196
night_with_stars 1f303
nine 0039 20e3
no_bell 1f515
no_bicycles 1f6b3
no_entry 26d4
no_entry_sign 1f6ab
no_good 1f645
no_mobile_phones 1f4f5
no_mouth 1f636
no_pedestrians 1f6b7
no_smoking 1f6ad
non-potable_water 1f6b1
nose 1f443
notebook 1f4d3
notebook_with_decorative_cover 1f4d4
notes 1f3b6
nut_and_bolt 1f529
o 2b55
o2 1f17e
ocean 1f30a
octopus 1f419
oden 1f362
office 1f3e2
ok 1f197
ok_hand 1f44c
ok_woman 1f646
older_man 1f474
older_woman 1f475
on 1f51b
oncoming_automobile 1f698
oncoming_bus 1f68d
oncoming_police_car 1f694
oncoming_taxi 1f696
one 0031 20e3
open_book 1f4d6
open_file_folder 1f4c2
open_hands 1f450
open_mouth 1f62e
ophiuchus 26ce
orange_book 1f4d9
outbox_tray 1f4e4
ox 1f402
package 1f4e6
page_facing_up 1f4c4
page_with_curl 1f4c3
pager 1f4df
palm_tree 1f334
panda_face 1f43c
paperclip 1f4ce
parking 1f17f
part_alternation_mark 303d
partly_sunny 26c5
passport_control 1f6c2
paw_prints 1f43e
peach 1f351
pear 1f350
pencil 1f4dd
pencil2 270f
penguin 1f427
pensive 1f614
performing_arts 1f3ad
persevere 1f623
person_frowning 1f64d
person_with_blond_hair 1f471
person_with_pouting_face 1f64e
phone 260e
pig 1f437
pig2 1f416
pig_nose 1f43d
pill 1f48a
pineapple 1f34d
pisces 2653
pizza 1f355
point_down 1f447
point_left 1f448
point_right 1f449
point_up 261d
point_up_2 1f446
police_car 1f693
poodle 1f429
poop 1f4a9
post_office 1f3e3
postal_horn 1f4ef
postbox 1f4ee
potable_water 1f6b0
pouch 1f45d
poultry_leg 1f357
pound 1f4b7
pouting_cat 1f63e
pray 1f64f
princess 1f478
punch 1f44a
purple_heart 1f49c
purse 1f45b
pushpin 1f4cc
put_litter_in_its_place 1f6ae
question 2753
rabbit 1f430
rabbit2 1f407
racehorse 1f40e
radio 1f4fb
radio_button 1f518
rage 1f621
railway_car 1f683
rainbow 1f308
raised_hand 270b
raised_hands 1f64c
raising_hand 1f64b
ram 1f40f
ramen 1f35c
rat 1f400
recycle 267b
red_car 1f697
red_circle 1f534
registered 00ae
relaxed 263a
relieved 1f60c
repeat 1f501
repeat_one 1f502
restroom 1f6bb
revolving_hearts 1f49e
rewind 23ea
ribbon 1f380
rice 1f35a
rice_ball 1f359
rice_cracker 1f358
rice_scene 1f391
ring 1f48d
rocket 1f680
roller_coaster 1f3a2
rooster 1f413
rose 1f339
rotating_light 1f6a8
round_pushpin 1f4cd
rowboat 1f6a3
ru 1f1f7 1f1fa
rugby_football 1f3c9
runner 1f3c3
running 1f3c3
running_shirt_with_sash 1f3bd
sa 1f202
sagittarius 2650
sailboat 26f5
sake 1f376
sandal 1f461
santa 1f385
satellite 1f4e1
satisfied 1f606
saxophone 1f3b7
school 1f3eb
school_satchel 1f392
scissors 2702
scorpius 264f
scream 1f631
scream_cat 1f640
scroll 1f4dc
seat 1f4ba
secret 3299
see_no_evil 1f648
seedling 1f331
seven 0037 20e3
shaved_ice 1f367
sheep 1f411
shell 1f41a
ship 1f6a2
shirt 1f455
shit 1f4a9
shoe 1f45e
shower 1f6bf
signal_strength 1f4f6
six 0036 20e3
six_pointed_star 1f52f
ski 1f3bf
skull 1f480
sleeping 1f634
sleepy 1f62a
slot_machine 1f3b0
small_blue_diamond 1f539
small_orange_diamond 1f538
small_red_triangle 1f53a
small_red_triangle_down 1f53b
smile 1f604
smile_cat 1f638
smiley 1f603
smiley_cat 1f63a
smiling_imp 1f608
smirk 1f60f
smirk_cat 1f63c
smoking 1f6ac
snail 1f40c
snake 1f40d
snowboarder 1f3c2
snowflake 2744
snowman 26c4
sob 1f62d
soccer 26bd
soon 1f51c
sos 1f198
sound 1f509
space_invader 1f47e
spades 2660
spaghetti 1f35d
sparkle 2747
sparkler 1f387
sparkles 2728
sparkling_heart 1f496
speak_no_evil 1f64a
speaker 1f508
speech_balloon 1f4ac
speedboat 1f6a4
star 2b50
star2 1f31f
stars 1f320
station 1f689
statue_of_liberty 1f5fd
steam_locomotive 1f682
stew 1f372
straight_ruler 1f4cf
strawberry 1f353
stuck_out_tongue 1f61b
stuck_out_tongue_closed_eyes 1f61d
stuck_out_tongue_winking_eye 1f61c
sun_with_face 1f31e
sunflower 1f33b
sunglasses 1f60e
sunny 2600
sunrise 1f305
sunrise_over_mountains 1f304
surfer 1f3c4
sushi 1f363
suspension_railway 1f69f
sweat 1f613
sweat_drops 1f4a6
sweat_smile 1f605
sweet_potato 1f360
swimmer 1f3ca
symbols 1f523
syringe 1f489
tada 1f389
tanabata_tree 1f38b
tangerine 1f34a
taurus 2649
taxi 1f695
tea 1f375
telephone 260e
telephone_receiver 1f4de
telescope 1f52d
tennis 1f3be
tent 26fa
thought_balloon 1f4ad
three 0033 20e3
thumbsdown 1f44e
thumbsup 1f44d
ticket 1f3ab
tiger 1f42f
tiger2 1f405
tired_face 1f62b
tm 2122
toilet 1f6bd
tokyo_tower 1f5fc
tomato 1f345
tongue 1f445
top 1f51d
tophat 1f3a9
tractor 1f69c
traffic_light 1f6a5
train 1f68b
train2 1f686
tram 1f68a
triangular_flag_on_post 1f6a9
triangular_ruler 1f4d0
trident 1f531
triumph 1f624
trolleybus 1f68e
trophy 1f3c6
tropical_drink 1f379
tropical_fish 1f420
truck 1f69a
trumpet 1f3ba
tshirt 1f455
tulip 1f337
turtle 1f422
tv 1f4fa
twisted_rightwards_arrows 1f500
two 0032 20e3
two_hearts 1f495
two_men_holding_hands 1f46c
two_women_holding_hands 1f46d
u5272 1f239
u5408 1f234
u55b6 1f23a
u6307 1f22f
u6708 1f237
u6709 1f236
u6e80 1f235
u7121 1f21a
u7533 1f238
u7981 1f232
u7a7a 1f233
uk 1f1ec 1f1e7
umbrella 2614
unamused 1f612
underage 1f51e
unlock 1f513
up 1f199
us 1f1fa 1f1f8
v 270c
vertical_traffic_light 1f6a6
vhs 1f4fc
vibration_mode 1f4f3
video_camera 1f4f9
video_game 1f3ae
violin 1f3bb
virgo 264d
volcano 1f30b
vs 1f19a
walking 1f6b6
waning_crescent_moon 1f318
waning_gibbous_moon 1f316
warning 26a0
watch 231a
water_buffalo 1f403
watermelon 1f349
wave 1f44b
wavy_dash 3030
waxing_crescent_moon 1f312
waxing_gibbous_moon 1f314
wc 1f6be
weary 1f629
wedding 1f492
whale 1f433
whale2 1f40b
wheelchair 267f
white_check_mark 2705
white_circle 26aa
white_flower 1f4ae
white_large_square 2b1c
white_medium_small_square 25fd
white_medium_square 25fb
white_small_square 25ab
white_square_button 1f533
wind_chime 1f390
wine_glass 1f377
wink 1f609
wolf 1f43a
woman 1f469
womans_clothes 1f45a
womans_hat 1f452
womens 1f6ba
worried 1f61f
wrench 1f527
x 274c
yellow_heart 1f49b
yen 1f4b4
yum 1f60b
zap 26a1
zero 0030 20e3
zzz 1f4a4
simple_smile 1f642
##################
## custom emoji ##
##################
+1
bronze
bronze!?
bronze?!
euphoria
euphoria!
chromakode
pewpewpew
leck
dealwithit
spider
indigo_heart
orange_heart
bot
greenduck
phone 1f4f1

View file

@ -1,14 +1,3 @@
#![forbid(unsafe_code)]
// Rustc lint groups
#![warn(future_incompatible)]
#![warn(rust_2018_idioms)]
#![warn(unused)]
// Rustc lints
#![warn(noop_method_call)]
#![warn(single_use_lifetimes)]
// Clippy lints
#![warn(clippy::use_self)]
pub mod api;
#[cfg(feature = "bot")]
pub mod bot;

View file

@ -5,9 +5,10 @@ use unicode_normalization::UnicodeNormalization;
use crate::emoji::Emoji;
/// Does not remove emoji.
fn hue_normalize(text: &str) -> String {
text.chars()
fn hue_normalize(emoji: &Emoji, text: &str) -> String {
emoji
.remove(text)
.chars()
.filter(|&c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
.map(|c| c.to_ascii_lowercase())
.collect()
@ -15,7 +16,7 @@ fn hue_normalize(text: &str) -> String {
/// A re-implementation of [euphoria's nick hue hashing algorithm][0].
///
/// [0]: https://github.com/euphoria-io/heim/blob/master/client/lib/hueHash.js
/// [0]: https://github.com/CylonicRaider/heim/blob/097a1fde89ada53de2b70e51e635257f27956e4e/client/lib/heim/hueHash.js
fn hue_hash(text: &str, offset: i64) -> u8 {
let mut val = 0_i32;
for bibyte in text.encode_utf16() {
@ -35,7 +36,13 @@ 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 {
let normalized = hue_normalize(nick);
// 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>();
if normalized.is_empty() {
hue_hash(nick, GREENIE_OFFSET)
} else {
@ -48,9 +55,14 @@ 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/euphoria-io/heim/blob/978c921063e6b06012fc8d16d9fbf1b3a0be1191/client/lib/hueHash.js
/// [0]: https://github.com/CylonicRaider/heim/blob/097a1fde89ada53de2b70e51e635257f27956e4e/client/lib/heim/hueHash.js
pub fn hue(emoji: &Emoji, nick: &str) -> u8 {
hue_without_removing_emoji(&emoji.remove(nick))
let normalized = hue_normalize(emoji, nick);
if normalized.is_empty() {
hue_hash(nick, GREENIE_OFFSET)
} else {
hue_hash(&normalized, GREENIE_OFFSET)
}
}
/// Normalize a nick to a form that can be compared against other nicks.
@ -74,7 +86,7 @@ pub fn hue(emoji: &Emoji, nick: &str) -> u8 {
/// property that's easier to implement, even though it may be incorrect in some
/// edge cases.
///
/// [0]: https://github.com/euphoria-io/heim/blob/978c921063e6b06012fc8d16d9fbf1b3a0be1191/client/lib/stores/chat.js#L14
/// [0]: https://github.com/CylonicRaider/heim/blob/978c921063e6b06012fc8d16d9fbf1b3a0be1191/client/lib/stores/chat.js#L14
pub fn normalize(nick: &str) -> String {
mention(nick) // Step 1
.nfkc() // Step 2