304 lines
9.0 KiB
Rust
304 lines
9.0 KiB
Rust
use std::{
|
|
borrow::Cow,
|
|
path::Path,
|
|
sync::{Arc, Mutex},
|
|
time::Duration,
|
|
};
|
|
|
|
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 time::OffsetDateTime;
|
|
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<OwnedRoomOrAliasId>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize)]
|
|
pub struct Config {
|
|
app: AppConfig,
|
|
space: SpaceConfig,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum OpenState {
|
|
Open { since: OffsetDateTime },
|
|
Closed,
|
|
}
|
|
|
|
#[instrument(skip(api))]
|
|
async fn get_open_state(api: &ItsApi) -> Result<OpenState> {
|
|
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")?;
|
|
let last_change = OffsetDateTime::from_unix_timestamp(last_change)
|
|
.wrap_err("lastchange timestamp out of range")?;
|
|
OpenState::Open { since: last_change }
|
|
} else {
|
|
OpenState::Closed
|
|
};
|
|
|
|
Ok(state)
|
|
}
|
|
|
|
pub struct Bot {
|
|
config: Config,
|
|
announce_rooms: Vec<Joined>,
|
|
|
|
client: Client,
|
|
api: ItsApi,
|
|
open_state: Mutex<OpenState>,
|
|
}
|
|
|
|
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<Self> {
|
|
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<SessionData> {
|
|
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<OpenState> {
|
|
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<Cow<'id, RoomId>> {
|
|
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<Self>,
|
|
ev: &OriginalRoomMessageEvent,
|
|
room: &Joined,
|
|
) -> Result<()> {
|
|
event!(Level::TRACE, ?ev, "handling message");
|
|
if Some(&*ev.sender) == self.client.user_id() {
|
|
event!(Level::TRACE, "message from ourselves, ignoring");
|
|
return Ok(());
|
|
}
|
|
|
|
let MessageType::Text(TextMessageEventContent { body, .. }) = &ev.content.msgtype else {
|
|
event!(Level::TRACE, "non-plaintext message, ignoring");
|
|
return Ok(());
|
|
};
|
|
|
|
let Some(command) = body.strip_prefix(&self.config.app.command_prefix) else {
|
|
event!(Level::TRACE, "non-command message, ignoring");
|
|
return Ok(());
|
|
};
|
|
|
|
let reply = |msg: &str| {
|
|
// workaround for broken IRC bridge
|
|
// https://github.com/matrix-org/matrix-appservice-irc/issues/683#issuecomment-1312688727
|
|
let msg = format!("\n{msg}");
|
|
|
|
room.send(
|
|
RoomMessageEventContent::text_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<Never> {
|
|
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<Arc<Self>>| 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")
|
|
}
|
|
}
|