Compare commits

...

No commits in common. "7e06844b2f4e7b4cb9ffe0b9b7bbf4f01479c2a3" and "a028986975a66fc7f8cb9eb63caf9576c59961d1" have entirely different histories.

19 changed files with 3652 additions and 239 deletions

7
.editorconfig Normal file
View file

@ -0,0 +1,7 @@
root = true
[*]
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
charset = utf-8

3
.gitignore vendored
View file

@ -1,2 +1 @@
__pycache__
.mypy_cache
/target

2991
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

31
Cargo.toml Normal file
View file

@ -0,0 +1,31 @@
[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]
clap = { version = "4.0.22", features = ["color", "derive"] }
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"
time = "0.3.17"
tokio = { version = "1.21.2", features = ["full"] }
toml = "0.5.9"
url = { version = "2.3.1", features = ["serde"] }
xdg = "2.4.1"
# logging
color-eyre = "0.6.2"
tracing = "0.1.37"
tracing-error = "0.2.0"
tracing-subscriber = { version = "0.3.16", features = ["env-filter"] }

View file

@ -1,8 +0,0 @@
FROM docker.io/alpine:latest
RUN apk --no-cache add git py3-pip py3-matrix-nio py3-cryptography py3-pillow &&\
git clone https://git.it-syndikat.org/IT-Syndikat/its-matrix-bot.git /matrix-bot && \
cd /matrix-bot && \
pip install .
CMD python3 -m its_matrix_bot -c /its-matrix.toml

View file

