ruma-bot

git clone git://archive.git.mtrnord.blog/MTRNord/ruma-bot.git
Log | Files | Refs

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 }