Initial commit: Users can join!
Signed-off-by: Maxime “pep” Buquet <pep@bouah.net>
This commit is contained in:
commit
05033ee6f7
3 changed files with 366 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
/target
|
20
Cargo.toml
Normal file
20
Cargo.toml
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
[package]
|
||||||
|
name = "hanabi-muc"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
license = "AGPL-3.0-or-later"
|
||||||
|
description = "MUC implementation allowing participants to play the Hanabi game."
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
env_logger = "^0.9"
|
||||||
|
futures = "^0.3"
|
||||||
|
log = "^0.4"
|
||||||
|
tokio = "^1.20"
|
||||||
|
tokio-xmpp = { version = "^3.2", default-features = false, features = ["tls-rust"] }
|
||||||
|
xmpp-parsers = { version = "^0.19", features = ["component"] }
|
||||||
|
|
||||||
|
[patch.crates-io]
|
||||||
|
jid = { path = "../xmpp-rs/jid" }
|
||||||
|
minidom = { path = "../xmpp-rs/minidom" }
|
||||||
|
tokio-xmpp = { path = "../xmpp-rs/tokio-xmpp" }
|
||||||
|
xmpp-parsers = { path = "../xmpp-rs/parsers" }
|
345
src/main.rs
Normal file
345
src/main.rs
Normal file
|
@ -0,0 +1,345 @@
|
||||||
|
// Copyright (C) 2022-2099 The crate authors.
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify it
|
||||||
|
// under the terms of the GNU Affero General Public License as published by the
|
||||||
|
// Free Software Foundation, either version 3 of the License, or (at your
|
||||||
|
// option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful, but WITHOUT
|
||||||
|
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
||||||
|
// FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License
|
||||||
|
// for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
#![feature(once_cell)]
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::convert::TryFrom;
|
||||||
|
use std::env::args;
|
||||||
|
use std::error::Error as StdError;
|
||||||
|
use std::fmt;
|
||||||
|
use std::iter::IntoIterator;
|
||||||
|
use std::ops::ControlFlow;
|
||||||
|
use std::process::exit;
|
||||||
|
use std::sync::{LazyLock, Mutex};
|
||||||
|
|
||||||
|
use env_logger;
|
||||||
|
use futures::stream::StreamExt;
|
||||||
|
use log::{debug, error, info};
|
||||||
|
use tokio_xmpp::{Component, Error as TokioXMPPError};
|
||||||
|
use xmpp_parsers::{
|
||||||
|
disco::{DiscoInfoQuery, DiscoInfoResult, Feature, Identity},
|
||||||
|
iq::{Iq, IqType},
|
||||||
|
message::{Message, Subject, MessageType},
|
||||||
|
muc::{
|
||||||
|
user::{Affiliation, Item as MucItem, Role, Status as MucStatus},
|
||||||
|
Muc, MucUser,
|
||||||
|
},
|
||||||
|
ns,
|
||||||
|
presence::{Presence, Type as PresenceType},
|
||||||
|
stanza_error::{DefinedCondition, ErrorType, StanzaError},
|
||||||
|
BareJid, Element, FullJid, Jid,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum Error {
|
||||||
|
MismatchJids(Jid),
|
||||||
|
NickAlreadyAssigned(String),
|
||||||
|
XMPPError(TokioXMPPError),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StdError for Error {}
|
||||||
|
|
||||||
|
impl fmt::Display for Error {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Error::MismatchJids(err) => write!(f, "Mismatch Jids: {}", err),
|
||||||
|
Error::NickAlreadyAssigned(err) => write!(f, "Nickname already assigned: {}", err),
|
||||||
|
Error::XMPPError(err) => write!(f, "XMPP error: {}", err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<TokioXMPPError> for Error {
|
||||||
|
fn from(err: TokioXMPPError) -> Error {
|
||||||
|
Error::XMPPError(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_stanza<E: Into<Element>>(component: &mut Component, el: E) -> Result<(), Error> {
|
||||||
|
let el: Element = el.into();
|
||||||
|
debug!("SEND: {}", String::from(&el));
|
||||||
|
component.send_stanza(el).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type Nick = String;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct Room {
|
||||||
|
jid: BareJid,
|
||||||
|
occupants: HashMap<BareJid, Occupant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Room {
|
||||||
|
fn new(jid: BareJid) -> Self {
|
||||||
|
Room {
|
||||||
|
jid,
|
||||||
|
occupants: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn add_session(
|
||||||
|
&mut self,
|
||||||
|
component: &mut Component,
|
||||||
|
realjid: FullJid,
|
||||||
|
nick: Nick,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
let bare = BareJid::from(realjid.clone());
|
||||||
|
if let Some(occupant) = self.occupants.get_mut(&bare) {
|
||||||
|
occupant.add_session(realjid)?;
|
||||||
|
} else {
|
||||||
|
debug!("{} is joining {}", realjid, self.jid);
|
||||||
|
|
||||||
|
// Ensure nick isn't already assigned
|
||||||
|
let _ = self.occupants.iter().try_for_each(|(_, occupant)| {
|
||||||
|
let nick = nick.clone();
|
||||||
|
if occupant.nick == nick {
|
||||||
|
return Err(Error::NickAlreadyAssigned(nick));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Send occupants
|
||||||
|
debug!("Sending occupants for {}", realjid);
|
||||||
|
let presence = Presence::new(PresenceType::None)
|
||||||
|
.with_to(realjid.clone());
|
||||||
|
for (_, occupant) in self.occupants.iter() {
|
||||||
|
for session in occupant.iter() {
|
||||||
|
let presence = presence.clone().with_from(session.clone());
|
||||||
|
send_stanza(component, presence).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add into occupants
|
||||||
|
let _ = self
|
||||||
|
.occupants
|
||||||
|
.insert(bare.clone(), Occupant::new(realjid.clone()));
|
||||||
|
|
||||||
|
// Self-presence
|
||||||
|
debug!("Sending self-presence for {}", realjid);
|
||||||
|
let participant: FullJid = self.jid.clone().with_resource(nick);
|
||||||
|
let status = vec![MucStatus::SelfPresence, MucStatus::AssignedNick];
|
||||||
|
let items = vec![MucItem::new(Affiliation::Owner, Role::Moderator)];
|
||||||
|
let self_presence = Presence::new(PresenceType::None)
|
||||||
|
.with_from(participant)
|
||||||
|
.with_to(realjid.clone())
|
||||||
|
.with_payloads(vec![MucUser { status, items }.into()]);
|
||||||
|
send_stanza(component, self_presence).await?;
|
||||||
|
|
||||||
|
// Send subject
|
||||||
|
debug!("Sending subject!");
|
||||||
|
let mut subject = Message::new(Some(Jid::Full(realjid)));
|
||||||
|
subject.from = Some(Jid::Bare(self.jid.clone()));
|
||||||
|
subject.subjects.insert(String::from("en"), Subject(String::from("Hanabi")));
|
||||||
|
subject.type_ = MessageType::Groupchat;
|
||||||
|
send_stanza(component, subject).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct Occupant {
|
||||||
|
jid: BareJid,
|
||||||
|
nick: Nick,
|
||||||
|
sessions: Vec<FullJid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Occupant {
|
||||||
|
fn new(fulljid: FullJid) -> Occupant {
|
||||||
|
Occupant {
|
||||||
|
jid: BareJid::from(fulljid.clone()),
|
||||||
|
nick: fulljid.resource.clone(),
|
||||||
|
sessions: vec![fulljid],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_session(&mut self, fulljid: FullJid) -> Result<(), Error> {
|
||||||
|
if BareJid::from(fulljid.clone()) != self.jid {
|
||||||
|
return Err(Error::MismatchJids(Jid::from(fulljid.clone())));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoIterator for Occupant {
|
||||||
|
type Item = FullJid;
|
||||||
|
type IntoIter = std::vec::IntoIter<Self::Item>;
|
||||||
|
|
||||||
|
fn into_iter(self) -> Self::IntoIter {
|
||||||
|
self.sessions.into_iter()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Occupant {
|
||||||
|
fn iter(&self) -> std::slice::Iter<'_, FullJid> {
|
||||||
|
self.sessions.iter()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn iter_mut(&mut self) -> std::slice::IterMut<'_, FullJid> {
|
||||||
|
self.sessions.iter_mut()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static mut ROOMS: LazyLock<Mutex<HashMap<BareJid, Room>>> =
|
||||||
|
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||||
|
|
||||||
|
async fn handle_iq_disco(component: &mut Component, iq: Iq, payload: Element) {
|
||||||
|
match DiscoInfoQuery::try_from(payload) {
|
||||||
|
Ok(DiscoInfoQuery { node }) if node.is_none() => {
|
||||||
|
let identities = vec![Identity::new("conference", "text", "en", "Hanabi")];
|
||||||
|
let features = vec![
|
||||||
|
Feature::new("http://jabber.org/protocol/disco#info"),
|
||||||
|
Feature::new("xmpp:bouah.net:hanabi:muc:0"),
|
||||||
|
];
|
||||||
|
let extensions = Vec::new();
|
||||||
|
let payload = DiscoInfoResult {
|
||||||
|
node: None,
|
||||||
|
identities,
|
||||||
|
features,
|
||||||
|
extensions,
|
||||||
|
};
|
||||||
|
let reply = Iq::from_result(iq.id, Some(payload))
|
||||||
|
.with_from(iq.to.unwrap())
|
||||||
|
.with_to(iq.from.unwrap());
|
||||||
|
send_stanza(component, reply).await.unwrap();
|
||||||
|
}
|
||||||
|
Ok(DiscoInfoQuery { .. }) => {
|
||||||
|
let error = StanzaError::new(
|
||||||
|
ErrorType::Modify,
|
||||||
|
DefinedCondition::BadRequest,
|
||||||
|
"en",
|
||||||
|
format!("Unknown disco#info node"),
|
||||||
|
);
|
||||||
|
let reply = Iq::from_error(iq.id, error)
|
||||||
|
.with_from(iq.to.unwrap())
|
||||||
|
.with_to(iq.from.unwrap());
|
||||||
|
send_stanza(component, reply).await.unwrap();
|
||||||
|
}
|
||||||
|
Err(err) => error!("Failed to parse iq: {}", err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_iq(component: &mut Component, iq: Iq) {
|
||||||
|
match iq.payload {
|
||||||
|
IqType::Get(ref payload) => {
|
||||||
|
if payload.is("query", ns::DISCO_INFO) {
|
||||||
|
handle_iq_disco(component, iq.clone(), payload.clone()).await
|
||||||
|
} else {
|
||||||
|
// We MUST answer unhandled get iqs with a service-unavailable error.
|
||||||
|
let error = StanzaError::new(
|
||||||
|
ErrorType::Cancel,
|
||||||
|
DefinedCondition::ServiceUnavailable,
|
||||||
|
"en",
|
||||||
|
"No handler defined for this kind of iq.",
|
||||||
|
);
|
||||||
|
let iq = Iq::from_error(iq.id, error)
|
||||||
|
.with_from(iq.to.unwrap())
|
||||||
|
.with_to(iq.from.unwrap())
|
||||||
|
.into();
|
||||||
|
let _ = component.send_stanza(iq).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => error!("Not handled iq: {:?}", iq),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_presence(component: &mut Component, presence: Presence) {
|
||||||
|
let muc = presence
|
||||||
|
.payloads
|
||||||
|
.into_iter()
|
||||||
|
.try_for_each(|payload| match Muc::try_from(payload) {
|
||||||
|
Ok(muc) => ControlFlow::Break(muc),
|
||||||
|
_ => ControlFlow::Continue(()),
|
||||||
|
});
|
||||||
|
|
||||||
|
if let ControlFlow::Continue(_) = muc {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Presences to MUC come from resources not accounts
|
||||||
|
if let Jid::Full(realjid) = presence.from.unwrap() &&
|
||||||
|
let Jid::Full(participant) = presence.to.unwrap() {
|
||||||
|
|
||||||
|
let roomjid = BareJid::from(participant.clone());
|
||||||
|
let nick: Nick = participant.resource.clone();
|
||||||
|
|
||||||
|
// Room already exists
|
||||||
|
if let Some(room) = unsafe { ROOMS.lock().unwrap().get_mut(&roomjid) } {
|
||||||
|
debug!("Presence received to existing room: {}", &roomjid);
|
||||||
|
room.add_session(component, realjid, nick).await.unwrap();
|
||||||
|
} else {
|
||||||
|
debug!("Presence received to new room: {}", &roomjid);
|
||||||
|
let mut room = Room::new(roomjid.clone());
|
||||||
|
room.add_session(component, realjid, nick).await.unwrap();
|
||||||
|
let _ = unsafe { ROOMS.lock().unwrap().insert(roomjid, room) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_message(_component: &mut Component, _message: Message) {}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
let args: Vec<String> = args().collect();
|
||||||
|
if args.len() != 3 {
|
||||||
|
println!("Usage: {} <jid> <password>", args[0]);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let jid = &args[1];
|
||||||
|
let passwd = &args[2];
|
||||||
|
let server = "::1";
|
||||||
|
let port = 5347u16;
|
||||||
|
|
||||||
|
env_logger::init();
|
||||||
|
|
||||||
|
let mut component = Component::new(jid, passwd, server, port).await.unwrap();
|
||||||
|
info!("Online as {}!", component.jid);
|
||||||
|
|
||||||
|
while let Some(elem) = component.next().await {
|
||||||
|
debug!("RECV {}", String::from(&elem));
|
||||||
|
if elem.is("iq", ns::COMPONENT_ACCEPT) {
|
||||||
|
let iq = Iq::try_from(elem).unwrap();
|
||||||
|
handle_iq(&mut component, iq).await;
|
||||||
|
} else if elem.is("message", ns::COMPONENT_ACCEPT) {
|
||||||
|
let message = Message::try_from(elem).unwrap();
|
||||||
|
handle_message(&mut component, message).await;
|
||||||
|
} else if elem.is("presence", ns::COMPONENT_ACCEPT) {
|
||||||
|
let presence = Presence::try_from(elem).unwrap();
|
||||||
|
handle_presence(&mut component, presence).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use xmpp_parsers::{BareJid, FullJid, Jid};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn occupant_ignore_dup_session() {
|
||||||
|
let fulljid = FullJid::from_str("foo@bar/meh").unwrap();
|
||||||
|
let mut occupant = Occupant::new(fulljid.clone());
|
||||||
|
occupant.add_session(fulljid.clone());
|
||||||
|
assert_eq!(occupant.iter().count(), 1);
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue