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