initial commit

This commit is contained in:
Pierre de Lacroix 2026-06-22 16:49:17 +02:00
commit f83fded289
Signed by: lateralus23
GPG key ID: 53E0CEC29C24EF39
15 changed files with 6084 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/target

4554
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

22
Cargo.toml Normal file
View file

@ -0,0 +1,22 @@
[package]
name = "shift_bot"
version = "0.1.0"
edition = "2024"
[lib]
name = "shift_bot"
[dependencies]
anyhow = "^1.0"
dirs = "^6.0"
futures-util = "^0.3"
matrix-sdk = "^0.16"
pulldown-cmark = "^0.13"
rand = "^0.9"
serde = "^1.0"
serde_json = { version = "^1.0", features = ["arbitrary_precision"] }
tokio = { version = "^1.49", features = ["full"] }
clap = { version = "^4.5", features = ["derive"] }
log = "^0.4"
simple_logger = "^5.2"
either = "^1.15"

29
README.md Normal file
View file

@ -0,0 +1,29 @@
# Shift bot
A bot for handling shifts.
## Build
Build with
```bash
> cargo build --release
[...]
# executable is here
> ls target/release/shift_bot
target/release/shift_bot
```
## Usage
Make sure `shift_bot` is in your path for what follows. Alternatively, you can replace command
`shift_bot` with `cargo run --release --` in the instructions below, assuming you are at the root
of this repository.
```bash
shift_bot --username shift_bot --homeserver interhacker.space --password SO_SECRET --grist_api_key SECRET_TOO
# alternatively
shift_bot -u neko -s interhacker.space -p SO_SECRET --grist_api_key SECRET_TOO
# or, if the homeserver is `matrix.org`
shift_bot -u neko -p SO_SECRET --GRIST_API_KEY SECRET_TOO
```

189
src/basic.rs Normal file
View file

@ -0,0 +1,189 @@
/*
Copyright © 2026 Anzenlang
Licensed under the PolyForm Noncommercial License 1.0.0
https://polyformproject.org/licenses/noncommercial/
SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
SPDX-AI-Restriction: No training allowed. See NOTICE file.
See LICENSE file for complete terms.
WARNING: The contents of this file may NOT be used to train AI/LLM models. See NOTICE for legal
details.
*/
//! # Basic helpers
pub use std::{
collections::{HashMap, HashSet},
fmt::{Display, Formatter as Fmt, Result as FmtRes},
hash::Hash,
mem,
path::{Path, PathBuf},
sync::{Arc, RwLock},
thread,
time::Duration,
};
pub use matrix_sdk::ruma::{EventId, OwnedEventId, OwnedRoomId, OwnedUserId, RoomId, UserId};
pub use tokio::sync::mpsc::{self, Receiver, Sender};
pub use serde::{Deserialize, Serialize};
pub use serde_json::{Number as JsonNumber, Value as Json};
pub use anyhow::{Context, Result as Res, anyhow, bail};
pub use log::{debug, error, info, trace, warn};
pub use either::Either::{self, Left, Right};
pub mod conf;
pub use crate::{
serve::{self, Client},
};
/// Identity function.
pub fn id<T>(t: T) -> T {
t
}
/// Runs a command.
pub fn run_cmd(mut cmd: std::process::Command) -> Res<()> {
let output = cmd
.output()
.with_context(lformat!("failed to run command {:?}", cmd))?;
if !output.status.success() {
bail!(
"\
command execution failed with {}
> {:?}
```stdout
{}
```
```stderr
{}
```\
",
output.status,
cmd,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
)
}
Ok(())
}
/// A matrix identifier (name + homeserver).
#[derive(Clone, Debug)]
pub struct MatrixId {
/// User name.
name: String,
/// Homeserver.
homeserver: String,
}
impl MatrixId {
/// Constructor.
pub fn new(name: impl Into<String>, homeserver: impl Into<String>) -> Self {
Self {
name: name.into(),
homeserver: homeserver.into(),
}
}
pub fn of_string(s: impl AsRef<str>) -> Res<Self> {
let mut s = s.as_ref();
if !s.starts_with("@") {
bail!("matrix identifier must start with `@`")
}
s = &s[1..];
if let Some(idx) = s.find(':') {
let (name, homeserver) = s.split_at(idx);
Ok(Self {
name: name.into(),
homeserver: homeserver.into(),
})
} else {
bail!("expected `@<name>:<homeserver>`")
}
}
/// User name.
pub fn name(&self) -> &str {
&self.name
}
/// Homeserver.
pub fn homeserver(&self) -> &str {
&self.homeserver
}
}
impl Display for MatrixId {
fn fmt(&self, f: &mut Fmt<'_>) -> FmtRes {
write!(f, "@{}:{}", self.name, self.homeserver)
}
}
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Sum<T, U> {
Inl(T),
Inr(U),
}
impl<T, U> Sum<T, U> {
pub fn new_left(t: T) -> Sum<T, U> {
Self::Inl(t)
}
pub fn new_right(u: U) -> Sum<T, U> {
Self::Inr(u)
}
pub fn as_ref(&self) -> Sum<&T, &U> {
match self {
Self::Inl(t) => Sum::Inl(t),
Self::Inr(u) => Sum::Inr(u),
}
}
pub fn map_both<X, Y>(self, fl: impl FnOnce(T) -> X, fr: impl FnOnce(U) -> Y) -> Sum<X, Y> {
match self {
Self::Inl(t) => Sum::Inl(fl(t)),
Self::Inr(u) => Sum::Inr(fr(u)),
}
}
pub fn map_left<X>(self, fl: impl FnOnce(T) -> X) -> Sum<X, U> {
self.map_both(fl, id)
}
pub fn map_right<X>(self, fr: impl FnOnce(U) -> X) -> Sum<T, X> {
self.map_both(id, fr)
}
pub fn merge<X>(self, fl: impl FnOnce(T) -> X, fr: impl FnOnce(U) -> X) -> X {
match self {
Self::Inl(t) => fl(t),
Self::Inr(u) => fr(u),
}
}
}
/// Catches an error produced by an async function: reports and propagates it.
pub async fn report_error_with<T>(msg: impl AsRef<str>, f: impl AsyncFnOnce() -> Res<T>) -> Res<T> {
match f().await {
Ok(t) => Ok(t),
Err(e) => {
let msg = msg.as_ref();
if msg.is_empty() {
error!("{}", e);
} else {
error!("{}\n{}", msg, e);
}
Err(e)
}
}
}
/// Catches an error produced by an async function: reports and propagates it.
pub async fn report_error<T>(f: impl AsyncFnOnce() -> Res<T>) -> Res<T> {
report_error_with("", f).await
}

