lib.rs (8589B)
1 //! `ruma_api_bot` is a crate that aims to simplify creating bots for [matrix][] servers. 2 //! 3 //! It currently requires nightly, but should work on stable once the `async_await` feature is 4 //! stabilized. 5 //! 6 //! [matrix]: https://matrix.org/ 7 8 #![warn(missing_debug_implementations, missing_docs)] 9 10 use std::{collections::HashMap, pin::Pin, sync::Arc}; 11 12 use anymap::any::CloneAny; 13 use failure::{err_msg, Fallible}; 14 use futures::{Future, TryStreamExt}; 15 use ruma_client::{api, HttpsClient as MatrixClient}; 16 use url::Url; 17 18 // TODO: Get rid of compiler warnings, either by replacing CloneAny, or by fixing the issue upstream 19 type AnyMap = anymap::Map<dyn CloneAny + Send + Sync>; 20 type HandlerFnMap = HashMap<&'static str, Box<dyn CommandHandler>>; 21 22 pub use ruma_bot_macros::command_handler; 23 24 mod debug; 25 mod util; 26 mod wrap; 27 28 #[doc(hidden)] 29 pub use util::{GetParam, HandlerParamMatcher}; 30 pub use wrap::{MsgContent, State}; 31 32 fn env_var(name: &'static str) -> Option<String> { 33 std::env::var(name) 34 .map_err(|e| { 35 if let std::env::VarError::NotUnicode(_) = e { 36 panic!("ruma_bot: {} contains non-unicode bytes", name); 37 } 38 }) 39 .ok() 40 } 41 42 /// A function (usually `async`) annotated with `#[ruma_bot::command_handler] 43 pub trait CommandHandler: Send + Sync { 44 /// The command(s) this function handles 45 fn commands() -> &'static [&'static str] 46 where 47 Self: Sized; 48 49 /// Used to call the command handler with the necessary application state and command context 50 fn handle(&self, _: &Bot, msg_content: &str) -> Pin<Box<dyn Future<Output = ()> + Send>>; 51 } 52 53 #[derive(Clone, Debug)] 54 struct ConnectionDetails { 55 //homeserver_url: Url, 56 username: String, 57 password: String, 58 } 59 60 /// A builder for the `Bot` type 61 pub struct BotBuilder { 62 handlers: HandlerFnMap, 63 state: AnyMap, 64 homeserver_url: Option<Url>, 65 username: Option<String>, 66 password: Option<String>, 67 } 68 69 impl BotBuilder { 70 /// Crate a new `BotBuilder` 71 #[allow(clippy::new_without_default)] 72 pub fn new() -> Self { 73 Self { 74 handlers: HashMap::new(), 75 state: AnyMap::new(), 76 homeserver_url: None, 77 username: None, 78 password: None, 79 } 80 } 81 82 /// Register a command handler 83 pub fn register<T>(mut self, handler: T) -> Self 84 where 85 T: CommandHandler + Copy + 'static, 86 { 87 for command in T::commands() { 88 let old_value = self.handlers.insert(command, Box::new(handler)); 89 assert!( 90 old_value.is_none(), 91 "ruma_bot: Tried to register a command handler for '{}' when one was already registered", 92 command, 93 ); 94 } 95 96 self 97 } 98 99 /// Register some state to be used across calls to event handlers 100 pub fn state<T>(mut self, initial_value: T) -> Self 101 where 102 T: CloneAny + Send + Sync, 103 { 104 self.state.insert(initial_value); 105 self 106 } 107 108 /// Set the homeserver url (must be an `https` url currently) 109 /// 110 /// If the homeserver url is not set with this method, `build` will fall back to the environment 111 /// variable `RUMA_BOT_HOMESERVER_URL`. 112 pub fn homeserver_url(mut self, homeserver_url: Url) -> Self { 113 self.homeserver_url = Some(homeserver_url); 114 self 115 } 116 117 /// Set the username 118 /// 119 /// If the username is not set with this method, `build` will fall back to the environment 120 /// variable `RUMA_BOT_USERNAME`. 121 pub fn username(mut self, username: String) -> Self { 122 self.username = Some(username); 123 self 124 } 125 126 /// Set the password 127 /// 128 /// If the password is not set with this method, ,`build` will fall back to the environment 129 /// variable `RUMA_BOT_PASSWORD`. 130 pub fn password(mut self, password: String) -> Self { 131 self.password = Some(password); 132 self 133 } 134 135 /// Build the bot 136 pub fn build(self) -> Fallible<Bot> { 137 Ok(Bot { 138 client: MatrixClient::https( 139 self.homeserver_url 140 .or_else(|| { 141 env_var("RUMA_BOT_HOMESERVER_URL") 142 .map(|s| s.parse().expect("valid url for RUMA_BOT_HOMESERVER_URL")) 143 }) 144 .ok_or_else(|| err_msg("ruma_bot: homeserver_url not configured"))?, 145 None, 146 )?, 147 handlers: Arc::new(self.handlers), 148 state: self.state, 149 connection_details: Some(ConnectionDetails { 150 username: self 151 .username 152 .or_else(|| env_var("RUMA_BOT_USERNAME")) 153 .ok_or_else(|| err_msg("ruma_bot: username not configured"))?, 154 password: self 155 .password 156 .or_else(|| env_var("RUMA_BOT_PASSWORD")) 157 .ok_or_else(|| err_msg("ruma_bot: password not configured"))?, 158 }), 159 }) 160 } 161 } 162 163 /// A bot, usually containing one or more command handlers and optionally a reaction handler, 164 /// which can also manage arbitrary application data (one instance per type). 165 #[derive(Clone)] 166 pub struct Bot { 167 client: MatrixClient, 168 handlers: Arc<HandlerFnMap>, 169 state: AnyMap, 170 connection_details: Option<ConnectionDetails>, 171 } 172 173 impl Bot { 174 /// Start the Bot's main loop 175 pub async fn run(mut self) -> Result<(), ruma_client::Error> { 176 use api::r0::{ 177 filter::{Filter, FilterDefinition, RoomEventFilter, RoomFilter}, 178 sync::sync_events::Filter as SyncFilter, 179 }; 180 use ruma_client::events::{ 181 collections::all::RoomEvent, 182 room::message::{MessageEvent, MessageEventContent, TextMessageEventContent}, 183 }; 184 185 // A Bot is always instantiated with connection_details = Some(...) and this method (that 186 // can only be called once) is the only one method that uses connection_details. Thus, this 187 // .unwrap() will never fail. 188 let cd = self.connection_details.take().unwrap(); 189 190 self.client.log_in(cd.username, cd.password, None).await?; 191 192 let mut sync0 = Box::pin(self.client.sync( 193 Some(SyncFilter::FilterDefinition(FilterDefinition::ignore_all())), 194 None, 195 false, 196 )); 197 198 let sync_start = sync0.try_next().await?.expect("sync response").next_batch; 199 let mut sync_stream = Box::pin(self.client.sync( 200 Some(SyncFilter::FilterDefinition(FilterDefinition { 201 account_data: Some(Filter::ignore_all()), 202 room: Some(RoomFilter { 203 account_data: Some(RoomEventFilter::ignore_all()), 204 state: Some(RoomEventFilter::ignore_all()), 205 ..Default::default() 206 }), 207 presence: Some(Filter::ignore_all()), 208 ..Default::default() 209 })), 210 Some(sync_start), 211 true, 212 )); 213 214 while let Some(res) = sync_stream.try_next().await? { 215 for (_room_id, room) in res.rooms.join { 216 for event in room 217 .timeline 218 .events 219 .into_iter() 220 // ignore invalid events 221 .flat_map(|ev_res| ev_res.into_result()) 222 { 223 // Filter out the text messages 224 if let RoomEvent::RoomMessage(MessageEvent { 225 content: MessageEventContent::Text(TextMessageEventContent { body, .. }), 226 sender: _sender, 227 .. 228 }) = event 229 { 230 if body.starts_with('!') { 231 if let Some(idx) = body.find(char::is_whitespace) { 232 let command = &body[1..idx]; 233 234 if let Some(handler) = self.handlers.get(command) { 235 tokio::spawn(handler.handle(&self, &body[idx + 1..])); 236 } 237 } 238 } 239 } 240 } 241 } 242 243 for (_room_id, _invited_room) in res.rooms.invite { 244 // TODO 245 } 246 } 247 248 Ok(()) 249 } 250 } 251 252 #[allow(dead_code)] 253 mod compile_tests { 254 use super::Bot; 255 256 fn send_sync() { 257 fn assert_send<T: Send>() {} 258 fn assert_sync<T: Send>() {} 259 assert_send::<Bot>(); 260 assert_sync::<Bot>(); 261 } 262 }