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

- 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:
root
2026-04-29 13:08:01 +02:00
parent 0978d0c2e9
commit cacd2b04a7
80 changed files with 18307 additions and 1724 deletions

View File

@@ -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"

View File

@@ -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());
}
}

View File

@@ -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);
}
}
}

View File

@@ -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()
}
}

View File

@@ -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");
}
}

View File

@@ -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};

View File

@@ -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);
}
}