feat: comprehensive project improvements
Some checks failed
CI / Rust Format (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Test Server (push) Has been cancelled
CI / Frontend Check (push) Has been cancelled
CI / Tauri Client Check (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Build Tauri (Linux) (push) Has been cancelled
Some checks failed
CI / Rust Format (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Test Server (push) Has been cancelled
CI / Frontend Check (push) Has been cancelled
CI / Tauri Client Check (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Build Tauri (Linux) (push) Has been cancelled
- Fix 14 Clippy warnings across server and bot-sdk - Add 67 unit tests (32 bot-sdk, 34 server, 1 doctest) - Add Prometheus metrics endpoint (/api/metrics) - Add structured JSON logging (EIFELDC_LOG_FORMAT=json) - Add release workflow (Docker push + GitHub Release + Tauri builds) - Add rate limiting middleware (EIFELDC_RATE_LIMIT) - Add CORS restriction (EIFELDC_CORS_ORIGINS) - Add session token expiry (EIFELDC_SESSION_TTL) - Add input validation (username/password/homeserver length limits) - Add upload size limit (EIFELDC_MAX_UPLOAD_MB) - Upgrade Tauri client from v1 to v2 - Add session store with SQLite persistence - Add proper error types and cleanup across all crates - Format all code with cargo fmt - Update CI pipeline with fmt, clippy, test, frontend, and Tauri checks - Add README with full API reference and setup guide
This commit is contained in:
@@ -5,18 +5,10 @@ edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
matrix-sdk = { workspace = true }
|
||||
matrix-sdk-base = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
url = { workspace = true }
|
||||
async-trait = "0.1"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
[dev-dependencies]
|
||||
mockito = "1"
|
||||
futures = "0.3"
|
||||
mime = "0.3"
|
||||
@@ -1,3 +1,4 @@
|
||||
#[derive(Default)]
|
||||
pub struct BotAuth {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
@@ -21,4 +22,46 @@ impl BotAuth {
|
||||
pub fn is_configured(&self) -> bool {
|
||||
!self.username.is_empty() && !self.password.is_empty()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn new_creates_empty_auth() {
|
||||
let auth = BotAuth::new();
|
||||
assert!(auth.username.is_empty());
|
||||
assert!(auth.password.is_empty());
|
||||
assert!(!auth.is_configured());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_creates_empty_auth() {
|
||||
let auth = BotAuth::default();
|
||||
assert!(auth.username.is_empty());
|
||||
assert!(auth.password.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_credentials_creates_configured_auth() {
|
||||
let auth = BotAuth::with_credentials("botuser", "secret123");
|
||||
assert_eq!(auth.username, "botuser");
|
||||
assert_eq!(auth.password, "secret123");
|
||||
assert!(auth.is_configured());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_configured_false_when_username_empty() {
|
||||
let mut auth = BotAuth::new();
|
||||
auth.password = "secret".to_string();
|
||||
assert!(!auth.is_configured());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_configured_false_when_password_empty() {
|
||||
let mut auth = BotAuth::new();
|
||||
auth.username = "user".to_string();
|
||||
assert!(!auth.is_configured());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,132 +1,574 @@
|
||||
use crate::auth::BotAuth;
|
||||
use crate::commands::{CommandHandler, CommandRegistry, SharedCommandRegistry};
|
||||
use crate::event::{BotEvent, EventHandler, SharedEventHandler};
|
||||
use crate::room::{RoomInfo, RoomManager, SharedRoomManager};
|
||||
use matrix_sdk::config::SyncSettings;
|
||||
use matrix_sdk::ruma::events::reaction::ReactionEventContent;
|
||||
use matrix_sdk::ruma::events::relation::{Annotation, InReplyTo, Replacement};
|
||||
use matrix_sdk::ruma::events::room::message::{
|
||||
EmoteMessageEventContent, FileMessageEventContent, ImageMessageEventContent, MessageType,
|
||||
Relation, RoomMessageEventContent, RoomMessageEventContentWithoutRelation,
|
||||
};
|
||||
use matrix_sdk::ruma::OwnedRoomId;
|
||||
use matrix_sdk::Client;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::auth::BotAuth;
|
||||
use crate::commands::CommandRegistry;
|
||||
use crate::event::EventHandler;
|
||||
use crate::room::RoomManager;
|
||||
use tokio::sync::watch;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
pub struct BotClient {
|
||||
client: Arc<RwLock<Option<Client>>>,
|
||||
auth: Arc<RwLock<BotAuth>>,
|
||||
commands: Arc<RwLock<CommandRegistry>>,
|
||||
event_handler: Arc<RwLock<EventHandler>>,
|
||||
room_manager: Arc<RwLock<RoomManager>>,
|
||||
homeserver: String,
|
||||
auth: BotAuth,
|
||||
client: Option<Client>,
|
||||
command_prefix: String,
|
||||
commands: SharedCommandRegistry,
|
||||
event_handlers: SharedEventHandler,
|
||||
rooms: SharedRoomManager,
|
||||
shutdown_tx: Option<watch::Sender<bool>>,
|
||||
}
|
||||
|
||||
impl BotClient {
|
||||
pub fn new(homeserver: &str) -> Self {
|
||||
Self {
|
||||
client: Arc::new(RwLock::new(None)),
|
||||
auth: Arc::new(RwLock::new(BotAuth::new())),
|
||||
commands: Arc::new(RwLock::new(CommandRegistry::new())),
|
||||
event_handler: Arc::new(RwLock::new(EventHandler::new())),
|
||||
room_manager: Arc::new(RwLock::new(RoomManager::new())),
|
||||
homeserver: homeserver.to_string(),
|
||||
auth: BotAuth::new(),
|
||||
client: None,
|
||||
command_prefix: "!".to_string(),
|
||||
commands: Arc::new(Mutex::new(CommandRegistry::new())),
|
||||
event_handlers: Arc::new(Mutex::new(EventHandler::new())),
|
||||
rooms: Arc::new(Mutex::new(RoomManager::new())),
|
||||
shutdown_tx: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_auth(self, username: &str, password: &str) -> Self {
|
||||
let auth = BotAuth::with_credentials(username, password);
|
||||
self.auth = Arc::new(RwLock::new(auth));
|
||||
pub fn with_auth(mut self, username: &str, password: &str) -> Self {
|
||||
self.auth = BotAuth::with_credentials(username, password);
|
||||
self
|
||||
}
|
||||
|
||||
pub async fn start(&self) -> anyhow::Result<()> {
|
||||
pub fn with_command_prefix(mut self, prefix: &str) -> Self {
|
||||
self.command_prefix = prefix.to_string();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_command(&self, name: &str, handler: CommandHandler) {
|
||||
let mut commands = self.commands.blocking_lock();
|
||||
commands.set_prefix(&self.command_prefix);
|
||||
commands.register(name, handler);
|
||||
}
|
||||
|
||||
pub fn on_event(&self, handler: impl Fn(BotEvent) + Send + Sync + 'static) {
|
||||
let mut handlers = self.event_handlers.blocking_lock();
|
||||
handlers.add_handler(Arc::new(handler));
|
||||
}
|
||||
|
||||
pub fn get_rooms(&self) -> Vec<RoomInfo> {
|
||||
self.rooms
|
||||
.blocking_lock()
|
||||
.list_rooms()
|
||||
.iter()
|
||||
.map(|r| (*r).clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn send_message(&self, room_id: &str, message: &str) -> anyhow::Result<String> {
|
||||
let client = self
|
||||
.client
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Bot not started"))?;
|
||||
let rid: OwnedRoomId = room_id
|
||||
.parse()
|
||||
.map_err(|e| anyhow::anyhow!("Invalid room ID: {}", e))?;
|
||||
let room = client
|
||||
.get_room(&rid)
|
||||
.ok_or_else(|| anyhow::anyhow!("Room not found: {}", room_id))?;
|
||||
let content =
|
||||
matrix_sdk::ruma::events::room::message::RoomMessageEventContent::text_plain(message);
|
||||
let response = room
|
||||
.send(content)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to send message: {}", e))?;
|
||||
Ok(response.event_id.to_string())
|
||||
}
|
||||
|
||||
pub async fn send_notice(&self, room_id: &str, message: &str) -> anyhow::Result<String> {
|
||||
let client = self
|
||||
.client
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Bot not started"))?;
|
||||
let rid: OwnedRoomId = room_id
|
||||
.parse()
|
||||
.map_err(|e| anyhow::anyhow!("Invalid room ID: {}", e))?;
|
||||
let room = client
|
||||
.get_room(&rid)
|
||||
.ok_or_else(|| anyhow::anyhow!("Room not found: {}", room_id))?;
|
||||
let content =
|
||||
matrix_sdk::ruma::events::room::message::RoomMessageEventContent::notice_plain(message);
|
||||
let response = room
|
||||
.send(content)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to send notice: {}", e))?;
|
||||
Ok(response.event_id.to_string())
|
||||
}
|
||||
|
||||
pub async fn send_emote(&self, room_id: &str, message: &str) -> anyhow::Result<String> {
|
||||
let client = self
|
||||
.client
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Bot not started"))?;
|
||||
let rid: OwnedRoomId = room_id
|
||||
.parse()
|
||||
.map_err(|e| anyhow::anyhow!("Invalid room ID: {}", e))?;
|
||||
let room = client
|
||||
.get_room(&rid)
|
||||
.ok_or_else(|| anyhow::anyhow!("Room not found: {}", room_id))?;
|
||||
let content = RoomMessageEventContent::new(MessageType::Emote(
|
||||
EmoteMessageEventContent::plain(message),
|
||||
));
|
||||
let response = room
|
||||
.send(content)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to send emote: {}", e))?;
|
||||
Ok(response.event_id.to_string())
|
||||
}
|
||||
|
||||
pub async fn edit_message(
|
||||
&self,
|
||||
room_id: &str,
|
||||
event_id: &str,
|
||||
new_body: &str,
|
||||
) -> anyhow::Result<String> {
|
||||
let client = self
|
||||
.client
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Bot not started"))?;
|
||||
let rid: OwnedRoomId = room_id
|
||||
.parse()
|
||||
.map_err(|e| anyhow::anyhow!("Invalid room ID: {}", e))?;
|
||||
let room = client
|
||||
.get_room(&rid)
|
||||
.ok_or_else(|| anyhow::anyhow!("Room not found: {}", room_id))?;
|
||||
let eid: matrix_sdk::ruma::OwnedEventId = event_id
|
||||
.parse()
|
||||
.map_err(|e| anyhow::anyhow!("Invalid event ID: {}", e))?;
|
||||
let new_content = RoomMessageEventContentWithoutRelation::text_plain(new_body);
|
||||
let replaces = Replacement::new(eid, new_content);
|
||||
let mut content = RoomMessageEventContent::text_plain(new_body);
|
||||
content.relates_to = Some(Relation::Replacement(replaces));
|
||||
let response = room
|
||||
.send(content)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to edit message: {}", e))?;
|
||||
Ok(response.event_id.to_string())
|
||||
}
|
||||
|
||||
pub async fn delete_message(
|
||||
&self,
|
||||
room_id: &str,
|
||||
event_id: &str,
|
||||
reason: Option<&str>,
|
||||
) -> anyhow::Result<()> {
|
||||
let client = self
|
||||
.client
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Bot not started"))?;
|
||||
let rid: OwnedRoomId = room_id
|
||||
.parse()
|
||||
.map_err(|e| anyhow::anyhow!("Invalid room ID: {}", e))?;
|
||||
let room = client
|
||||
.get_room(&rid)
|
||||
.ok_or_else(|| anyhow::anyhow!("Room not found: {}", room_id))?;
|
||||
let eid: matrix_sdk::ruma::OwnedEventId = event_id
|
||||
.parse()
|
||||
.map_err(|e| anyhow::anyhow!("Invalid event ID: {}", e))?;
|
||||
room.redact(&eid, reason, None)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to delete message: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn react(&self, room_id: &str, event_id: &str, key: &str) -> anyhow::Result<String> {
|
||||
let client = self
|
||||
.client
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Bot not started"))?;
|
||||
let rid: OwnedRoomId = room_id
|
||||
.parse()
|
||||
.map_err(|e| anyhow::anyhow!("Invalid room ID: {}", e))?;
|
||||
let room = client
|
||||
.get_room(&rid)
|
||||
.ok_or_else(|| anyhow::anyhow!("Room not found: {}", room_id))?;
|
||||
let eid: matrix_sdk::ruma::OwnedEventId = event_id
|
||||
.parse()
|
||||
.map_err(|e| anyhow::anyhow!("Invalid event ID: {}", e))?;
|
||||
let annotation = Annotation::new(eid.clone(), key.to_string());
|
||||
let react_content = ReactionEventContent::new(annotation);
|
||||
let response = room
|
||||
.send(react_content)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to react: {}", e))?;
|
||||
Ok(response.event_id.to_string())
|
||||
}
|
||||
|
||||
pub async fn upload_media(
|
||||
&self,
|
||||
room_id: &str,
|
||||
data: Vec<u8>,
|
||||
content_type: &str,
|
||||
filename: &str,
|
||||
) -> anyhow::Result<String> {
|
||||
let client = self
|
||||
.client
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Bot not started"))?;
|
||||
let rid: OwnedRoomId = room_id
|
||||
.parse()
|
||||
.map_err(|e| anyhow::anyhow!("Invalid room ID: {}", e))?;
|
||||
let room = client
|
||||
.get_room(&rid)
|
||||
.ok_or_else(|| anyhow::anyhow!("Room not found: {}", room_id))?;
|
||||
let mime_type: mime::Mime = content_type
|
||||
.parse()
|
||||
.map_err(|e| anyhow::anyhow!("Invalid MIME type: {}", e))?;
|
||||
let upload = client
|
||||
.media()
|
||||
.upload(&mime_type, data)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Upload failed: {}", e))?;
|
||||
let content = if mime_type.type_() == mime::IMAGE {
|
||||
RoomMessageEventContent::new(MessageType::Image(ImageMessageEventContent::plain(
|
||||
filename.to_string(),
|
||||
upload.content_uri,
|
||||
)))
|
||||
} else {
|
||||
RoomMessageEventContent::new(MessageType::File(FileMessageEventContent::plain(
|
||||
filename.to_string(),
|
||||
upload.content_uri,
|
||||
)))
|
||||
};
|
||||
let response = room
|
||||
.send(content)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to send media: {}", e))?;
|
||||
Ok(response.event_id.to_string())
|
||||
}
|
||||
|
||||
pub async fn join_room(&self, room_id_or_alias: &str) -> anyhow::Result<String> {
|
||||
let client = self
|
||||
.client
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Bot not started"))?;
|
||||
let alias: matrix_sdk::ruma::OwnedRoomOrAliasId = room_id_or_alias
|
||||
.parse()
|
||||
.map_err(|e| anyhow::anyhow!("Invalid room alias: {}", e))?;
|
||||
let room = client
|
||||
.join_room_by_id_or_alias(&alias, &[])
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to join room: {}", e))?;
|
||||
Ok(room.room_id().to_string())
|
||||
}
|
||||
|
||||
pub async fn leave_room(&self, room_id: &str) -> anyhow::Result<()> {
|
||||
let client = self
|
||||
.client
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Bot not started"))?;
|
||||
let rid: OwnedRoomId = room_id
|
||||
.parse()
|
||||
.map_err(|e| anyhow::anyhow!("Invalid room ID: {}", e))?;
|
||||
let room = client
|
||||
.get_room(&rid)
|
||||
.ok_or_else(|| anyhow::anyhow!("Room not found: {}", room_id))?;
|
||||
room.leave()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to leave room: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_room_members(&self, room_id: &str) -> anyhow::Result<Vec<String>> {
|
||||
let client = self
|
||||
.client
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Bot not started"))?;
|
||||
let rid: OwnedRoomId = room_id
|
||||
.parse()
|
||||
.map_err(|e| anyhow::anyhow!("Invalid room ID: {}", e))?;
|
||||
let room = client
|
||||
.get_room(&rid)
|
||||
.ok_or_else(|| anyhow::anyhow!("Room not found: {}", room_id))?;
|
||||
let members = room
|
||||
.members(matrix_sdk::RoomMemberships::JOIN)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to get members: {}", e))?;
|
||||
Ok(members.iter().map(|m| m.user_id().to_string()).collect())
|
||||
}
|
||||
|
||||
pub async fn set_room_name(&self, room_id: &str, name: &str) -> anyhow::Result<()> {
|
||||
let client = self
|
||||
.client
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Bot not started"))?;
|
||||
let rid: OwnedRoomId = room_id
|
||||
.parse()
|
||||
.map_err(|e| anyhow::anyhow!("Invalid room ID: {}", e))?;
|
||||
let room = client
|
||||
.get_room(&rid)
|
||||
.ok_or_else(|| anyhow::anyhow!("Room not found: {}", room_id))?;
|
||||
room.set_name(name.to_string())
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to set room name: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_room_topic(&self, room_id: &str, topic: &str) -> anyhow::Result<()> {
|
||||
let client = self
|
||||
.client
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Bot not started"))?;
|
||||
let rid: OwnedRoomId = room_id
|
||||
.parse()
|
||||
.map_err(|e| anyhow::anyhow!("Invalid room ID: {}", e))?;
|
||||
let room = client
|
||||
.get_room(&rid)
|
||||
.ok_or_else(|| anyhow::anyhow!("Room not found: {}", room_id))?;
|
||||
room.set_room_topic(topic)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to set room topic: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_display_name(&self, user_id: &str) -> anyhow::Result<Option<String>> {
|
||||
let client = self
|
||||
.client
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Bot not started"))?;
|
||||
let uid: matrix_sdk::ruma::OwnedUserId = user_id
|
||||
.parse()
|
||||
.map_err(|e| anyhow::anyhow!("Invalid user ID: {}", e))?;
|
||||
let profile = client
|
||||
.get_profile(&uid)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to get profile: {}", e))?;
|
||||
Ok(profile.displayname)
|
||||
}
|
||||
|
||||
pub async fn set_display_name(&self, name: &str) -> anyhow::Result<()> {
|
||||
let client = self
|
||||
.client
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Bot not started"))?;
|
||||
client
|
||||
.account()
|
||||
.set_display_name(Some(name))
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to set display name: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn send_reply(
|
||||
&self,
|
||||
room_id: &str,
|
||||
reply_to_event_id: &str,
|
||||
message: &str,
|
||||
) -> anyhow::Result<String> {
|
||||
let client = self
|
||||
.client
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Bot not started"))?;
|
||||
let rid: OwnedRoomId = room_id
|
||||
.parse()
|
||||
.map_err(|e| anyhow::anyhow!("Invalid room ID: {}", e))?;
|
||||
let room = client
|
||||
.get_room(&rid)
|
||||
.ok_or_else(|| anyhow::anyhow!("Room not found: {}", room_id))?;
|
||||
let eid: matrix_sdk::ruma::OwnedEventId = reply_to_event_id
|
||||
.parse()
|
||||
.map_err(|e| anyhow::anyhow!("Invalid event ID: {}", e))?;
|
||||
let mut content = RoomMessageEventContent::text_plain(message);
|
||||
let reply = InReplyTo::new(eid);
|
||||
content.relates_to = Some(Relation::Reply { in_reply_to: reply });
|
||||
let response = room
|
||||
.send(content)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to send reply: {}", e))?;
|
||||
Ok(response.event_id.to_string())
|
||||
}
|
||||
|
||||
pub async fn start(&mut self) -> anyhow::Result<()> {
|
||||
let client = Client::builder()
|
||||
.homeserver_url(&self.homeserver)
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
{
|
||||
let mut guard = self.client.write().await;
|
||||
*guard = Some(client.clone());
|
||||
}
|
||||
|
||||
let auth = self.auth.read().await;
|
||||
client
|
||||
.matrix_auth()
|
||||
.login_username(&auth.username, &auth.password)
|
||||
.login_username(&self.auth.username, &self.auth.password)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
tracing::info!("Bot logged in as {}", auth.username);
|
||||
drop(auth);
|
||||
tracing::info!("Bot logged in as {}", self.auth.username);
|
||||
|
||||
self.client = Some(client.clone());
|
||||
|
||||
let (shutdown_tx, mut shutdown_rx) = watch::channel(false);
|
||||
self.shutdown_tx = Some(shutdown_tx);
|
||||
|
||||
let commands = self.commands.clone();
|
||||
let event_handlers = self.event_handlers.clone();
|
||||
let rooms = self.rooms.clone();
|
||||
|
||||
tracing::info!("Bot starting sync loop with manual event processing...");
|
||||
|
||||
let mut sync_token: Option<String> = None;
|
||||
loop {
|
||||
let mut settings = matrix_sdk::config::SyncSettings::new();
|
||||
if let Some(token) = sync_token.as_ref() {
|
||||
settings = settings.token(token.clone());
|
||||
}
|
||||
match client.sync_once(settings).await {
|
||||
Ok(response) => {
|
||||
sync_token = Some(response.next_batch);
|
||||
tokio::select! {
|
||||
result = client.sync_once(SyncSettings::new()) => {
|
||||
match result {
|
||||
Ok(response) => {
|
||||
{
|
||||
let eh = event_handlers.lock().await;
|
||||
eh.dispatch(BotEvent::SyncComplete);
|
||||
}
|
||||
|
||||
let handler = self.event_handler.read().await;
|
||||
handler.dispatch("sync");
|
||||
drop(handler);
|
||||
for (room_id, joined) in &response.rooms.join {
|
||||
let _room = match client.get_room(room_id) {
|
||||
Some(r) => r,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let rooms = client.joined_rooms();
|
||||
let mut room_mgr = self.room_manager.write().await;
|
||||
for room in rooms {
|
||||
let name = room.display_name().await.map(|n| n.to_string()).unwrap_or_default();
|
||||
room_mgr.add_room(crate::room::RoomInfo {
|
||||
room_id: room.room_id().to_string(),
|
||||
name,
|
||||
is_encrypted: room.is_encrypted().await.unwrap_or(false),
|
||||
});
|
||||
let room_id_str = room_id.to_string();
|
||||
|
||||
for event in &joined.timeline.events {
|
||||
let raw_json: serde_json::Value = match event.event.deserialize_as() {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let event_type = raw_json.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let sender = raw_json.get("sender").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let event_id = event.event_id().map(|e| e.to_string()).unwrap_or_default();
|
||||
let content = raw_json.get("content").unwrap_or(&serde_json::Value::Null);
|
||||
|
||||
if event_type == "m.room.message" {
|
||||
let body = content.get("body").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let msgtype = content.get("msgtype").and_then(|v| v.as_str()).unwrap_or("m.text");
|
||||
let url = content.get("url").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
|
||||
if msgtype == "m.image" {
|
||||
let eh = event_handlers.lock().await;
|
||||
eh.dispatch(BotEvent::Image {
|
||||
room_id: room_id_str.clone(),
|
||||
event_id: event_id.clone(),
|
||||
sender: sender.to_string(),
|
||||
body: body.to_string(),
|
||||
url: url.unwrap_or_default(),
|
||||
});
|
||||
drop(eh);
|
||||
} else if msgtype == "m.file" || msgtype == "m.video" || msgtype == "m.audio" {
|
||||
let eh = event_handlers.lock().await;
|
||||
eh.dispatch(BotEvent::File {
|
||||
room_id: room_id_str.clone(),
|
||||
event_id: event_id.clone(),
|
||||
sender: sender.to_string(),
|
||||
body: body.to_string(),
|
||||
url: url.unwrap_or_default(),
|
||||
});
|
||||
drop(eh);
|
||||
} else if !body.is_empty() {
|
||||
let eh = event_handlers.lock().await;
|
||||
eh.dispatch(BotEvent::Message {
|
||||
room_id: room_id_str.clone(),
|
||||
event_id: event_id.clone(),
|
||||
sender: sender.to_string(),
|
||||
body: body.to_string(),
|
||||
});
|
||||
drop(eh);
|
||||
|
||||
let reg = commands.lock().await;
|
||||
reg.parse_and_execute(body, sender, &room_id_str);
|
||||
}
|
||||
} else if event_type == "m.reaction" {
|
||||
let key = content.get("m.relates_to")
|
||||
.and_then(|r| r.get("key"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let relates_to = content.get("m.relates_to")
|
||||
.and_then(|r| r.get("event_id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
let eh = event_handlers.lock().await;
|
||||
eh.dispatch(BotEvent::Reaction {
|
||||
room_id: room_id_str.clone(),
|
||||
event_id: event_id.clone(),
|
||||
sender: sender.to_string(),
|
||||
key: key.to_string(),
|
||||
relates_to: relates_to.to_string(),
|
||||
});
|
||||
} else if event_type == "m.room.redaction" {
|
||||
let redacts = raw_json.get("redacts").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let eh = event_handlers.lock().await;
|
||||
eh.dispatch(BotEvent::Redaction {
|
||||
room_id: room_id_str.clone(),
|
||||
event_id: event_id.clone(),
|
||||
sender: sender.to_string(),
|
||||
redacts: redacts.to_string(),
|
||||
});
|
||||
} else if event_type == "m.room.member" {
|
||||
let membership = content.get("membership").and_then(|v| v.as_str()).unwrap_or("");
|
||||
|
||||
let eh = event_handlers.lock().await;
|
||||
if membership == "join" {
|
||||
eh.dispatch(BotEvent::MemberJoined {
|
||||
room_id: room_id_str.clone(),
|
||||
user_id: sender.to_string(),
|
||||
});
|
||||
} else if membership == "leave" || membership == "ban" {
|
||||
eh.dispatch(BotEvent::MemberLeft {
|
||||
room_id: room_id_str.clone(),
|
||||
user_id: sender.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let joined_rooms = client.joined_rooms();
|
||||
let mut room_mgr = rooms.lock().await;
|
||||
room_mgr.rooms.clear();
|
||||
for room in &joined_rooms {
|
||||
let room_id = room.room_id().to_string();
|
||||
let name = room.display_name().await
|
||||
.map(|n| n.to_string())
|
||||
.unwrap_or_else(|_| room_id.clone());
|
||||
let is_encrypted = room.is_encrypted().await.unwrap_or(false);
|
||||
room_mgr.add_room(RoomInfo::new(room_id, name, is_encrypted));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Sync error: {}", e);
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Sync error: {}", e);
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
_ = shutdown_rx.changed() => {
|
||||
tracing::info!("Bot shutting down...");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn stop(&self) -> anyhow::Result<()> {
|
||||
let mut guard = self.client.write().await;
|
||||
*guard = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn send_message(&self, room_id: &str, message: &str) -> anyhow::Result<()> {
|
||||
let guard = self.client.read().await;
|
||||
let client = guard.as_ref().ok_or(anyhow::anyhow!("Not connected"))?;
|
||||
|
||||
let rid = matrix_sdk::ruma::room_id!(room_id)
|
||||
.map_err(|_| anyhow::anyhow!("Invalid room ID"))?;
|
||||
let room = client.get_room(&rid)
|
||||
.ok_or(anyhow::anyhow!("Room not found"))?;
|
||||
|
||||
let content = matrix_sdk::ruma::events::room::message::RoomMessageEventContent::text_plain(message);
|
||||
let txn_id = matrix_sdk::ruma::TransactionId::new();
|
||||
room.send(content, Some(&txn_id)).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn on_command(&self, name: &str, handler: Box<dyn Fn(&str, &str) + Send + Sync>) {
|
||||
let mut commands = self.commands.write().await;
|
||||
commands.register(name, handler);
|
||||
pub fn stop(&mut self) {
|
||||
if let Some(tx) = self.shutdown_tx.take() {
|
||||
let _ = tx.send(true);
|
||||
}
|
||||
self.client = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn on_event(&self, handler: Box<dyn Fn(&str) + Send + Sync>) {
|
||||
let mut event_handler = self.event_handler.write().await;
|
||||
event_handler.add_handler(handler);
|
||||
impl Drop for BotClient {
|
||||
fn drop(&mut self) {
|
||||
self.stop();
|
||||
}
|
||||
|
||||
pub async fn get_rooms(&self) -> Vec<crate::room::RoomInfo> {
|
||||
let room_mgr = self.room_manager.read().await;
|
||||
room_mgr.list_rooms().into_iter().cloned().collect()
|
||||
}
|
||||
|
||||
pub async fn handle_message(&self, room_id: &str, sender: &str, body: &str) {
|
||||
let commands = self.commands.read().await;
|
||||
commands.parse_and_execute(body, sender);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,31 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
type CommandHandler = Box<dyn Fn(&str, &str) + Send + Sync>;
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CommandContext {
|
||||
pub room_id: String,
|
||||
pub sender: String,
|
||||
pub args: String,
|
||||
pub command: String,
|
||||
}
|
||||
|
||||
pub type CommandHandler = Arc<dyn Fn(CommandContext) + Send + Sync>;
|
||||
|
||||
pub struct CommandRegistry {
|
||||
commands: HashMap<String, CommandHandler>,
|
||||
prefix: String,
|
||||
}
|
||||
|
||||
impl Default for CommandRegistry {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
commands: HashMap::new(),
|
||||
prefix: "!".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CommandRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
@@ -15,9 +34,11 @@ impl CommandRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_prefix(mut self, prefix: &str) -> Self {
|
||||
self.prefix = prefix.to_string();
|
||||
self
|
||||
pub fn with_prefix(prefix: &str) -> Self {
|
||||
Self {
|
||||
commands: HashMap::new(),
|
||||
prefix: prefix.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_prefix(&mut self, prefix: &str) {
|
||||
@@ -32,18 +53,24 @@ impl CommandRegistry {
|
||||
self.commands.remove(name);
|
||||
}
|
||||
|
||||
pub fn parse_and_execute(&self, message: &str, sender: &str) {
|
||||
pub fn parse_and_execute(&self, message: &str, sender: &str, room_id: &str) {
|
||||
if !message.starts_with(&self.prefix) {
|
||||
return;
|
||||
}
|
||||
|
||||
let content = &message[self.prefix.len()..];
|
||||
let parts: Vec<&str> = content.splitn(2, ' ').collect();
|
||||
let parts: Vec<&str> = content.splitn(2, char::is_whitespace).collect();
|
||||
let command = parts[0];
|
||||
let args = parts.get(1).unwrap_or(&"");
|
||||
let args = parts.get(1).unwrap_or(&"").to_string();
|
||||
|
||||
if let Some(handler) = self.commands.get(command) {
|
||||
handler(args, sender);
|
||||
let ctx = CommandContext {
|
||||
room_id: room_id.to_string(),
|
||||
sender: sender.to_string(),
|
||||
args,
|
||||
command: command.to_string(),
|
||||
};
|
||||
handler(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,4 +81,142 @@ impl CommandRegistry {
|
||||
pub fn has_command(&self, name: &str) -> bool {
|
||||
self.commands.contains_key(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type SharedCommandRegistry = Arc<Mutex<CommandRegistry>>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
#[test]
|
||||
fn new_creates_empty_registry() {
|
||||
let reg = CommandRegistry::new();
|
||||
assert!(reg.list_commands().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_creates_empty_registry() {
|
||||
let reg = CommandRegistry::default();
|
||||
assert!(reg.list_commands().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_prefix_sets_custom_prefix() {
|
||||
let mut reg = CommandRegistry::with_prefix("~");
|
||||
let counter = Arc::new(AtomicUsize::new(0));
|
||||
let counter_clone = counter.clone();
|
||||
reg.register(
|
||||
"test",
|
||||
Arc::new(move |_| {
|
||||
counter_clone.fetch_add(1, Ordering::SeqCst);
|
||||
}),
|
||||
);
|
||||
reg.parse_and_execute("~test", "user", "!room");
|
||||
assert_eq!(counter.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_and_execute_command() {
|
||||
let mut reg = CommandRegistry::new();
|
||||
let counter = Arc::new(AtomicUsize::new(0));
|
||||
let counter_clone = counter.clone();
|
||||
reg.register(
|
||||
"hello",
|
||||
Arc::new(move |_| {
|
||||
counter_clone.fetch_add(1, Ordering::SeqCst);
|
||||
}),
|
||||
);
|
||||
reg.parse_and_execute("!hello", "user", "room");
|
||||
assert_eq!(counter.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_command_with_args() {
|
||||
let mut reg = CommandRegistry::new();
|
||||
let captured = Arc::new(Mutex::new(String::new()));
|
||||
let captured_clone = captured.clone();
|
||||
reg.register(
|
||||
"echo",
|
||||
Arc::new(move |ctx| {
|
||||
let _ = captured_clone.try_lock().map(|mut g| *g = ctx.args);
|
||||
}),
|
||||
);
|
||||
reg.parse_and_execute("!echo hello world", "user", "room");
|
||||
assert_eq!(captured_args(&captured), "hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignore_message_without_prefix() {
|
||||
let reg = CommandRegistry::new();
|
||||
let counter = Arc::new(AtomicUsize::new(0));
|
||||
let mut reg = reg;
|
||||
let counter_clone = counter.clone();
|
||||
reg.register(
|
||||
"hello",
|
||||
Arc::new(move |_| {
|
||||
counter_clone.fetch_add(1, Ordering::SeqCst);
|
||||
}),
|
||||
);
|
||||
reg.parse_and_execute("hello", "user", "room");
|
||||
assert_eq!(counter.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignore_unknown_command() {
|
||||
let reg = CommandRegistry::new();
|
||||
let counter = Arc::new(AtomicUsize::new(0));
|
||||
let mut reg = reg;
|
||||
let counter_clone = counter.clone();
|
||||
reg.register(
|
||||
"hello",
|
||||
Arc::new(move |_| {
|
||||
counter_clone.fetch_add(1, Ordering::SeqCst);
|
||||
}),
|
||||
);
|
||||
reg.parse_and_execute("!unknown", "user", "room");
|
||||
assert_eq!(counter.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unregister_removes_command() {
|
||||
let mut reg = CommandRegistry::new();
|
||||
reg.register("hello", Arc::new(|_| {}));
|
||||
assert!(reg.has_command("hello"));
|
||||
reg.unregister("hello");
|
||||
assert!(!reg.has_command("hello"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_prefix_changes_prefix() {
|
||||
let mut reg = CommandRegistry::new();
|
||||
reg.set_prefix("~");
|
||||
let counter = Arc::new(AtomicUsize::new(0));
|
||||
let counter_clone = counter.clone();
|
||||
reg.register(
|
||||
"test",
|
||||
Arc::new(move |_| {
|
||||
counter_clone.fetch_add(1, Ordering::SeqCst);
|
||||
}),
|
||||
);
|
||||
reg.parse_and_execute("~test", "user", "room");
|
||||
assert_eq!(counter.load(Ordering::SeqCst), 1);
|
||||
reg.parse_and_execute("!test", "user", "room");
|
||||
assert_eq!(counter.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_commands_returns_all_names() {
|
||||
let mut reg = CommandRegistry::new();
|
||||
reg.register("hello", Arc::new(|_| {}));
|
||||
reg.register("ping", Arc::new(|_| {}));
|
||||
let mut names = reg.list_commands();
|
||||
names.sort();
|
||||
assert_eq!(names, vec!["hello", "ping"]);
|
||||
}
|
||||
|
||||
fn captured_args(captured: &Arc<Mutex<String>>) -> String {
|
||||
captured.try_lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,187 @@
|
||||
type EventCallback = Box<dyn Fn(&str) + Send + Sync>;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum BotEvent {
|
||||
Message {
|
||||
room_id: String,
|
||||
event_id: String,
|
||||
sender: String,
|
||||
body: String,
|
||||
},
|
||||
Image {
|
||||
room_id: String,
|
||||
event_id: String,
|
||||
sender: String,
|
||||
body: String,
|
||||
url: String,
|
||||
},
|
||||
File {
|
||||
room_id: String,
|
||||
event_id: String,
|
||||
sender: String,
|
||||
body: String,
|
||||
url: String,
|
||||
},
|
||||
Reaction {
|
||||
room_id: String,
|
||||
event_id: String,
|
||||
sender: String,
|
||||
key: String,
|
||||
relates_to: String,
|
||||
},
|
||||
Redaction {
|
||||
room_id: String,
|
||||
event_id: String,
|
||||
sender: String,
|
||||
redacts: String,
|
||||
},
|
||||
MemberJoined {
|
||||
room_id: String,
|
||||
user_id: String,
|
||||
},
|
||||
MemberLeft {
|
||||
room_id: String,
|
||||
user_id: String,
|
||||
},
|
||||
RoomJoined {
|
||||
room_id: String,
|
||||
room_name: String,
|
||||
},
|
||||
RoomLeft {
|
||||
room_id: String,
|
||||
},
|
||||
SyncComplete,
|
||||
}
|
||||
|
||||
type EventCallback = Arc<dyn Fn(BotEvent) + Send + Sync>;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct EventHandler {
|
||||
handlers: Vec<EventCallback>,
|
||||
}
|
||||
|
||||
impl EventHandler {
|
||||
pub fn new() -> Self {
|
||||
Self { handlers: Vec::new() }
|
||||
Self {
|
||||
handlers: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_handler(&mut self, handler: EventCallback) {
|
||||
self.handlers.push(handler);
|
||||
}
|
||||
|
||||
pub fn dispatch(&self, event: &str) {
|
||||
pub fn dispatch(&self, event: BotEvent) {
|
||||
for handler in &self.handlers {
|
||||
handler(event);
|
||||
handler(event.clone());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handler_count(&self) -> usize {
|
||||
self.handlers.len()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type SharedEventHandler = Arc<Mutex<EventHandler>>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
#[test]
|
||||
fn new_creates_empty_handler() {
|
||||
let handler = EventHandler::new();
|
||||
assert_eq!(handler.handler_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_creates_empty_handler() {
|
||||
let handler = EventHandler::default();
|
||||
assert_eq!(handler.handler_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_handler_increments_count() {
|
||||
let mut handler = EventHandler::new();
|
||||
handler.add_handler(Arc::new(|_| {}));
|
||||
assert_eq!(handler.handler_count(), 1);
|
||||
handler.add_handler(Arc::new(|_| {}));
|
||||
assert_eq!(handler.handler_count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_calls_all_handlers() {
|
||||
let mut handler = EventHandler::new();
|
||||
let counter = Arc::new(AtomicUsize::new(0));
|
||||
let c1 = counter.clone();
|
||||
handler.add_handler(Arc::new(move |_| {
|
||||
c1.fetch_add(1, Ordering::SeqCst);
|
||||
}));
|
||||
let c2 = counter.clone();
|
||||
handler.add_handler(Arc::new(move |_| {
|
||||
c2.fetch_add(1, Ordering::SeqCst);
|
||||
}));
|
||||
handler.dispatch(BotEvent::SyncComplete);
|
||||
assert_eq!(counter.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_message_event() {
|
||||
let mut handler = EventHandler::new();
|
||||
let captured = Arc::new(Mutex::new(String::new()));
|
||||
let captured_clone = captured.clone();
|
||||
handler.add_handler(Arc::new(move |event| {
|
||||
if let BotEvent::Message { body, .. } = event {
|
||||
let _ = captured_clone.try_lock().map(|mut g| *g = body);
|
||||
}
|
||||
}));
|
||||
handler.dispatch(BotEvent::Message {
|
||||
room_id: "!room:server".to_string(),
|
||||
event_id: "$event".to_string(),
|
||||
sender: "@user:server".to_string(),
|
||||
body: "hello".to_string(),
|
||||
});
|
||||
assert_eq!(captured.try_lock().unwrap().as_str(), "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_reaction_event() {
|
||||
let mut handler = EventHandler::new();
|
||||
let counter = Arc::new(AtomicUsize::new(0));
|
||||
let c = counter.clone();
|
||||
handler.add_handler(Arc::new(move |event| {
|
||||
if let BotEvent::Reaction { key, .. } = event {
|
||||
if key == "👍" {
|
||||
c.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
}));
|
||||
handler.dispatch(BotEvent::Reaction {
|
||||
room_id: "!room:server".to_string(),
|
||||
event_id: "$event".to_string(),
|
||||
sender: "@user:server".to_string(),
|
||||
key: "👍".to_string(),
|
||||
relates_to: "$orig".to_string(),
|
||||
});
|
||||
assert_eq!(counter.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_member_joined_event() {
|
||||
let mut handler = EventHandler::new();
|
||||
let captured = Arc::new(Mutex::new(String::new()));
|
||||
let c = captured.clone();
|
||||
handler.add_handler(Arc::new(move |event| {
|
||||
if let BotEvent::MemberJoined { user_id, .. } = event {
|
||||
let _ = c.try_lock().map(|mut g| *g = user_id);
|
||||
}
|
||||
}));
|
||||
handler.dispatch(BotEvent::MemberJoined {
|
||||
room_id: "!room:server".to_string(),
|
||||
user_id: "@alice:server".to_string(),
|
||||
});
|
||||
assert_eq!(captured.try_lock().unwrap().as_str(), "@alice:server");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,41 @@
|
||||
//! EifelDC Bot SDK — Build Matrix bots for the EifelDC platform.
|
||||
//!
|
||||
//! # Quick Start
|
||||
//! ```no_run
|
||||
//! use std::sync::Arc;
|
||||
//! use eifeldc_bot_sdk::{BotClient, BotEvent, CommandContext};
|
||||
//!
|
||||
//! #[tokio::main]
|
||||
//! async fn main() -> anyhow::Result<()> {
|
||||
//! let mut bot = BotClient::new("https://matrix.example.org")
|
||||
//! .with_auth("botuser", "botpassword");
|
||||
//!
|
||||
//! bot.on_event(|event| {
|
||||
//! match event {
|
||||
//! BotEvent::Message { room_id, sender, body, .. } => {
|
||||
//! println!("{} in {}: {}", sender, room_id, body);
|
||||
//! }
|
||||
//! _ => {}
|
||||
//! }
|
||||
//! });
|
||||
//!
|
||||
//! bot.on_command("hello", Arc::new(|ctx: CommandContext| {
|
||||
//! println!("Hello command from {} in {}!", ctx.sender, ctx.room_id);
|
||||
//! }));
|
||||
//!
|
||||
//! bot.start().await?;
|
||||
//! Ok(())
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
pub mod auth;
|
||||
pub mod client;
|
||||
pub mod commands;
|
||||
pub mod event;
|
||||
pub mod room;
|
||||
pub mod auth;
|
||||
|
||||
pub use auth::BotAuth;
|
||||
pub use client::BotClient;
|
||||
pub use auth::BotAuth;
|
||||
pub use commands::{CommandContext, CommandHandler, CommandRegistry};
|
||||
pub use event::{BotEvent, EventHandler};
|
||||
pub use room::{RoomInfo, RoomManager};
|
||||
|
||||
@@ -1,19 +1,34 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
#[derive(Clone)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RoomInfo {
|
||||
pub room_id: String,
|
||||
pub name: String,
|
||||
pub is_encrypted: bool,
|
||||
}
|
||||
|
||||
impl RoomInfo {
|
||||
pub fn new(room_id: String, name: String, is_encrypted: bool) -> Self {
|
||||
Self {
|
||||
room_id,
|
||||
name,
|
||||
is_encrypted,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct RoomManager {
|
||||
rooms: HashMap<String, RoomInfo>,
|
||||
pub rooms: HashMap<String, RoomInfo>,
|
||||
}
|
||||
|
||||
impl RoomManager {
|
||||
pub fn new() -> Self {
|
||||
Self { rooms: HashMap::new() }
|
||||
Self {
|
||||
rooms: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_room(&mut self, room: RoomInfo) {
|
||||
@@ -35,4 +50,112 @@ impl RoomManager {
|
||||
pub fn room_count(&self) -> usize {
|
||||
self.rooms.len()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type SharedRoomManager = Arc<Mutex<RoomManager>>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn new_creates_empty_manager() {
|
||||
let mgr = RoomManager::new();
|
||||
assert_eq!(mgr.room_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_creates_empty_manager() {
|
||||
let mgr = RoomManager::default();
|
||||
assert_eq!(mgr.room_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_room_inserts_room() {
|
||||
let mut mgr = RoomManager::new();
|
||||
mgr.add_room(RoomInfo::new(
|
||||
"!room1:server".into(),
|
||||
"Room One".into(),
|
||||
false,
|
||||
));
|
||||
assert_eq!(mgr.room_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_multiple_rooms() {
|
||||
let mut mgr = RoomManager::new();
|
||||
mgr.add_room(RoomInfo::new("!r1:server".into(), "A".into(), false));
|
||||
mgr.add_room(RoomInfo::new("!r2:server".into(), "B".into(), true));
|
||||
assert_eq!(mgr.room_count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_room_returns_correct_room() {
|
||||
let mut mgr = RoomManager::new();
|
||||
mgr.add_room(RoomInfo::new(
|
||||
"!room1:server".into(),
|
||||
"Room One".into(),
|
||||
false,
|
||||
));
|
||||
let room = mgr.get_room("!room1:server").unwrap();
|
||||
assert_eq!(room.name, "Room One");
|
||||
assert!(!room.is_encrypted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_room_returns_none_for_missing() {
|
||||
let mgr = RoomManager::new();
|
||||
assert!(mgr.get_room("!nonexistent:server").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_room_deletes_room() {
|
||||
let mut mgr = RoomManager::new();
|
||||
mgr.add_room(RoomInfo::new(
|
||||
"!room1:server".into(),
|
||||
"Room One".into(),
|
||||
false,
|
||||
));
|
||||
assert_eq!(mgr.room_count(), 1);
|
||||
mgr.remove_room("!room1:server");
|
||||
assert_eq!(mgr.room_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_nonexistent_room_is_noop() {
|
||||
let mut mgr = RoomManager::new();
|
||||
mgr.add_room(RoomInfo::new(
|
||||
"!room1:server".into(),
|
||||
"Room One".into(),
|
||||
false,
|
||||
));
|
||||
mgr.remove_room("!nonexistent:server");
|
||||
assert_eq!(mgr.room_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_room_overwrites_existing() {
|
||||
let mut mgr = RoomManager::new();
|
||||
mgr.add_room(RoomInfo::new(
|
||||
"!room1:server".into(),
|
||||
"Old Name".into(),
|
||||
false,
|
||||
));
|
||||
mgr.add_room(RoomInfo::new(
|
||||
"!room1:server".into(),
|
||||
"New Name".into(),
|
||||
true,
|
||||
));
|
||||
assert_eq!(mgr.room_count(), 1);
|
||||
assert_eq!(mgr.get_room("!room1:server").unwrap().name, "New Name");
|
||||
assert!(mgr.get_room("!room1:server").unwrap().is_encrypted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_rooms_returns_all() {
|
||||
let mut mgr = RoomManager::new();
|
||||
mgr.add_room(RoomInfo::new("!r1:server".into(), "A".into(), false));
|
||||
mgr.add_room(RoomInfo::new("!r2:server".into(), "B".into(), true));
|
||||
assert_eq!(mgr.list_rooms().len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user