132
src/basic/conf.rs Normal file
View file

@ -0,0 +1,132 @@
/*
Copyright © 2026 Anzenlang
Licensed under the PolyForm Noncommercial License 1.0.0
https://polyformproject.org/licenses/noncommercial/
SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
SPDX-AI-Restriction: No training allowed. See NOTICE file.
See LICENSE file for complete terms.
WARNING: The contents of this file may NOT be used to train AI/LLM models. See NOTICE for legal
details.
*/
//! # Configuration helpers
use std::sync::{Arc, LazyLock, RwLock};
prelude! {}
/// Lazy static `Main`-configuration.
static CONF: LazyLock<RwLock<Arc<Main>>> = LazyLock::new(|| RwLock::new(Arc::new(Main::default())));
/// Lazy static `Serve`-configuration.
static SERVE_CONF: LazyLock<RwLock<Option<Arc<Serve>>>> = LazyLock::new(|| RwLock::new(None));
/// Sets the main configuration.
pub fn set(conf: Main) {
let mut conf_ref = CONF
.write()
.expect("main configuration is poisoned, giving up");
*conf_ref = Arc::new(conf);
}
/// Main configuration.
pub fn get() -> Arc<Main> {
CONF.read()
.expect("main configuration is poisoned, giving up")
.clone()
}
/// Sets the serve configuration, fails if it is already set.
pub fn set_serve(conf: Serve) {
let mut conf_ref = SERVE_CONF
.write()
.expect("serve configuration is poisoned, giving up");
if conf_ref.is_some() {
panic!("logical error: `serve` configuration is already set")
}
*conf_ref = Some(Arc::new(conf));
}
/// Serve-configuration.
pub fn get_serve() -> Arc<Serve> {
SERVE_CONF
.read()
.expect("serve configuration is poisoned, giving up")
.as_ref()
.expect("serve configuration not set, something is very wrong")
.clone()
}
/// Main configuration, used as a lazy static-const set by CLAP.
pub struct Main {
/// The data directory.
static_dir: PathBuf,
}
impl Main {
/// Constructor.
pub fn new() -> Self {
let static_dir_root = match dirs::data_dir().context("no `static_dir` directory found") {
Ok(root) => root,
Err(e) => {
error!("{}", e);
panic!("failed to retrieve data directory, cannot proceed")
}
};
Self {
static_dir: static_dir_root.join("shift_bot"),
}
}
/// The directory storing the static bot data.
pub fn static_dir(&self) -> &Path {
&self.static_dir
}
/// Yields the path to something in the static bot data directory.
pub fn from_static_dir(&self, path: impl AsRef<Path>) -> PathBuf {
self.static_dir().join(path)
}
const SESSION_FILE_NAME: &str = "session";
/// Path to the session file.
pub fn session_file(&self) -> PathBuf {
self.from_static_dir(Self::SESSION_FILE_NAME)
}
}
impl Default for Main {
fn default() -> Self {
Self::new()
}
}
/// Shift bot runner configuration.
#[derive(Clone, Debug)]
pub struct Serve {
/// Identifier of the bot.
bot_id: MatrixId,
/// Bot's account password.
bot_pass: String,
}
impl Serve {
/// Constructor.
pub fn new(bot_id: MatrixId, bot_pass: impl Into<String>) -> Self {
Self {
bot_id,
bot_pass: bot_pass.into(),
}
}
/// Identifier of the bot.
pub fn bot_id(&self) -> &MatrixId {
&self.bot_id
}
/// Bot account password.
pub fn bot_pass(&self) -> &str {
&self.bot_pass
}
}

69
src/lib.rs Normal file
View file

