initial commit
This commit is contained in:
commit
f83fded289
15 changed files with 6084 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
/target
|
||||||
4554
Cargo.lock
generated
Normal file
4554
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
22
Cargo.toml
Normal file
22
Cargo.toml
Normal 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
29
README.md
Normal 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
189
src/basic.rs
Normal 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
132
src/basic/conf.rs
Normal 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
69
src/lib.rs
Normal 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
84
src/main.rs
Normal 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
218
src/serve.rs
Normal 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
124
src/serve/auth.rs
Normal 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
228
src/serve/client.rs
Normal 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
69
src/serve/invitation.rs
Normal 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
132
src/serve/message.rs
Normal 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
65
src/serve/session.rs
Normal 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
168
src/serve/verification.rs
Normal 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(())
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue