initial commit

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

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

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

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

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

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

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

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

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

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

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

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

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