@ -0,0 +1,69 @@
/*
Copyright © 2026 Anzenlang
Licensed under the PolyForm Noncommercial License 1.0.0
https://polyformproject.org/licenses/noncommercial/
SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
SPDX-AI-Restriction: No training allowed. See NOTICE file.
See LICENSE file for complete terms.
WARNING: The contents of this file may NOT be used to train AI/LLM models. See NOTICE for legal
details.
*/
//! # Shift bot
/// Imports this crate's prelude.
#[macro_export]
macro_rules! prelude {
{ $($more_imports:tt)* } => {use $crate::basic::{*, $($more_imports)*};}
}
/// Lazy version of `format!`, for `anyhow::Context::with_context`.
macro_rules! lformat { { $($tokens:tt)* } => { || format!($($tokens)*) }
}
/// Lazy version of `anyhow!`.
macro_rules! lazyhow { { $($tokens:tt)* } => { || anyhow!($($tokens)*) } }
/// Issues an *unimplemented* message through `warn!`.
// macro_rules! warn_todo {
// // plain string
// { $e:expr } => { warn!(concat!("[unimplemented] ", $e)) } ;
// { $($tokens:tt)* } => { warn!("[unimplemented] {}", format!($($tokens)*)) }
// }
pub mod basic;
pub mod serve;
prelude! {}
/// Warmup, runs before anything.
pub fn warmup(wipe_static_dir: bool) -> Res<()> {
if wipe_static_dir {
let conf = conf::get();
let static_dir_root = conf.static_dir();
if static_dir_root.exists() {
debug!(
"wiping static data directory `{}`",
static_dir_root.display()
);
std::fs::remove_dir_all(static_dir_root).with_context(lformat!(
"failed to wipe static data directory `{}`",
static_dir_root.display()
))?
} else {
debug!("asked to wipe static data directory but none found, moving on")
}
}
Ok(())
}
/// Serves the shift bot.
pub async fn serve_bot(serve_conf: conf::Serve) -> Res<()> {
conf::set_serve(serve_conf);
serve::run().await
}

84
src/main.rs Normal file
View file

@ -0,0 +1,84 @@
/*
Copyright © 2026 Anzenlang
Licensed under the PolyForm Noncommercial License 1.0.0
https://polyformproject.org/licenses/noncommercial/
SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
SPDX-AI-Restriction: No training allowed. See NOTICE file.
See LICENSE file for complete terms.
WARNING: The contents of this file may NOT be used to train AI/LLM models. See NOTICE for legal
details.
*/
use clap::{Parser};
use shift_bot::*;
prelude! {}
/// Runs the shift bot.
#[derive(Parser, Debug)]
#[command(version, about)]
struct Clap {
/// Verbosity level.
#[arg(short, long, value_name = "NATURAL", default_value_t = 2)]
verb: usize,
/// Wipe the static data directory before doing anything.
#[arg(short, long)]
wipe: bool,
/// Identifier of the element bot account to run.
#[arg(
short,
long,
value_name = "MATRIX_USERNAME",
default_value = "shift_bot_dev"
)]
username: String,
/// Homeserver.
#[arg(
short = 's',
long,
value_name = "MATRIX_HOMESERVER",
default_value = "matrix.org"
)]
homeserver: String,
/// Password of the element bot account.
#[arg(short, long, value_name = "PASSWORD")]
password: String,
}
impl Clap {
/// Retrieves the `log::Level` from CLA-s.
fn verb_level(&self) -> log::Level {
match self.verb {
// in quiet mode, only show errors
0 => log::Level::Error,
1 => log::Level::Warn,
2 => log::Level::Info,
3 => log::Level::Debug,
_ => log::Level::Trace,
}
}
/// Sets the main configuration.
async fn run(self) -> Res<()> {
simple_logger::init_with_level(self.verb_level())
.context("something when wrong while initializing logger")?;
conf::set(conf::Main::new());
warmup(self.wipe)?;
let bot = MatrixId::new(self.username, self.homeserver);
let conf = conf::Serve::new(bot, self.password);
serve_bot(conf).await
}
}
#[tokio::main]
async fn main() -> Res<()> {
let clap = Clap::parse();
clap.run().await
}

218
src/serve.rs Normal file
View file