@ -2,7 +2,27 @@
Running in [#lobby:it-syndik.at](matrix:r/lobby:it-syndik.at).
This bot was recently rewritten in Rust, the original python implementation can be found in the
[python-legacy branch][pycode].
[pycode]: https://git.it-syndikat.org/IT-Syndikat/its-matrix-bot/src/branch/python-legacy
## Setup
For all these steps, either install the binary using `cargo install` and then run it as
`its-matrix-bot`, or run the project directly from the repository using `cargo run --` (e.g. `cargo
run -- setup`).
1. Copy `config.toml.example` to `~/.config/its-matrix-bot/config.toml` (or some other location
later specified using `-c, --config`)
2. Run first-time setup using `its-matrix-bot setup [-h HOMESERVER_URL] USERNAME` and enter the bot
user's password.
3. Start the bot using `its-matrix-bot run`.
## 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
- `!spaceping`: play a chime in the hackerpace to attempt to get someone to read your matrix
messages
<!-- vim: set tw=100: -->

1
clippy.toml Normal file
View file

@ -0,0 +1 @@
doc-valid-idents = ["SpaceAPI"]

View file

@ -1,13 +1,7 @@
[app]
command_prefix = "!"
[matrix]
homeserver = ...
username = ...
access_token = ...
[spaceping]
api_token = ...
[isitopen]
announce_rooms = ["!room_id:homeserver.example"]
[space]
base_url = "https://spaceapi.it-syndikat.org/api/"
spaceping_token = "foo"
announce_rooms = ["#lobby:it-syndik.at"]

3
deny.toml Normal file
View file

@ -0,0 +1,3 @@
[licenses]
allow-osi-fsf-free = "both"
allow = ["Unicode-DFS-2016", "CC0-1.0"]

View file

@ -1,124 +0,0 @@
import asyncio
import datetime
import logging
from nio.rooms import MatrixRoom
from nio.events.room_events import RoomMessage
import simplematrixbotlib as botlib
from .its_api import ItSyndikatApi
from .config import Config
class ItSyndikatBot:
bot: botlib.Bot
its_api: ItSyndikatApi
config: Config
def __init__(self, config: Config):
self.config = config
self.its_api = ItSyndikatApi(config)
self.current_open_state = None
creds = botlib.Creds(
config.matrix_homeserver,
config.matrix_username,
access_token=config.matrix_access_token,
session_stored_file="",
)
self.bot = botlib.Bot(creds)
self.bot.listener.on_message_event(self.on_message)
async def run(self):
async def poll_for_changes():
while True:
logging.debug("Polling open state")
try:
status = await self.its_api.status()
new_state = status["state"]["open"]
if (
self.current_open_state is not None
and new_state != self.current_open_state
):
await self.announce_open_change(new_state)
self.current_open_state = new_state
except Exception as e:
logging.error(f"Polling for open state failed: {e}")
await asyncio.sleep(60)
asyncio.create_task(poll_for_changes())
await self.bot.main()
async def on_message(self, room, message):
m = botlib.MessageMatch(room, message, self.bot, self.config.command_prefix)
if m.is_not_from_this_bot() and m.prefix():
if m.command("echo"):
await self.echo(room, message, m.args())
elif m.command("isitopen"):
await self.isitopen(room, message)
elif m.command("spaceping"):
await self.spaceping(room, message)
else:
await self.bot.api.send_text_message(
room.room_id, f"Unknown command: {m.command()}"
)
async def announce_open_change(self, now_open: bool):
logging.info("Open state changed: now " + ("open" if now_open else "closed"))
room_ids = self.config.isitopen_announce_rooms
if now_open:
message = "opening IT-Syndikat - Ohai!"
else:
message = "closing IT-Syndikat - nap time!"
for room_id in room_ids:
await self.bot.api.async_client.room_send(
room_id=room_id,
message_type="m.room.message",
content={
"msgtype": "m.notice",
"body": message,
},
)
async def reply(self, room, message, reply):
await self.bot.api.async_client.room_send(
room_id=room.room_id,
message_type="m.room.message",
content={
"msgtype": "m.text",
"body": reply,
"m.relates_to": {"m.in_reply_to": {"event_id": message.event_id}},
},
)
async def echo(self, room, message, args):
await self.bot.api.send_text_message(
room.room_id, " ".join(arg for arg in args)
)
async def isitopen(self, room, message):
try:
status = await self.its_api.status()
is_open = status["state"]["open"]
self.current_open_state = is_open
if is_open:
date = datetime.datetime.fromtimestamp(status["state"]["lastchange"])
text = f"positive! space has been open since {date}"
else:
text = "negative!"
except Exception as e:
text = f"error checking space status: {e}"
await self.reply(room, message, text)
async def spaceping(self, room: MatrixRoom, message: RoomMessage):
await self.its_api.ping()
await self.reply(room, message, "Hello Space!")

View file

@ -1,20 +0,0 @@
import asyncio
import argparse
import logging
from . import ItSyndikatBot
from .config import Config
logging.basicConfig(level=logging.DEBUG)
parser = argparse.ArgumentParser(description="IT-Syndikat matrix bot")
parser.add_argument(
"-c",
"--config",
help="path to the config file",
)
args = parser.parse_args()
bot = ItSyndikatBot(Config(args.config))
asyncio.run(bot.run())

View file

@ -1,31 +0,0 @@
import toml
from typing import List
class Config:
command_prefix: str
matrix_homeserver: str
matrix_user: str
matrix_access_token: str
spaceping_token: str
isitopen_announce_rooms: List[str]
def __init__(self, path=None):
if path is None:
path = "/etc/itsyndikat-bot.toml"
config = toml.load(path)
self.command_prefix = config["app"]["command_prefix"]
matrix = config["matrix"]
self.matrix_homeserver = matrix["homeserver"]
self.matrix_username = matrix["username"]
self.matrix_access_token = matrix["access_token"]
self.spaceping_token = config["spaceping"]["api_token"]
self.isitopen_announce_rooms = config["isitopen"]["announce_rooms"]

View file

@ -1,26 +0,0 @@
import aiohttp
from .config import Config
class ItSyndikatApi:
base_url: str
config: Config
def __init__(self, config: Config):
self.base_url = "https://spaceapi.it-syndikat.org/api/"
self.config = config
async def status(self):
async with aiohttp.ClientSession() as session:
async with session.get(self.base_url + "status.php") as response:
return await response.json()
async def ping(self):
params = {"apikey": self.config.spaceping_token}
async with aiohttp.ClientSession() as session:
async with session.post(
self.base_url + "ping.php", params=params
) as response:
await response.text()

View file

@ -1,17 +0,0 @@
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"
[project]
name = "its-matrix-bot"
version = "0.0.1"
description = "IT-Syndikat matrix bot"
readme = "README.md"
license = {text = "GNU Affero General Public License"}
classifiers = [
"Programming Language :: Python :: 3",
]
dependencies = [
"simplematrixbotlib",
"aiohttp",
]

15
rustfmt.toml Normal file
View 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

303
src/bot.rs Normal file
View file

@ -0,0 +1,303 @@
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")
}
}

77
src/its_api.rs Normal file
View 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(#[source] reqwest::Error),
#[error("network request failed")]
Network(#[source] 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(())
}
}

132
src/main.rs Normal file
View file

@ -0,0 +1,132 @@
#![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 {
/// Override path to the bot's configuration file
#[arg(long, short = 'c')]
config: Option<PathBuf>,
#[command(subcommand)]
sub: Subcommand,
}
#[derive(clap::Subcommand, Debug)]
enum Subcommand {
Setup(setup::Setup),
/// Run the bot
Run,
}
/// 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();
match args.sub {
Subcommand::Setup(setup) => {
setup::setup(setup).await?;
Ok(())
}
Subcommand::Run => {
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?
}
}
}

66
src/setup.rs Normal file
View file

@ -0,0 +1,66 @@
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;
/// Perform first-time setup using password login
#[derive(Args, Debug)]
pub(crate) struct Setup {
/// The bot's matrix username, e.g. `@mybot:myhomeserver.example`
username: Box<UserId>,
/// An optional homeserver base URL. Will attempt to auto-detect if not
/// specified.
#[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(())
}