Rewrite it in Rust!
This commit is contained in:
parent
7a608ae9d0
commit
4d01d3f8f0
10 changed files with 3750 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/target
|
3134
Cargo.lock
generated
Normal file
3134
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
29
Cargo.toml
Normal file
29
Cargo.toml
Normal file
|
@ -0,0 +1,29 @@
|
|||
[package]
|
||||
name = "its-matrix-bot"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.65"
|
||||
|
||||
description = "IT-Syndikat matrix bot"
|
||||
repository = "https://git.it-syndikat.org/IT-Syndikat/its-matrix-bot"
|
||||
license = "AGPL-3.0-or-later"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
chrono = "0.4.23"
|
||||
clap = { version = "4.0.22", features = ["color", "derive"] }
|
||||
color-eyre = "0.6.2"
|
||||
matrix-sdk = { version = "0.6.2", features = ["e2e-encryption"] }
|
||||
never-say-never = "6.6.666"
|
||||
reqwest = { version = "0.11.12", features = ["json"] }
|
||||
serde = { version = "1.0.147", features = ["derive"] }
|
||||
spaceapi = "0.8.1"
|
||||
thiserror = "1.0.37"
|
||||
tokio = { version = "1.21.2", features = ["full"] }
|
||||
toml = "0.5.9"
|
||||
tracing = "0.1.37"
|
||||
tracing-error = "0.2.0"
|
||||
tracing-subscriber = { version = "0.3.16", features = ["env-filter"] }
|
||||
url = { version = "2.3.1", features = ["serde"] }
|
||||
xdg = "2.4.1"
|
8
README.md
Normal file
8
README.md
Normal file
|
@ -0,0 +1,8 @@
|
|||
# IT-Syndikat matrix bot
|
||||
|
||||
Running in [#lobby:it-syndik.at](matrix:r/lobby:it-syndik.at).
|
||||
|
||||
## Available commands
|
||||
|
||||
- `!isitopen`: check if the hackerspace is currently open
|
||||
- `!spaceping`: play a chime in the hackerpace to attempt to get someone to read your matrix messages
|
1
clippy.toml
Normal file
1
clippy.toml
Normal file
|
@ -0,0 +1 @@
|
|||
doc-valid-idents = ["SpaceAPI"]
|
15
rustfmt.toml
Normal file
15
rustfmt.toml
Normal file
|
@ -0,0 +1,15 @@
|
|||
error_on_line_overflow = true
|
||||
|
||||
format_code_in_doc_comments = true
|
||||
format_macro_bodies = true
|
||||
format_macro_matchers = true
|
||||
|
||||
group_imports = "StdExternalCrate"
|
||||
imports_granularity = "Crate"
|
||||
reorder_imports = true
|
||||
|
||||
hex_literal_case = "Upper"
|
||||
newline_style = "Unix"
|
||||
reorder_impl_items = true
|
||||
use_field_init_shorthand = true
|
||||
wrap_comments = true
|
293
src/bot.rs
Normal file
293
src/bot.rs
Normal file
|
@ -0,0 +1,293 @@
|
|||
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<OwnedRoomOrAliasId>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct Config {
|
||||
app: AppConfig,
|
||||
space: SpaceConfig,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum OpenState {
|
||||
Open { since: DateTime<Utc> },
|
||||
Closed,
|
||||
}
|
||||
|
||||
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")?;
|
||||
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<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<()> {
|
||||
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<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")
|
||||
}
|
||||
}
|
77
src/its_api.rs
Normal file
77
src/its_api.rs
Normal file
|
@ -0,0 +1,77 @@
|
|||
use reqwest::{Client, Url};
|
||||
use thiserror::Error;
|
||||
use tracing::{event, instrument, Level};
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum Error {
|
||||
#[error("invalid URL")]
|
||||
InvalidUrl(#[from] url::ParseError),
|
||||
#[error("invalid response")]
|
||||
InvalidResponse(reqwest::Error),
|
||||
#[error("network request failed")]
|
||||
Network(reqwest::Error),
|
||||
}
|
||||
|
||||
/// Handler for the IT-Syndikat API.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ItsApi {
|
||||
base_url: Url,
|
||||
spaceping_token: String,
|
||||
client: Client,
|
||||
}
|
||||
|
||||
impl ItsApi {
|
||||
/// Constructs a new IT-Syndikat API handler.
|
||||
#[instrument]
|
||||
pub fn new(base_url: Url, spaceping_token: String) -> Self {
|
||||
Self {
|
||||
base_url,
|
||||
spaceping_token,
|
||||
client: Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Request the SpaceAPI status.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// This function returns an error if the network request fails, or if the
|
||||
/// returned data can not be parsed as a valid SpaceAPI response.
|
||||
#[instrument]
|
||||
pub async fn status(&self) -> Result<spaceapi::Status, Error> {
|
||||
event!(Level::DEBUG, "requesting spaceapi status");
|
||||
let status = self
|
||||
.client
|
||||
.get(self.base_url.join("status.php")?)
|
||||
.send()
|
||||
.await
|
||||
.map_err(Error::Network)?
|
||||
.json()
|
||||
.await
|
||||
.map_err(Error::InvalidResponse)?;
|
||||
|
||||
event!(Level::DEBUG, ?status);
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
/// Makes the box in the space beep.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// This function returns an error if the network request fails.
|
||||
#[instrument]
|
||||
pub async fn ping(&self) -> Result<(), Error> {
|
||||
event!(Level::INFO, "sending spaceping");
|
||||
|
||||
self.client
|
||||
.post(self.base_url.join("ping.php")?)
|
||||
.query(&[("apikey", &self.spaceping_token)])
|
||||
.send()
|
||||
.await
|
||||
.map_err(Error::Network)?
|
||||
.error_for_status()
|
||||
.map_err(Error::InvalidResponse)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
130
src/main.rs
Normal file
130
src/main.rs
Normal file
|
@ -0,0 +1,130 @@
|
|||
#![forbid(unsafe_code)]
|
||||
#![deny(unused_must_use)]
|
||||
#![warn(clippy::pedantic, clippy::as_conversions)]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use bot::{Bot, Config};
|
||||
use clap::Parser as _;
|
||||
use color_eyre::{eyre::Context, Result};
|
||||
use matrix_sdk::Session;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::instrument;
|
||||
use xdg::BaseDirectories;
|
||||
|
||||
mod bot;
|
||||
mod its_api;
|
||||
mod setup;
|
||||
|
||||
/// The session data required by the bot to log in. Stored as TOML at the path
|
||||
/// given by [`session_path()`].
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct SessionData {
|
||||
pub homeserver_url: Option<String>,
|
||||
pub session: Session,
|
||||
}
|
||||
|
||||
#[derive(clap::Parser)]
|
||||
struct Cli {
|
||||
#[arg(long, short = 'c')]
|
||||
config: Option<PathBuf>,
|
||||
#[command(subcommand)]
|
||||
sub: Option<Subcommand>,
|
||||
}
|
||||
|
||||
#[derive(clap::Subcommand, Debug)]
|
||||
enum Subcommand {
|
||||
Setup(setup::Setup),
|
||||
}
|
||||
|
||||
/// Returns the default path to the bot's configuration file.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the directory containing the configuration file
|
||||
/// could not be created.
|
||||
fn default_config_path() -> Result<PathBuf> {
|
||||
let base_dirs = BaseDirectories::with_prefix("its-matrix-bot")?;
|
||||
let path = base_dirs.place_config_file("config.toml")?;
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Returns the path to the bot's session file.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the directory containing the session file
|
||||
/// could not be created.
|
||||
fn session_path() -> Result<PathBuf> {
|
||||
let base_dirs = BaseDirectories::with_prefix("its-matrix-bot")?;
|
||||
let path = base_dirs.place_data_file("state.toml")?;
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Returns the path to the bot's internal database.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if no base directory could be determined (e.g. because
|
||||
/// no home directory is set).
|
||||
fn sled_store_path() -> Result<PathBuf> {
|
||||
let base_dirs = BaseDirectories::with_prefix("gavel")?;
|
||||
Ok(base_dirs.get_data_home())
|
||||
}
|
||||
|
||||
#[instrument]
|
||||
async fn load_config(path: &Path) -> Result<Config> {
|
||||
let config = toml::from_str(&tokio::fs::read_to_string(path).await?)?;
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn install_tracing() {
|
||||
use tracing_subscriber::{
|
||||
prelude::__tracing_subscriber_SubscriberExt, util::SubscriberInitExt, EnvFilter,
|
||||
};
|
||||
|
||||
let fmt_layer = tracing_subscriber::fmt::layer().with_target(false);
|
||||
let filter_layer = tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.or_else(|_| EnvFilter::try_from("info"))
|
||||
.unwrap();
|
||||
let error_layer = tracing_error::ErrorLayer::default();
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(fmt_layer)
|
||||
.with(filter_layer)
|
||||
.with(error_layer)
|
||||
.init();
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
install_tracing();
|
||||
color_eyre::install()?;
|
||||
|
||||
let args = Cli::parse();
|
||||
|
||||
#[allow(clippy::single_match_else)]
|
||||
match args.sub {
|
||||
Some(Subcommand::Setup(setup)) => {
|
||||
setup::setup(setup).await?;
|
||||
Ok(())
|
||||
}
|
||||
None => {
|
||||
let config_path = if let Some(config) = args.config {
|
||||
config
|
||||
} else {
|
||||
default_config_path()?
|
||||
};
|
||||
|
||||
let config = load_config(&config_path)
|
||||
.await
|
||||
.wrap_err("Failed to load bot configuration")?;
|
||||
|
||||
let bot = Bot::new(config).await?;
|
||||
bot.run().await?
|
||||
}
|
||||
}
|
||||
}
|
62
src/setup.rs
Normal file
62
src/setup.rs
Normal file
|
@ -0,0 +1,62 @@
|
|||
use std::io::Write;
|
||||
|
||||
use clap::Args;
|
||||
use color_eyre::{eyre::eyre, Result};
|
||||
use matrix_sdk::{config::SyncSettings, ruma::UserId, Client, Session};
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
|
||||
use crate::SessionData;
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
pub(crate) struct Setup {
|
||||
username: Box<UserId>,
|
||||
#[arg(long, short = 's')]
|
||||
homeserver_url: Option<String>,
|
||||
}
|
||||
|
||||
async fn login_password_interactive(setup: &Setup, client: &Client) -> Result<Session> {
|
||||
print!("Enter password for {}: ", setup.username);
|
||||
std::io::stdout().flush()?;
|
||||
|
||||
let stdin = tokio::io::BufReader::new(tokio::io::stdin());
|
||||
|
||||
let password = stdin
|
||||
.lines()
|
||||
.next_line()
|
||||
.await?
|
||||
.ok_or(eyre!("Password is required on first start"))?;
|
||||
|
||||
let session = client
|
||||
.login_username(&setup.username, &password)
|
||||
.initial_device_display_name("gavel bot")
|
||||
.send()
|
||||
.await?
|
||||
.into();
|
||||
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
pub(crate) async fn setup(setup: Setup) -> Result<()> {
|
||||
let client = {
|
||||
let mut builder = Client::builder().sled_store(crate::sled_store_path()?, None)?;
|
||||
if let Some(ref url) = setup.homeserver_url {
|
||||
builder = builder.homeserver_url(url);
|
||||
} else {
|
||||
builder = builder.server_name(setup.username.server_name());
|
||||
}
|
||||
builder.build().await?
|
||||
};
|
||||
|
||||
let session = login_password_interactive(&setup, &client).await?;
|
||||
|
||||
let data = SessionData {
|
||||
session,
|
||||
homeserver_url: setup.homeserver_url,
|
||||
};
|
||||
|
||||
tokio::fs::write(crate::session_path()?, toml::to_vec(&data)?).await?;
|
||||
|
||||
client.sync_once(SyncSettings::default()).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
Loading…
Reference in a new issue