@ -0,0 +1,218 @@
/*
Copyright © 2026 Anzenlang
Licensed under the PolyForm Noncommercial License 1.0.0
https://polyformproject.org/licenses/noncommercial/
SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
SPDX-AI-Restriction: No training allowed. See NOTICE file.
See LICENSE file for complete terms.
WARNING: The contents of this file may NOT be used to train AI/LLM models. See NOTICE for legal
details.
*/
use matrix_sdk::{
Error, LoopCtrl,
config::SyncSettings,
ruma::api::client::filter::FilterDefinition,
ruma::events::{
key::verification::request::ToDeviceKeyVerificationRequestEvent,
room::message::{MessageType, OriginalSyncRoomMessageEvent},
},
};
prelude! {}
mod auth;
mod client;
mod invitation;
mod message;
mod session;
mod verification;
pub use client::Client;
/// Runs the shift bot.
pub async fn run() -> Res<()> {
trace!("building *serve* state");
let state = State::new().await.context("failed to create/retrieve")?;
trace!("launching");
state.run().await
}
/// Shift bot state.
#[derive(Clone)]
pub struct State {
pub client: Client,
pub sync_token: Option<String>,
}
// impl std::ops::Deref for State {
// type Target = Client;
// fn deref(&self) -> &Self::Target {
// &self.client
// }
// }
impl State {
pub async fn new() -> Res<State> {
let (client, sync_token) = Client::restore_session_or_login().await?;
let rooms = client.rooms();
if rooms.is_empty() {
trace!("I'm not in any room");
} else {
let mut msg = format!("Currently in {} room(s)", rooms.len());
for room in rooms {
if let Some(name) = room.name() {
msg = format!("{}\n- `{}`: {}", msg, name, room.room_id())
} else {
msg = format!("{}\n{}", msg, room.room_id())
}
}
trace!("{}", msg)
}
Ok(Self { client, sync_token })
}
pub async fn run(mut self) -> Res<()> {
info!("deploying");
let sync_settings = self.deploy().await.context("failed to deploy")?;
info!("✅ client ready and listening to new messages");
let res = self
.loop_sync(sync_settings)
.await
.context("during sync-loop");
info!("done serving (result is error: {})", res.is_err());
res
}
/// Setup the client to listen to new messages.
pub async fn deploy(&mut self) -> Res<SyncSettings> {
debug!("adding event handler (`ToDeviceKeyVerificationRequestEvent`)");
self.client.add_event_handler(
|ev: ToDeviceKeyVerificationRequestEvent, client| async move {
let client = Client::from_matrix(client);
let request = client
.encryption()
.get_verification_request(&ev.sender, &ev.content.transaction_id)
.await
.expect("failed to create request object in event handler");
tokio::spawn(verification::request_verification_handler(client, request));
},
);
debug!("adding event handler (`OriginalSyncRoomMessageEvent`)");
self.client.add_event_handler(
|ev: OriginalSyncRoomMessageEvent, client: Client| async move {
if let MessageType::VerificationRequest(_) = &ev.content.msgtype {
let request = client
.encryption()
.get_verification_request(&ev.sender, &ev.event_id)
.await
.expect("request object wasn't created");
tokio::spawn(verification::request_verification_handler(client, request));
}
},
);
let mut sync_settings = {
// Enable room members lazy-loading, it will speed up the initial sync a lot
// for accounts in lots of rooms.
// See <https://spec.matrix.org/v1.6/client-server-api/#lazy-loading-room-members>.
let filter = FilterDefinition::with_lazy_loading();
SyncSettings::default().filter(filter.into())
};
// We restore the sync where we left. This is not necessary when not using `sync_once`.
// The other sync methods get the sync token from the store.
if let Some(sync_token) = self.sync_token.as_ref() {
sync_settings = sync_settings.token(sync_token);
}
debug!("launching a first sync to ignore past messages…");
// Let's ignore messages before the program was launched.
//
// This is a loop in case the initial sync is longer than our timeout. The server should
// cache the response and it will ultimately take less time to receive.
loop {
match self
.client
.sync_once(sync_settings.clone())
.await
.context("initial sync failed")
{
Ok(response) => {
// This is the last time we need to provide this token, the sync method after
// will handle it on its own.
sync_settings = sync_settings.token(response.next_batch.clone());
session::persist_sync_token(response.next_batch).await?;
break;
}
Err(error) => {
error!("{}", error.context("while draining old messages"));
info!("running initial sync again…");
}
}
}
debug!("adding event handlers");
// self.client.add_event_handler_context(in_msg_sender);
// Now that we've synced, let's attach a handler for incoming room messages.
self.client.add_event_handler(message::on_room_message);
// And one for auto-joining rooms
self.client
.add_event_handler(invitation::on_stripped_state_member);
Ok(sync_settings)
}
/// Loops forever, updating the sync-token.
pub async fn loop_sync(self, sync_settings: SyncSettings) -> Res<()> {
// // This loops until we kill the program or an error happens.
// self.client
// .sync_with_result_callback(sync_settings, |sync_result| async move {
// trace!("loop-sync iteration");
// let response = sync_result?;
// // We persist the token each time to be able to restore our session
// session::persist_sync_token(response.next_batch)
// .await
// .map_err(|err| Error::UnknownError(err.into()))?;
// trace!("continue loop-sync");
// Ok(LoopCtrl::Continue)
// })
// .await?;
// Ok(())
// This loops until we kill the program or an error happens.
self.client
.sync_with_result_callback(sync_settings, |sync_result| async move {
match sync_result {
Ok(response) => {
// We persist the token each time to be able to restore our session
session::persist_sync_token(response.next_batch)
.await
.map_err(|err| {
error!("in persist sync token:\n{}", err);
Error::UnknownError(err.into())
})?;
}
// Err(Error::Timeout) => {
// warn!("periodic sync timed out, ignoring and looping back");
// }
Err(e) => {
error!("in sync-loop, ignoring and looping back:\n{}", e);
// return Err(e);
}
};
Ok(LoopCtrl::Continue)
})
.await?;
Ok(())
}
}

124
src/serve/auth.rs Normal file
View file

