use std::{ borrow::Cow, path::Path, sync::{Arc, Mutex}, time::Duration, }; use chrono::{DateTime, TimeZone, Utc}; use color_eyre::{ eyre::{eyre, Context}, Help, Result, }; use matrix_sdk::{ config::SyncSettings, event_handler::Ctx, room::{Joined, Room}, ruma::{ events::room::message::{ MessageType, OriginalRoomMessageEvent, OriginalSyncRoomMessageEvent, RoomMessageEventContent, TextMessageEventContent, }, OwnedRoomOrAliasId, RoomAliasId, RoomId, RoomOrAliasId, }, Client, }; use never_say_never::Never; use reqwest::Url; use serde::Deserialize; use tokio::time::sleep; use tracing::{event, instrument, span, Level}; use crate::{its_api::ItsApi, session_path, sled_store_path, SessionData}; const STATE_POLLING_FREQUENCY: Duration = Duration::from_secs(10); #[derive(Clone, Debug, Deserialize)] pub struct AppConfig { command_prefix: String, } #[derive(Clone, Debug, Deserialize)] pub struct SpaceConfig { base_url: Url, spaceping_token: String, announce_rooms: Vec, } #[derive(Clone, Debug, Deserialize)] pub struct Config { app: AppConfig, space: SpaceConfig, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum OpenState { Open { since: DateTime }, Closed, } async fn get_open_state(api: &ItsApi) -> Result { let state = api .status() .await? .state .ok_or(eyre!("missing `state` key"))?; let is_open = state.open.ok_or(eyre!("missing `open` key"))?; let state = if is_open { let last_change = state .lastchange .ok_or(eyre!("missing `lastchange` field"))? .try_into() .wrap_err("lastchange timestamp out of i64 range")?; OpenState::Open { since: Utc .timestamp_opt(last_change, 0) .single() .expect("nanos count of 0 produced invalid timestamp"), } } else { OpenState::Closed }; Ok(state) } pub struct Bot { config: Config, announce_rooms: Vec, client: Client, api: ItsApi, open_state: Mutex, } impl Bot { /// Creates a new bot instance and logs in to the homeserver. /// /// # Errors /// /// Returns an error if the login failed. #[instrument] pub async fn new(config: Config) -> Result { let mut builder = Client::builder().sled_store(sled_store_path()?, None)?; let session_path = session_path()?; let session_data = Self::load_session(&session_path) .await .wrap_err("Failed to load session data") .note("Has the bot been initialized?") .suggestion("Run `its-matrix-bot setup`")?; if let Some(url) = session_data.homeserver_url { builder = builder.homeserver_url(url); } else { builder = builder.server_name(session_data.session.user_id.server_name()); } let client = builder.build().await?; client.restore_login(session_data.session).await?; let api = ItsApi::new( config.space.base_url.clone(), config.space.spaceping_token.clone(), ); let open_state = get_open_state(&api).await?; Ok(Self { config, announce_rooms: vec![], client, api, open_state: Mutex::new(open_state), }) } #[instrument] async fn load_session(path: &Path) -> Result { let data = toml::from_str(&tokio::fs::read_to_string(path).await?)?; Ok(data) } /// Updates the internally stored open state and announces any changes to /// the state in the announcement channels. #[instrument(skip(self))] async fn update_open_state(&self) -> Result { let new_state = get_open_state(&self.api).await?; if new_state != *self.open_state.lock().unwrap() { let message = if let OpenState::Open { .. } = new_state { "opening IT-Syndikat - Ohai!" } else { "closing IT-Syndikat - nap time!" }; for room in &self.announce_rooms { room.send(RoomMessageEventContent::notice_plain(message), None) .await?; } } *self.open_state.lock().unwrap() = new_state; Ok(new_state) } /// Resolves a [`RoomOrAliasId`] to a room ID if it is an alias, or returns /// the room ID unchanged. #[instrument(skip(self))] async fn room_id<'id>(&self, room: &'id RoomOrAliasId) -> Result> { let id = if let Ok(room_id) = <&RoomId>::try_from(room) { Cow::Borrowed(room_id) } else { event!(Level::DEBUG, "resolving room alias"); let alias = <&RoomAliasId>::try_from(room) .expect("room identifier is neither a room ID nor a room alias"); Cow::Owned(self.client.resolve_room_alias(alias).await?.room_id) }; Ok(id) } #[instrument(skip(self))] async fn join_announce_rooms(&mut self) -> Result<()> { for room in &self.config.space.announce_rooms { let joined = if let Some(joined) = self.client.get_joined_room(&self.room_id(room).await?) { joined } else { event!(Level::INFO, %room, "joining announcement room"); let id = self .client .join_room_by_id_or_alias(room, &[]) .await? .room_id; self.client .get_joined_room(&id) .ok_or(eyre!("room is not joined even after joining"))? }; self.announce_rooms.push(joined); } Ok(()) } #[instrument(skip(self))] async fn handle_message( self: &Arc, ev: &OriginalRoomMessageEvent, room: &Joined, ) -> Result<()> { let MessageType::Text(TextMessageEventContent { body, .. }) = &ev.content.msgtype else { return Ok(()); }; let Some(command) = body.strip_prefix(&self.config.app.command_prefix) else { return Ok(()); }; let reply = |msg: &str| { room.send( RoomMessageEventContent::notice_plain(msg).make_reply_to(ev), None, ) }; match command { "isitopen" => { match self.update_open_state().await? { OpenState::Open { since } => { reply(&format!("positive! space has been open since {since}")).await? } OpenState::Closed => reply("negative!").await?, }; } "spaceping" => { self.api.ping().await?; reply("Hello Space!").await?; } _ => { reply("Unknown command").await?; } } Ok(()) } /// Runs the bot. This function does not return except in case of error. #[instrument(skip(self))] pub async fn run(mut self) -> Result { self.join_announce_rooms() .await .wrap_err("failed to join announcement rooms")?; let this = Arc::new(self); let this_poll = Arc::clone(&this); tokio::spawn(async move { let this = this_poll; let span = span!(Level::INFO, "state polling task"); let _enter = span.enter(); loop { if let Err(error) = this.update_open_state().await { event!(Level::WARN, %error, "failed to update state"); }; sleep(STATE_POLLING_FREQUENCY).await; } }); this.client.add_event_handler_context(Arc::clone(&this)); this.client.add_event_handler( |ev: OriginalSyncRoomMessageEvent, room: Room, this: Ctx>| async move { let Room::Joined(room) = room else { return; }; let ev = ev.into_full_event(room.room_id().to_owned()); if let Err(error) = (*this).handle_message(&ev, &room).await { event!(Level::WARN, event = ?ev, ?error, "handling message failed"); } }, ); this.client.sync(SyncSettings::default()).await?; unreachable!("sync() returned unexpectedly") } }