initial commit
This commit is contained in:
commit
f83fded289
15 changed files with 6084 additions and 0 deletions
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