@ -0,0 +1,124 @@
/*
Copyright © 2026 Anzenlang
Licensed under the PolyForm Noncommercial License 1.0.0
https://polyformproject.org/licenses/noncommercial/
SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
SPDX-AI-Restriction: No training allowed. See NOTICE file.
See LICENSE file for complete terms.
WARNING: The contents of this file may NOT be used to train AI/LLM models. See NOTICE for legal
details.
*/
prelude! {}
use matrix_sdk::ServerName;
/// ## Session / login
impl Client {
// Restore a previous session.
pub async fn restore_session(session_file: impl AsRef<Path>) -> Res<(Self, Option<String>)> {
use serve::session::FullSession;
use tokio::fs;
let session_file = session_file.as_ref();
trace!(
"previous session found in '{}'",
session_file.to_string_lossy()
);
// The session was serialized as JSON in a file.
let serialized_session = fs::read_to_string(session_file).await?;
let FullSession {
client_session,
user_session,
sync_token,
} = serde_json::from_str(&serialized_session)?;
let server_name = ServerName::parse(client_session.homeserver)?;
// Build the client with the previous settings from the session.
let client = matrix_sdk::Client::builder()
.server_name(&server_name)
.sqlite_store(client_session.db_path, Some(&client_session.passphrase))
.build()
.await?;
debug!("restoring session for {}…", user_session.meta.user_id);
// Restore the Matrix user session.
client.restore_session(user_session).await?;
Ok((Self::from(client), sync_token))
}
/// Login with a new device.
pub async fn login(
homeserver: impl Into<String>,
username: impl AsRef<str>,
password: impl AsRef<str>,
) -> Res<Self> {
use serve::session::FullSession;
use tokio::fs;
debug!("no previous session found, logging in…");
let session_file = conf::get().session_file();
let username = username.as_ref();
let password = password.as_ref();
info!("building client");
let (client, client_session) = Client::build_client(homeserver).await?;
thread::sleep(Duration::from_secs(5));
let matrix_auth = client.matrix_auth();
info!("matrix auth");
matrix_auth
.login_username(username, &password)
.initial_device_display_name("Shift Bot")
.await
.context("failed to login")?;
thread::sleep(Duration::from_secs(5));
// Persist the session to reuse it later.
// This is not very secure, for simplicity. If the system provides a way of
// storing secrets securely, it should be used instead.
// Note that we could also build the user session from the login response.
let user_session = matrix_auth
.session()
.context("a logged-in client should have a session")?;
let serialized_session = serde_json::to_string(&FullSession {
client_session,
user_session,
sync_token: None,
})?;
fs::write(&session_file, serialized_session).await?;
debug!("session persisted in {}", session_file.display());
// After logging in, you might want to verify this session with another one (see
// the `emoji_verification` example), or bootstrap cross-signing if this is your
// first session with encryption, or if you need to reset cross-signing because
// you don't have access to your old sessions (see the
// `cross_signing_bootstrap` example).
Ok(client)
}
/// Restores the session from the session file or
pub async fn restore_session_or_login() -> Res<(Self, Option<String>)> {
let serve_conf = conf::get_serve();
let session_file = conf::get().session_file();
let (bot_id, bot_pass) = (serve_conf.bot_id(), serve_conf.bot_pass());
Ok(if session_file.exists() {
trace!("restoring session");
Self::restore_session(&session_file).await?
} else {
trace!("logging in");
(
Self::login(bot_id.homeserver(), bot_id.name(), bot_pass).await?,
None,
)
})
}
}

228
src/serve/client.rs Normal file
View file

@ -0,0 +1,228 @@
/*
Copyright © 2026 Anzenlang
Licensed under the PolyForm Noncommercial License 1.0.0
https://polyformproject.org/licenses/noncommercial/
SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
SPDX-AI-Restriction: No training allowed. See NOTICE file.
See LICENSE file for complete terms.
WARNING: The contents of this file may NOT be used to train AI/LLM models. See NOTICE for legal
details.
*/
use matrix_sdk::{
ruma::events::direct::{DirectEventContent, OwnedDirectUserIdentifier},
{Room, ServerName},
};
use rand::{Rng, distr::Alphanumeric, rng};
use super::session::ClientSession;
prelude! {}
/// Wrapper around `matrix_sdk::Client` so that we can extend its functionalities.
#[derive(Clone)]
pub struct Client {
/// Actual matrix client.
get: matrix_sdk::Client,
}
impl std::ops::Deref for Client {
type Target = matrix_sdk::Client;
fn deref(&self) -> &Self::Target {
&self.get
}
}
impl matrix_sdk::event_handler::EventHandlerContext for Client {
fn from_data(handler: &matrix_sdk::event_handler::EventHandlerData<'_>) -> Option<Self> {
matrix_sdk::Client::from_data(handler).map(Self::from_matrix)
}
}
impl Into<matrix_sdk::Client> for Client {
fn into(self) -> matrix_sdk::Client {
self.into_matrix()
}
}
impl AsRef<matrix_sdk::Client> for Client {
fn as_ref(&self) -> &matrix_sdk::Client {
self.as_matrix()
}
}
impl From<matrix_sdk::Client> for Client {
fn from(client: matrix_sdk::Client) -> Self {
Self::from_matrix(client)
}
}
/// ## Basic helpers
impl Client {
/// Private constructor from a matrix client.
pub fn from_matrix(client: matrix_sdk::Client) -> Self {
Self { get: client }
}
pub fn to_matrix(&self) -> matrix_sdk::Client {
self.get.clone()
}
pub fn into_matrix(self) -> matrix_sdk::Client {
self.get
}
pub fn as_matrix(&self) -> &matrix_sdk::Client {
&self.get
}
/// Builds a new client.
pub async fn build_client(homeserver: impl Into<String>) -> Res<(Self, ClientSession)> {
let homeserver = homeserver.into();
let mut rng = rng();
// Generating a subfolder for the database is not mandatory, but it is useful if
// you allow several clients to run at the same time. Each one must have a
// separate database, which is a different folder with the SQLite store.
let db_subfolder: String = (&mut rng)
.sample_iter(Alphanumeric)
.take(7)
.map(char::from)
.collect();
let db_path = conf::get().from_static_dir(db_subfolder);
// Generate a random passphrase.
let passphrase: String = (&mut rng)
.sample_iter(Alphanumeric)
.take(32)
.map(char::from)
.collect();
// We create a loop here so the user can retry if an error happens.
let server_name = ServerName::parse(&homeserver)?;
match matrix_sdk::Client::builder()
.server_name(&server_name)
// We use the SQLite store, which is enabled by default. This is the crucial part to
// persist the encryption setup.
// Note that other store backends are available and you can even implement your own.
.sqlite_store(&db_path, Some(&passphrase))
.build()
.await
{
Ok(client) => {
return Ok((
Self::from(client),
ClientSession {
homeserver,
db_path,
passphrase,
},
));
}
Err(error) => return Err(error.into()),
}
}
/// Resolves the direct-/private-message-room with a user.
pub async fn try_resolve_direct_room(&self, user_id: impl AsRef<UserId>) -> Res<Option<Room>> {
if let Some(content) = self
.account()
.fetch_account_data_static::<DirectEventContent>()
.await?
{
let content = content.deserialize()?;
let user = user_id.as_ref();
let user_id = OwnedDirectUserIdentifier::from(user);
let room_id = content
.get(&user_id)
.ok_or_else(lazyhow!("illegal user identifier `{}`", user))?
.get(0)
.ok_or_else(lazyhow!("failed to resolve direct room with `{}`", user))?;
Ok(self.get_room(room_id))
} else {
Ok(None)
}
}
/// Resolves a room from its identifier.
fn resolve_room(&self, room: &RoomId) -> Res<Room> {
self.get_room(room)
.ok_or_else(lazyhow!("failed to resolve room {}", room))
}
/// Sends a message to a room.
pub async fn send_room_msg(
&self,
room: impl AsRef<RoomId>,
content: String,
) -> Res<OwnedEventId> {
use matrix_sdk::ruma::events::room::message::RoomMessageEventContent;
let room = room.as_ref();
let md_parser = pulldown_cmark::Parser::new(&content);
let mut html_body = String::with_capacity(250);
pulldown_cmark::html::push_html(&mut html_body, md_parser);
let content = RoomMessageEventContent::text_html(content, html_body);
let room = self.resolve_room(room)?;
let sent = room.send(content).await?;
Ok(sent.event_id)
}
/// Sends a message in the thread corresponding to the message corresponding to `event`.
pub async fn send_event_msg(
&self,
room: impl AsRef<RoomId>,
event: impl AsRef<EventId>,
content: impl Into<String>,
) -> Res<OwnedEventId> {
use matrix_sdk::{
room::reply::{EnforceThread, Reply},
ruma::events::room::message::{
ReplyWithinThread, RoomMessageEventContentWithoutRelation,
},
};
let room = room.as_ref();
let event = event.as_ref();
let content = content.into();
let md_parser = pulldown_cmark::Parser::new(&content);
let mut html_body = String::with_capacity(250);
pulldown_cmark::html::push_html(&mut html_body, md_parser);
let content = RoomMessageEventContentWithoutRelation::text_html(content, html_body);
let room = self.resolve_room(room)?;
let event = room
.make_reply_event(
content,
Reply {
event_id: event.to_owned(),
enforce_thread: EnforceThread::Threaded(ReplyWithinThread::No),
},
)
.await?;
let r = room.send(event).await?;
Ok(r.event_id)
}
/// Attaches a reaction to the message corresponding to `event`.
///
/// If sending the reaction fails, a warning is issued but not error is thrown.
pub async fn send_reaction_to(
&self,
room: impl AsRef<RoomId>,
event: impl AsRef<EventId>,
reaction: impl Into<String>,
) -> Res<()> {
use matrix_sdk::ruma::events::{reaction::ReactionEventContent, relation::Annotation};
let event = event.as_ref();
let reaction = reaction.into();
let room = self.resolve_room(room.as_ref())?;
let reaction_res = room
.send(ReactionEventContent::new(Annotation::new(
event.into(),
reaction,
)))
.await;
if let Err(e) = reaction_res {
warn!("failed to send reaction, moving on...\n{}", e)
}
Ok(())
}
}

69
src/serve/invitation.rs Normal file
View file

@ -0,0 +1,69 @@
/*
Copyright © 2026 Anzenlang
Licensed under the PolyForm Noncommercial License 1.0.0
https://polyformproject.org/licenses/noncommercial/
SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
SPDX-AI-Restriction: No training allowed. See NOTICE file.
See LICENSE file for complete terms.
WARNING: The contents of this file may NOT be used to train AI/LLM models. See NOTICE for legal
details.
*/
use matrix_sdk::{Client, Room, ruma::events::room::member::StrippedRoomMemberEvent};
use tokio::time::{Duration, sleep};
prelude! {}
pub async fn on_stripped_state_member(
room_member: StrippedRoomMemberEvent,
client: Client,
room: Room,
) -> Res<()> {
if room_member.state_key
!= client
.user_id()
.ok_or_else(|| anyhow!("could not retrieve client user identifier"))?
{
return Ok(());
}
tokio::spawn(async move {
info!("autojoining room {}", room.room_id());
let mut delay = 2;
let mut iter_count = 0;
while let Err(err) = room.join().await {
iter_count += 1;
// retry autojoin due to synapse sending invites, before the
// invited user can join for more information see
// https://github.com/matrix-org/synapse/issues/4345
warn!(
"failed to join room {} ({err:?}), retrying in {delay}s...",
room.room_id()
);
sleep(Duration::from_secs(delay)).await;
delay *= 2;
if delay > 3600 {
error!(
"\
still can't join room {} after {} attempt(s), giving up
latest error is {}",
room.room_id(),
iter_count,
err
);
break;
}
}
debug!("successfully joined room {}", room.room_id());
});
Ok(())
}

132
src/serve/message.rs Normal file
View file

@ -0,0 +1,132 @@
/*
Copyright © 2026 Anzenlang
Licensed under the PolyForm Noncommercial License 1.0.0
https://polyformproject.org/licenses/noncommercial/
SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
SPDX-AI-Restriction: No training allowed. See NOTICE file.
See LICENSE file for complete terms.
WARNING: The contents of this file may NOT be used to train AI/LLM models. See NOTICE for legal
details.
*/
use matrix_sdk::{
Client, Room, RoomState,
// event_handler::Ctx,
ruma::events::room::message::{MessageType, OriginalSyncRoomMessageEvent},
};
prelude! {}
/// Handle room messages.
pub async fn on_room_message(
event: OriginalSyncRoomMessageEvent,
room: Room,
client: Client,
) -> Res<()> {
// Ignore room we're not in.
if room.state() != RoomState::Joined {
debug!("ignoring room message from room I am not in");
return Ok(());
}
// Ignore messages from ourselves.
if event.sender
== client
.user_id()
.ok_or_else(|| anyhow!("failed to retrieve client user identifier"))?
{
debug!("ignoring message sent by myself");
return Ok(());
}
debug!(
"handling room message from {} through room {}",
event.sender,
room.room_id()
);
// We only want `m.text` messages
let MessageType::Text(text_content) = &event.content.msgtype else {
debug!("ignoring non-text room message");
return Ok(());
};
let mut body = text_content.body.as_str();
// If not in a private room, ignore messages that don't start with our name optionally followed
// by `<ws>:<ws>`.
if !room.is_direct().await? {
let Some(my_id) = client.user_id() else {
warn!("failed to retrieve my own identifier, ignoring message");
return Ok(());
};
if let Some(mentions) = event.content.mentions {
if !mentions.user_ids.contains(my_id) {
debug!("ignoring message in non-direct room that does not mention me");
return Ok(());
}
} else {
debug!("ignoring message in non-direct room with no mentions");
return Ok(());
}
let account = client.account();
let pref = if let Ok(Some(display_name)) = account.get_display_name().await {
display_name
} else if let Some(id) = client.user_id() {
id.to_string()
} else {
warn!(
"failed to retrieve display name AND user identifier, \
ignoring non-direct room message"
);
return Ok(());
};
// debug!("my prefix is `{}`\nmessage body:\n```\n{}\n```", pref, body);
if body.starts_with(&pref) {
info!("dropping id prefix");
body = body[pref.len()..].trim();
// debug!("updated message body:\n```\n{}\n```", body);
if body.starts_with(':') {
body = body[1..].trim();
// debug!("updated message body (final):\n```\n{}\n```", body);
}
} else {
debug!("ignoring message in non-direct room that does not start with a tag to myself");
return Ok(());
}
}
let room_name = match room
.display_name()
.await
.context("failed to get room display name")
{
Ok(room_name) => room_name.to_string(),
Err(error) => {
warn!("{}", error);
let id = room.room_id();
debug!("falling back to room ID `{}`", id);
// Let's fallback to the room ID.
id.into()
}
};
debug!(
"[{room_name}] {}:\n```txt\n{}\n```\n\nactual body:\n```txt\n{}\n```",
event.sender, text_content.body, body
);
// let room = munity::Room::new(room.room_id())?;
// let user = munity::User::new(event.sender.to_string())?;
// let event = munity::Event::new(event.event_id.to_string())?;
// let message = munity::Message::new(user.clone(), event);
// let source = munity::Thread::new(room, message);
// let msg = munity::InMsg::Com(munity::InComMsg::new(user, source, body));
// in_msg_sender.send(msg).await?;
// debug!("message successfully sent");
Ok(())
}

65
src/serve/session.rs Normal file
View file

@ -0,0 +1,65 @@
/*
Copyright © 2026 Anzenlang
Licensed under the PolyForm Noncommercial License 1.0.0
https://polyformproject.org/licenses/noncommercial/
SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
SPDX-AI-Restriction: No training allowed. See NOTICE file.
See LICENSE file for complete terms.
WARNING: The contents of this file may NOT be used to train AI/LLM models. See NOTICE for legal
details.
*/
use matrix_sdk::authentication::matrix::MatrixSession;
use tokio::fs;
prelude! {}
/// The data needed to re-build a client.
#[derive(Debug, Serialize, Deserialize)]
pub struct ClientSession {
/// The URL of the home server of the user.
pub homeserver: String,
/// The path of the database.
pub db_path: PathBuf,
/// The passphrase of the database.
pub passphrase: String,
}
/// The full session to persist.
#[derive(Debug, Serialize, Deserialize)]
pub struct FullSession {
/// The data to re-build the client.
pub client_session: ClientSession,
/// The Matrix user session.
pub user_session: MatrixSession,
/// The latest sync token.
///
/// It is only needed to persist it when using `Client::sync_once()` and we
/// want to make our syncs faster by not receiving all the initial sync
/// again.
#[serde(skip_serializing_if = "Option::is_none")]
pub sync_token: Option<String>,
}
/// Persist the sync token for a future session.
/// Note that this is needed only when using `sync_once`. Other sync methods get
/// the sync token from the store.
pub async fn persist_sync_token(sync_token: impl Into<String>) -> Res<()> {
let session_file = &conf::get().session_file();
let serialized_session = fs::read_to_string(session_file).await?;
let mut full_session: FullSession = serde_json::from_str(&serialized_session)?;
full_session.sync_token = Some(sync_token.into());
let serialized_session = serde_json::to_string(&full_session)?;
fs::write(session_file, serialized_session).await?;
Ok(())
}

168
src/serve/verification.rs Normal file
View file

@ -0,0 +1,168 @@
/*
Copyright © 2026 Anzenlang
Licensed under the PolyForm Noncommercial License 1.0.0
https://polyformproject.org/licenses/noncommercial/
SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
SPDX-AI-Restriction: No training allowed. See NOTICE file.
See LICENSE file for complete terms.
WARNING: The contents of this file may NOT be used to train AI/LLM models. See NOTICE for legal
details.
*/
use std::io::Write;
use futures_util::stream::StreamExt;
use matrix_sdk::{
encryption::verification::{
Emoji, SasState, SasVerification, Verification, VerificationRequest,
VerificationRequestState, format_emojis,
},
ruma::UserId,
};
prelude! {}
async fn wait_for_confirmation(sas: SasVerification, emoji: [Emoji; 7]) -> Res<()> {
println!("\ndo the emojis match: \n{}", format_emojis(emoji));
print!("confirm with `yes` or cancel with `no`: ");
std::io::stdout()
.flush()
.context("failed to flush stdout while asking for confirmation")?;
let mut input = String::new();
std::io::stdin()
.read_line(&mut input)
.context("unable to read user input while asking for confirmation")?;
match input.trim().to_lowercase().as_ref() {
"yes" | "true" | "ok" => sas.confirm().await.context("SAS confirmation failed"),
_ => sas.cancel().await.context("SAS cancellation failed"),
}
}
async fn print_devices(user_id: &UserId, client: &Client) -> Res<()> {
let mut acc = String::with_capacity(500);
for device in client
.encryption()
.get_user_devices(user_id)
.await?
.devices()
{
if device.device_id()
== client
.device_id()
.context("we should be logged in now and know our device id")?
{
continue;
}
acc = format!(
"{}\n- {:<10} {:<30} {:<}",
acc,
device.device_id(),
device.display_name().unwrap_or("-"),
if device.is_verified() { "" } else { "" }
);
}
acc = if acc.is_empty() { " none".into() } else { acc };
info!("Devices of user {user_id}:{acc}");
Ok(())
}
async fn sas_verification_handler(client: Client, sas: SasVerification) -> Res<()> {
debug!(
"starting verification with {} {}",
&sas.other_device().user_id(),
&sas.other_device().device_id()
);
print_devices(sas.other_device().user_id(), &client)
.await
.context("failed to print devices")?;
sas.accept().await?;
let mut stream = sas.changes();
while let Some(state) = stream.next().await {
match state {
SasState::KeysExchanged {
emojis,
decimals: _,
} => {
tokio::spawn(wait_for_confirmation(
sas.clone(),
emojis
.context("we only support verifications using emojis")?
.emojis,
));
}
SasState::Done { .. } => {
let device = sas.other_device();
debug!(
"successfully verified device {} {} {:?}",
device.user_id(),
device.device_id(),
device.local_trust_state()
);
print_devices(sas.other_device().user_id(), &client)
.await
.context("failed to print devices")?;
break;
}
SasState::Cancelled(cancel_info) => {
debug!(
"verification has been cancelled, reason: {}",
cancel_info.reason()
);
break;
}
SasState::Created { .. }
| SasState::Started { .. }
| SasState::Accepted { .. }
| SasState::Confirmed => (),
}
}
Ok(())
}
pub async fn request_verification_handler(client: Client, request: VerificationRequest) -> Res<()> {
info!(
"accepting verification request from {}",
request.other_user_id()
);
request
.accept()
.await
.context("could not accept verification request")?;
let mut stream = request.changes();
while let Some(state) = stream.next().await {
match state {
VerificationRequestState::Created { .. }
| VerificationRequestState::Requested { .. }
| VerificationRequestState::Ready { .. } => (),
VerificationRequestState::Transitioned { verification } => {
// We only support SAS verification.
if let Verification::SasV1(s) = verification {
tokio::spawn(sas_verification_handler(client, s));
break;
}
}
VerificationRequestState::Done | VerificationRequestState::Cancelled(_) => break,
}
}
Ok(())
}