Initial commit

This commit is contained in:
Slavi Pantaleev
2024-09-12 13:44:06 +03:00
commit 946aa9d9e9
220 changed files with 26033 additions and 0 deletions

419
src/bot/implementation.rs Normal file
View File

@@ -0,0 +1,419 @@
use std::sync::Arc;
use std::{future::Future, pin::Pin};
use mxlink::matrix_sdk::media::{MediaFormat, MediaRequest};
use mxlink::matrix_sdk::ruma::{
events::room::MediaSource, MilliSecondsSinceUnixEpoch, OwnedUserId,
};
use mxlink::matrix_sdk::Room;
use mxlink::{
InitConfig, LoginConfig, LoginCredentials, LoginEncryption, MatrixLink, PersistenceConfig,
};
use mxlink::helpers::account_data_config::{
ConfigError, GlobalConfigManager as AccountDataGlobalConfigManager,
RoomConfigManager as AccountDataRoomConfigManager,
};
use mxlink::helpers::encryption::Manager as EncryptionManager;
use crate::agent::Manager as AgentManager;
use crate::entity::catch_up_marker::{
CatchUpMarker, CatchUpMarkerManager, DelayedCatchUpMarkerManager,
};
use crate::entity::cfg::Config;
use crate::entity::globalconfig::{GlobalConfig, GlobalConfigurationManager};
use crate::entity::roomconfig::{RoomConfig, RoomConfigurationManager};
use crate::agent::Manager;
use crate::conversation::matrix::{RoomDisplayNameFetcher, RoomEventFetcher};
const ROOM_EVENT_FETCHER_LRU_CACHE_SIZE: usize = 1000;
const ROOM_DISPLAY_NAME_FETCHER_LRU_CACHE_SIZE: usize = 1000;
const ROOM_CONFIG_MANAGER_LRU_CACHE_SIZE: usize = 1000;
const LOGO_BYTES: &[u8] = include_bytes!("../../etc/assets/baibot-torso-768.png");
const LOGO_MIME_TYPE: &str = "image/png";
/// Controls how often we persist the catch-up marker to Account Data.
/// Consult the `DelayedCatchUpMarkerManager` documentation for more information.
const DELAYED_CATCH_UP_MARKER_MANAGER_PERSIST_INTERVAL_DURATION: std::time::Duration =
std::time::Duration::from_secs(10);
/// Controls what federation delay we will tolerate. The timestamp that gets persisted
/// will be based on the last seen event's `origin_server_ts` minus this duration.
/// Consult the `DelayedCatchUpMarkerManager` documentation for more information.
const DELAYED_CATCH_UP_MARKER_MANAGER_FEDERATION_DELAY_TOLERANCE_DURATION: std::time::Duration =
std::time::Duration::from_secs(90);
struct BotInner {
config: Config,
matrix_link: MatrixLink,
delayed_catch_up_marker_manager: DelayedCatchUpMarkerManager,
global_config_manager: tokio::sync::Mutex<GlobalConfigurationManager>,
room_config_manager: tokio::sync::Mutex<RoomConfigurationManager>,
room_event_fetcher: Arc<RoomEventFetcher>,
room_display_name_fetcher: Arc<RoomDisplayNameFetcher>,
agent_manager: Manager,
admin_pattern_regexes: Vec<regex::Regex>,
}
/// Bot represents a bot instance.
///
/// All of the state is held in an `Arc` so the `Bot` can be cloned freely.
#[derive(Clone)]
pub struct Bot {
inner: Arc<BotInner>,
}
impl Bot {
pub async fn new(config: Config) -> anyhow::Result<Self> {
// Take some potentially problematic configuration values out of the config early on.
// If we'd be failing, we'd like it to happen early, before we log in, etc.
let initial_global_config: GlobalConfig =
config.initial_global_config.clone().try_into()?;
let admin_pattern_regexes = config.access.admin_pattern_regexes()?;
let persistence_config_encryption_key = config.persistence.config_encryption_key()?;
let agent_manager = AgentManager::new(config.agents.static_definitions.clone())?;
let encryption_manager = EncryptionManager::new(persistence_config_encryption_key);
let matrix_link = create_matrix_link(&config).await?;
let catch_up_marker_manager = create_catch_up_marker_manager(matrix_link.clone());
let delayed_catch_up_marker_manager = DelayedCatchUpMarkerManager::new(
catch_up_marker_manager,
DELAYED_CATCH_UP_MARKER_MANAGER_PERSIST_INTERVAL_DURATION,
DELAYED_CATCH_UP_MARKER_MANAGER_FEDERATION_DELAY_TOLERANCE_DURATION,
);
let global_config_manager = tokio::sync::Mutex::new(create_global_configuration_manager(
matrix_link.clone(),
encryption_manager.clone(),
initial_global_config,
));
let room_config_manager = tokio::sync::Mutex::new(create_room_configuration_manager(
matrix_link.clone(),
encryption_manager.clone(),
));
let room_event_fetcher = RoomEventFetcher::new(Some(ROOM_EVENT_FETCHER_LRU_CACHE_SIZE));
let room_display_name_fetcher = RoomDisplayNameFetcher::new(
matrix_link.clone(),
Some(ROOM_DISPLAY_NAME_FETCHER_LRU_CACHE_SIZE),
);
Ok(Self {
inner: Arc::new(BotInner {
config,
matrix_link,
delayed_catch_up_marker_manager,
global_config_manager,
room_config_manager,
room_event_fetcher: Arc::new(room_event_fetcher),
room_display_name_fetcher: Arc::new(room_display_name_fetcher),
agent_manager,
admin_pattern_regexes,
}),
})
}
pub(crate) fn admin_patterns(&self) -> &Vec<String> {
&self.inner.config.access.admin_patterns
}
pub(crate) fn name(&self) -> &str {
&self.inner.config.user.name
}
pub(crate) fn command_prefix(&self) -> &str {
&self.inner.config.command_prefix
}
pub(crate) fn homeserver_name(&self) -> &str {
&self.inner.config.homeserver.server_name
}
pub(crate) fn global_config_manager(&self) -> &tokio::sync::Mutex<GlobalConfigurationManager> {
&self.inner.global_config_manager
}
pub(crate) fn room_config_manager(&self) -> &tokio::sync::Mutex<RoomConfigurationManager> {
&self.inner.room_config_manager
}
pub(crate) fn room_event_fetcher(&self) -> Arc<RoomEventFetcher> {
self.inner.room_event_fetcher.clone()
}
pub(crate) fn room_display_name_fetcher(&self) -> Arc<RoomDisplayNameFetcher> {
self.inner.room_display_name_fetcher.clone()
}
pub(crate) fn agent_manager(&self) -> &Manager {
&self.inner.agent_manager
}
pub(crate) fn matrix_link(&self) -> &MatrixLink {
&self.inner.matrix_link
}
pub(crate) fn user_id(&self) -> &OwnedUserId {
self.matrix_link().user_id()
}
pub(crate) fn reacting(&self) -> super::reacting::Reacting {
super::reacting::Reacting::new(self.clone())
}
pub(crate) fn rooms(&self) -> super::rooms::Rooms {
super::rooms::Rooms::new(self.clone())
}
pub(crate) fn messaging(&self) -> super::messaging::Messaging {
super::messaging::Messaging::new(self.clone())
}
pub(crate) fn admin_pattern_regexes(&self) -> &Vec<regex::Regex> {
&self.inner.admin_pattern_regexes
}
pub(crate) async fn global_config(&self) -> Result<GlobalConfig, ConfigError> {
let mut global_config_manager_guard = self.inner.global_config_manager.lock().await;
global_config_manager_guard.get_or_create().await
}
pub(crate) async fn is_caught_up(
&self,
event_origin_server_ts: MilliSecondsSinceUnixEpoch,
) -> Result<bool, ConfigError> {
self.inner
.delayed_catch_up_marker_manager
.is_caught_up(event_origin_server_ts.0.into())
.await
}
pub(crate) async fn catch_up(&self, event_origin_server_ts: MilliSecondsSinceUnixEpoch) {
self.inner
.delayed_catch_up_marker_manager
.catch_up(event_origin_server_ts.0.into())
.await
}
pub async fn start(&self) -> anyhow::Result<()> {
self.rooms().attach_event_handlers().await;
self.messaging().attach_event_handlers().await;
self.reacting().attach_event_handlers().await;
self.inner.delayed_catch_up_marker_manager.start().await;
self.prepare_profile().await?;
self.inner
.matrix_link
.start()
.await
.map_err(|e| anyhow::anyhow!("Failed to sync: {:?}", e))
}
async fn prepare_profile(&self) -> anyhow::Result<()> {
use std::time::Duration;
use tokio::time::sleep;
let mut delay = Duration::from_secs(3);
let max_delay = Duration::from_secs(30);
loop {
match self.do_prepare_profile().await {
Ok(_) => return Ok(()),
Err(err) => {
tracing::warn!(
?err,
?delay,
"Failed to prepare profile.. Will retry after delay..."
);
sleep(delay).await;
delay = std::cmp::min(delay * 2, max_delay);
}
}
}
}
async fn do_prepare_profile(&self) -> anyhow::Result<()> {
tracing::debug!("Preparing profile..");
let account = self.inner.matrix_link.client().account();
let media = self.inner.matrix_link.client().media();
let desired_display_name = self.inner.config.user.name.clone();
let profile = account
.get_profile()
.await
.map_err(|e| anyhow::anyhow!("Failed fetching profile: {:?}", e))?;
let should_update_display_name = match &profile.displayname {
Some(displayname) => displayname != &desired_display_name,
None => true,
};
if should_update_display_name {
tracing::info!(
?profile.displayname,
?desired_display_name,
"Updating display name.."
);
if let Err(err) = account.set_display_name(Some(&desired_display_name)).await {
return Err(anyhow::anyhow!("Failed setting display name: {:?}", err));
}
}
let should_update_avatar = match &profile.avatar_url {
Some(avatar_url) => {
let request = MediaRequest {
source: MediaSource::Plain(avatar_url.to_owned()),
format: MediaFormat::File,
};
let content = media
.get_media_content(&request, true)
.await
.map_err(|e| anyhow::anyhow!("Failed fetching existing avatar: {:?}", e))?;
content.as_slice() != LOGO_BYTES
}
None => true,
};
if should_update_avatar {
tracing::info!("Updating avatar..");
let mime_type = LOGO_MIME_TYPE
.parse()
.expect("Failed parsing mime type for logo");
account
.upload_avatar(&mime_type, LOGO_BYTES.to_vec())
.await
.map_err(|e| anyhow::anyhow!("Failed uploading avatar: {:?}", e))?;
}
Ok(())
}
}
async fn create_matrix_link(config: &Config) -> anyhow::Result<MatrixLink> {
let session_file_path = config.persistence.session_file_path()?;
let session_encryption_key = config.persistence.session_encryption_key()?;
let db_dir_path: std::path::PathBuf = config.persistence.db_dir_path()?;
let login_creds = LoginCredentials::UserPassword(
config.user.mxid_localpart.to_owned(),
config.user.password.to_owned(),
);
let login_encryption = LoginEncryption::new(
config.user.encryption.recovery_passphrase.clone(),
config.user.encryption.recovery_reset_allowed,
);
let login_config = LoginConfig::new(
config.homeserver.url.to_owned(),
login_creds,
Some(login_encryption),
config.user.name.to_owned(),
);
let persistence_config =
PersistenceConfig::new(session_file_path, session_encryption_key, db_dir_path);
let init_config = InitConfig::new(login_config, persistence_config);
mxlink::init(&init_config).await.map_err(|e| e.into())
}
pub fn create_global_configuration_manager(
matrix_link: MatrixLink,
encryption_manager: EncryptionManager,
initial_global_config: GlobalConfig,
) -> GlobalConfigurationManager {
let initial_global_config_callback = move || {
let initial_global_config = initial_global_config.clone();
let future = create_initial_global_config(initial_global_config);
// Explicitly box the future to match the expected type
Box::pin(future) as Pin<Box<dyn Future<Output = GlobalConfig> + Send>>
};
AccountDataGlobalConfigManager::new(
matrix_link,
encryption_manager,
initial_global_config_callback,
)
}
async fn create_initial_global_config(initial_global_config: GlobalConfig) -> GlobalConfig {
initial_global_config
}
pub fn create_room_configuration_manager(
matrix_link: MatrixLink,
encryption_manager: EncryptionManager,
) -> RoomConfigurationManager {
let initial_room_config_callback = |room: Room| {
let future = create_initial_room_config(room);
// Explicitly box the future to match the expected type
Box::pin(future) as Pin<Box<dyn Future<Output = RoomConfig> + Send>>
};
AccountDataRoomConfigManager::new(
matrix_link.user_id().clone(),
encryption_manager,
initial_room_config_callback,
Some(ROOM_CONFIG_MANAGER_LRU_CACHE_SIZE),
)
}
async fn create_initial_room_config(room: Room) -> RoomConfig {
RoomConfig::default().with_room(room).await
}
pub fn create_catch_up_marker_manager(matrix_link: MatrixLink) -> CatchUpMarkerManager {
let initial_global_config_callback = || {
let future = create_initial_catch_up_marker();
// Explicitly box the future to match the expected type
Box::pin(future) as Pin<Box<dyn Future<Output = CatchUpMarker> + Send>>
};
// Intentionally not using encryption, to make this resilient even if we lose our encryption key.
// We're not worried about the catch-up marker being read or tampered with, as it's not sensitive data.
let encryption_manager = EncryptionManager::new(None);
let catch_up_marker_manager: CatchUpMarkerManager = AccountDataGlobalConfigManager::new(
matrix_link.clone(),
encryption_manager,
initial_global_config_callback,
);
catch_up_marker_manager
}
async fn create_initial_catch_up_marker() -> CatchUpMarker {
CatchUpMarker::new(0)
}

110
src/bot/load_config.rs Normal file
View File

@@ -0,0 +1,110 @@
use std::env;
use std::path::PathBuf;
use anyhow::anyhow;
use crate::agent::AgentPurpose;
pub use crate::entity::cfg::{defaults as cfg_defaults, env as cfg_env, Config};
pub fn load() -> anyhow::Result<Config> {
let config_file_path = env::var(cfg_env::BAIBOT_CONFIG_FILE_PATH)
.unwrap_or_else(|_| cfg_defaults::config_file_path().to_owned());
let config_file_path = PathBuf::from(config_file_path);
if !config_file_path.exists() {
return Err(anyhow!(
"Config file ({}) not found. Adjust the {} environment variable to use another config file.",
config_file_path.display(),
cfg_env::BAIBOT_CONFIG_FILE_PATH,
));
}
let config_str = std::fs::read_to_string(config_file_path)?;
let mut config: Config = serde_yaml::from_str(&config_str)?;
// Allow environment variables to override some configuration keys
for (key, value) in env::vars() {
match key.as_str() {
cfg_env::BAIBOT_HOMESERVER_SERVER_NAME => config.homeserver.server_name = value,
cfg_env::BAIBOT_HOMESERVER_URL => config.homeserver.url = value,
cfg_env::BAIBOT_USER_MXID_LOCALPART => config.user.mxid_localpart = value,
cfg_env::BAIBOT_USER_PASSWORD => config.user.password = value,
cfg_env::BAIBOT_USER_ENCRYPTION_RECOVERY_PASSPHRASE => {
config.user.encryption.recovery_passphrase = Some(value);
}
cfg_env::BAIBOT_USER_NAME => config.user.name = value,
cfg_env::BAIBOT_COMMAND_PREFIX => config.command_prefix = value,
cfg_env::BAIBOT_LOGGING => {
config.logging = value;
}
cfg_env::BAIBOT_ACCESS_ADMIN_PATTERNS => {
config.access.admin_patterns = value
.split(' ')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
}
cfg_env::BAIBOT_PERSISTENCE_DATA_DIR_PATH => {
config.persistence.data_dir_path = Some(value);
}
cfg_env::BAIBOT_PERSISTENCE_CONFIG_ENCRYPTION_KEY => {
config.persistence.config_encryption_key = Some(value);
}
cfg_env::BAIBOT_INITIAL_GLOBAL_CONFIG_HANDLER_CATCH_ALL => {
let value = if value.is_empty() { None } else { Some(value) };
config
.initial_global_config
.handler
.set_by_purpose(AgentPurpose::CatchAll, value);
}
cfg_env::BAIBOT_INITIAL_GLOBAL_CONFIG_HANDLER_TEXT_GENERATION => {
let value = if value.is_empty() { None } else { Some(value) };
config
.initial_global_config
.handler
.set_by_purpose(AgentPurpose::TextGeneration, value);
}
cfg_env::BAIBOT_INITIAL_GLOBAL_CONFIG_HANDLER_TEXT_TO_SPEECH => {
let value = if value.is_empty() { None } else { Some(value) };
config
.initial_global_config
.handler
.set_by_purpose(AgentPurpose::TextToSpeech, value);
}
cfg_env::BAIBOT_INITIAL_GLOBAL_CONFIG_HANDLER_SPEECH_TO_TEXT => {
let value = if value.is_empty() { None } else { Some(value) };
config
.initial_global_config
.handler
.set_by_purpose(AgentPurpose::SpeechToText, value);
}
cfg_env::BAIBOT_INITIAL_GLOBAL_CONFIG_HANDLER_IMAGE_GENERATION => {
let value = if value.is_empty() { None } else { Some(value) };
config
.initial_global_config
.handler
.set_by_purpose(AgentPurpose::ImageGeneration, value);
}
cfg_env::BAIBOT_INITIAL_GLOBAL_CONFIG_USER_PATTERNS => {
config.initial_global_config.user_patterns = Some(
value
.split(' ')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect(),
);
}
_ => {}
}
}
config.validate().map_err(|s| anyhow!(s))?;
Ok(config)
}

334
src/bot/messaging.rs Normal file
View File

@@ -0,0 +1,334 @@
use mxlink::matrix_sdk::{
ruma::{
api::client::receipt::create_receipt::v3::ReceiptType,
events::room::message::OriginalSyncRoomMessageEvent, OwnedEventId,
},
Room,
};
use mxlink::{CallbackError, MessageResponseType};
use tracing::Instrument;
use crate::{
conversation::matrix::determine_thread_context_for_room_event,
entity::{MessageContext, MessagePayload, RoomConfigContext, TriggerEventInfo},
};
#[derive(Clone)]
pub struct Messaging {
bot: super::Bot,
}
impl Messaging {
pub fn new(bot: super::Bot) -> Self {
Self { bot }
}
pub async fn send_text_markdown_no_fail(
&self,
room: &Room,
message: String,
response_type: MessageResponseType,
) -> Option<mxlink::matrix_sdk::ruma::api::client::message::send_message_event::v3::Response>
{
let result = self
.bot
.matrix_link()
.messaging()
.send_text_markdown(room, message, response_type)
.await;
match result {
Ok(result) => Some(result),
Err(err) => {
tracing::error!(
room_id = format!("{:?}", room.room_id()),
?err,
"Failed to send text message to room",
);
None
}
}
}
pub async fn send_notice_markdown_no_fail(
&self,
room: &Room,
message: String,
response_type: MessageResponseType,
) -> Option<mxlink::matrix_sdk::ruma::api::client::message::send_message_event::v3::Response>
{
let result = self
.bot
.matrix_link()
.messaging()
.send_notice_markdown(room, message, response_type)
.await;
match result {
Ok(result) => Some(result),
Err(err) => {
tracing::error!(
room_id = format!("{:?}", room.room_id()),
?err,
"Failed to send notice message to room",
);
None
}
}
}
pub async fn send_tooltip_markdown_no_fail(
&self,
room: &Room,
message: &str,
response_type: MessageResponseType,
) -> Option<mxlink::matrix_sdk::ruma::api::client::message::send_message_event::v3::Response>
{
self.send_notice_markdown_no_fail(
room,
crate::utils::status::create_tooltip_message_text(message),
response_type,
)
.await
}
pub async fn send_success_markdown_no_fail(
&self,
room: &Room,
message: &str,
response_type: MessageResponseType,
) -> Option<mxlink::matrix_sdk::ruma::api::client::message::send_message_event::v3::Response>
{
self.send_notice_markdown_no_fail(
room,
crate::utils::status::create_success_message_text(message),
response_type,
)
.await
}
pub async fn send_error_markdown_no_fail(
&self,
room: &Room,
err: &str,
response_type: MessageResponseType,
) -> Option<mxlink::matrix_sdk::ruma::api::client::message::send_message_event::v3::Response>
{
self.send_notice_markdown_no_fail(
room,
crate::utils::status::create_error_message_text(err),
response_type,
)
.await
}
pub async fn redact_event_no_fail(
&self,
room: &Room,
target_event_id: OwnedEventId,
reason: Option<String>,
) -> Option<mxlink::matrix_sdk::ruma::api::client::redact::redact_event::v3::Response> {
let result = self
.bot
.matrix_link()
.messaging()
.redact_event(room, target_event_id.clone(), reason)
.await;
match result {
Ok(result) => Some(result),
Err(err) => {
tracing::error!(
room_id = format!("{:?}", room.room_id()),
?target_event_id,
?err,
"Failed to send redaction to room",
);
None
}
}
}
pub(super) async fn attach_event_handlers(&self) {
let matrix_link_messaging = self.bot.matrix_link().messaging();
let this = self.clone();
matrix_link_messaging.on_actionable_room_message(|event, room| async move {
this.on_actionable_message(event, room).await
});
}
#[tracing::instrument(name = "bot_on_actionable_message", skip_all, fields(room_id = room.room_id().as_str(), event_id = event.event_id.as_str()))]
async fn on_actionable_message(
&self,
event: OriginalSyncRoomMessageEvent,
room: Room,
) -> Result<(), CallbackError> {
if self
.bot
.is_caught_up(event.origin_server_ts)
.await
.map_err(|e| {
CallbackError::Unknown(
format!("Failed to determine catch-up state: {:?}", e).into(),
)
})?
{
tracing::debug!(
event_origin_server_ts = format!("{:?}", event.origin_server_ts),
"Ignoring old message event",
);
return Ok(());
}
tracing::info!("Processing message");
let global_config = self
.bot
.global_config()
.await
.map_err(|err| CallbackError::Unknown(err.into()))?;
tracing::trace!(?global_config, "Global config");
let room_config = self
.bot
.room_config_manager()
.lock()
.await
.get_or_create_for_room(&room)
.await
.map_err(|err| CallbackError::Unknown(err.into()))?;
tracing::trace!(?room_config, "Room config");
let trigger_event_sender_is_admin = mxidwc::match_user_id(
event.sender.clone().as_str(),
self.bot.admin_pattern_regexes(),
);
let trigger_event_sender_is_allowed_user = match &global_config.access.user_patterns {
Some(user_patterns) => {
let allowed_user_regexes = mxidwc::parse_patterns_vector(user_patterns)
.map_err(|err| CallbackError::Unknown(err.into()))?;
mxidwc::match_user_id(event.sender.clone().as_str(), &allowed_user_regexes)
}
None => false,
};
if !trigger_event_sender_is_admin && !trigger_event_sender_is_allowed_user {
tracing::debug!("Ignoring message from non-admin/non-allowed user");
return Ok(());
}
let payload: Result<MessagePayload, String> = event.content.msgtype.clone().try_into();
let payload = match payload {
Ok(payload) => payload,
Err(err) => {
tracing::debug!(
msg_type = event.content.msgtype(),
?err,
"Ignoring message not supported by us",
);
return Ok(());
}
};
let thread_context = determine_thread_context_for_room_event(
self.bot.user_id(),
&room,
&event,
&payload,
&self.bot.room_event_fetcher(),
)
.await;
let thread_context = match thread_context {
Ok(value) => value,
Err(err) => {
tracing::error!(?err, "Failed to determine thread context for event");
return Ok(());
}
};
let Some(thread_context) = thread_context else {
tracing::debug!("Ignoring message with unknown thread context (likely not a threaded message or a top-level message)");
return Ok(());
};
let room_config_context =
RoomConfigContext::new(global_config.clone(), room_config.clone());
let trigger_event_info = TriggerEventInfo::new(
event.event_id.clone(),
event.sender.clone(),
payload,
trigger_event_sender_is_admin,
);
let message_context = MessageContext::new(
room.clone(),
room_config_context,
self.bot.admin_pattern_regexes().clone(),
trigger_event_info,
thread_context.info.clone(),
);
let bot_display_name = self
.bot
.room_display_name_fetcher()
.own_display_name_in_room(message_context.room())
.await;
let bot_display_name = match bot_display_name {
Ok(value) => value,
Err(err) => {
tracing::warn!(
?err,
"Failed to fetch bot display name. Proceeding without it"
);
None
}
};
// The first event in the thread determines which handler processes the current event.
let controller_type = crate::controller::determine_controller(
self.bot.command_prefix(),
&thread_context.first_message,
&message_context,
self.bot.user_id(),
&bot_display_name,
);
tracing::info!(?controller_type, "Determined controller");
let _ = room
.send_single_receipt(
ReceiptType::Read,
thread_context.info.clone().into(),
event.event_id.clone(),
)
.await;
let start_time = std::time::Instant::now();
let event_span = tracing::error_span!("message_controller", ?controller_type);
crate::controller::dispatch_controller(&controller_type, &message_context, &self.bot)
.instrument(event_span)
.await;
let duration = std::time::Instant::now().duration_since(start_time);
tracing::debug!(?duration, "Controller finished");
self.bot.catch_up(event.origin_server_ts).await;
return Ok(());
}
}

8
src/bot/mod.rs Normal file
View File

@@ -0,0 +1,8 @@
mod implementation;
mod load_config;
mod messaging;
mod reacting;
mod rooms;
pub use implementation::Bot;
pub use load_config::load as load_config;

262
src/bot/reacting.rs Normal file
View File

@@ -0,0 +1,262 @@
use mxlink::matrix_sdk::{
ruma::{
events::{
room::message::Relation, AnyMessageLikeEvent, AnySyncTimelineEvent, AnyTimelineEvent,
MessageLikeEvent,
},
OwnedEventId, OwnedUserId,
},
Room,
};
use mxlink::CallbackError;
use mxlink::ThreadInfo;
use tracing::Instrument;
use crate::entity::{MessageContext, MessagePayload, RoomConfigContext, TriggerEventInfo};
#[derive(Clone)]
pub struct Reacting {
bot: super::Bot,
}
impl Reacting {
pub fn new(bot: super::Bot) -> Self {
Self { bot }
}
pub async fn react_no_fail(
&self,
room: &Room,
target_event_id: OwnedEventId,
reaction_key: String,
) -> Option<mxlink::matrix_sdk::ruma::api::client::message::send_message_event::v3::Response>
{
let result = self
.bot
.matrix_link()
.reacting()
.react(room, target_event_id.clone(), reaction_key)
.await;
match result {
Ok(result) => Some(result),
Err(err) => {
tracing::error!(
"Failed to send reaction to {} in room {:?}: {:?}",
target_event_id,
room.room_id(),
err
);
None
}
}
}
pub(super) async fn attach_event_handlers(&self) {
let matrix_link_reacting = self.bot.matrix_link().reacting();
let this = self.clone();
matrix_link_reacting.on_actionable_reaction(
|event, room, reaction_event_content| async move {
this.on_actionable_reaction(event, room, reaction_event_content)
.await
},
);
}
#[tracing::instrument(name = "bot_on_actionable_reaction", skip_all, fields(room_id = room.room_id().as_str(), event_id = event.event_id().as_str()))]
async fn on_actionable_reaction(
&self,
event: AnySyncTimelineEvent,
room: Room,
reaction_event_content: mxlink::matrix_sdk::ruma::events::reaction::ReactionEventContent,
) -> Result<(), CallbackError> {
if self
.bot
.is_caught_up(event.origin_server_ts())
.await
.map_err(|e| {
CallbackError::Unknown(
format!("Failed to determine catch-up state: {:?}", e).into(),
)
})?
{
tracing::debug!(
event_origin_server_ts = format!("{:?}", event.origin_server_ts()),
"Ignoring old reaction event",
);
return Ok(());
}
tracing::info!("Handling reaction");
let global_config = self
.bot
.global_config()
.await
.map_err(|err| CallbackError::Unknown(err.into()))?;
tracing::trace!(?global_config, "Global config");
let trigger_event_sender_is_admin =
mxidwc::match_user_id(event.sender().as_str(), self.bot.admin_pattern_regexes());
let trigger_event_sender_is_allowed_user = match &global_config.access.user_patterns {
Some(user_patterns) => {
let allowed_user_regexes = mxidwc::parse_patterns_vector(user_patterns)
.map_err(|err| CallbackError::Unknown(err.into()))?;
mxidwc::match_user_id(event.sender().as_str(), &allowed_user_regexes)
}
None => false,
};
if !trigger_event_sender_is_admin && !trigger_event_sender_is_allowed_user {
tracing::debug!("Ignoring reaction from non-admin/non-allowed user");
return Ok(());
}
let reacted_to_event_id = &reaction_event_content.relates_to.event_id;
let reacted_to_event = self
.bot
.room_event_fetcher()
.fetch_event_in_room(reacted_to_event_id, &room)
.await;
let reacted_to_event = match reacted_to_event {
Ok(value) => value,
Err(err) => {
tracing::error!(
?reacted_to_event_id,
?err,
"Failed to fetch reacted-to event",
);
return Ok(());
}
};
let reacted_to_event_any_timeline_event = match reacted_to_event.event.deserialize() {
Ok(value) => value,
Err(err) => {
tracing::error!(
?reacted_to_event_id,
?err,
"Failed to deserialize reacted-to event event",
);
return Ok(());
}
};
let reacted_to_event_sender_id: OwnedUserId =
reacted_to_event_any_timeline_event.sender().to_owned();
let AnyTimelineEvent::MessageLike(reacted_to_event_message_like) =
reacted_to_event_any_timeline_event
else {
tracing::debug!(
?reacted_to_event_id,
"Ignoring non-MessageLike reacted-to event",
);
return Ok(());
};
let AnyMessageLikeEvent::RoomMessage(reacted_to_event_room_message) =
reacted_to_event_message_like
else {
tracing::debug!(
?reacted_to_event_id,
"Ignoring non-RoomMessage reacted-to event",
);
return Ok(());
};
let MessageLikeEvent::Original(reacted_to_event_room_message_original) =
reacted_to_event_room_message
else {
tracing::debug!(?reacted_to_event_id, "Ignoring redacted reacted-to event",);
return Ok(());
};
let reacted_to_event_payload: Result<MessagePayload, String> =
reacted_to_event_room_message_original
.content
.msgtype
.clone()
.try_into();
let Ok(reacted_to_event_payload) = reacted_to_event_payload else {
tracing::debug!(
msg_type = reacted_to_event_room_message_original.content.msgtype(),
"Ignoring reaction to message of unknown type",
);
return Ok(());
};
let thread_root_event_id = match reacted_to_event_room_message_original.content.relates_to {
Some(relation) => {
if let Relation::Thread(thread_id) = relation {
thread_id.event_id.clone()
} else {
reacted_to_event_id.clone()
}
}
None => reacted_to_event_id.clone(),
};
let thread_info = ThreadInfo::new(thread_root_event_id, reacted_to_event_id.clone());
let room_config = self
.bot
.room_config_manager()
.lock()
.await
.get_or_create_for_room(&room)
.await
.map_err(|err| CallbackError::Unknown(err.into()))?;
tracing::trace!(?room_config, "Room config");
let room_config_context =
RoomConfigContext::new(global_config.clone(), room_config.clone());
let trigger_event_info = TriggerEventInfo::new(
event.event_id().to_owned(),
event.sender().to_owned(),
MessagePayload::Reaction {
key: reaction_event_content.relates_to.key,
reacted_to_event_payload: Box::new(reacted_to_event_payload),
reacted_to_event_id: reaction_event_content.relates_to.event_id.clone(),
reacted_to_event_sender_id,
},
trigger_event_sender_is_admin,
);
let message_context = MessageContext::new(
room,
room_config_context,
self.bot.admin_pattern_regexes().clone(),
trigger_event_info,
thread_info,
);
tracing::info!("Handling reaction via reaction controller");
let event_span = tracing::error_span!("reaction_controller");
crate::controller::reaction::handle(
&self.bot,
self.bot.matrix_link().clone(),
&message_context,
)
.instrument(event_span)
.await
.map_err(|err| CallbackError::Unknown(err.into()))?;
self.bot.catch_up(event.origin_server_ts()).await;
Ok(())
}
}

145
src/bot/rooms.rs Normal file
View File

@@ -0,0 +1,145 @@
use mxlink::{
matrix_sdk::{
ruma::events::{room::member::StrippedRoomMemberEvent, AnySyncTimelineEvent},
Room,
},
InvitationDecision,
};
use mxlink::CallbackError;
use tracing::Instrument;
use crate::entity::RoomConfigContext;
#[derive(Clone)]
pub struct Rooms {
bot: super::Bot,
}
impl Rooms {
pub fn new(bot: super::Bot) -> Self {
Self { bot }
}
pub(super) async fn attach_event_handlers(&self) {
let matrix_link_rooms = self.bot.matrix_link().rooms();
let this = self.clone();
matrix_link_rooms.on_being_last_member(|event, room| async move {
this.on_being_last_member(event, room).await
});
let this = self.clone();
matrix_link_rooms
.on_invitation(|event, room| async move { this.on_invitation(event, room).await });
let this = self.clone();
matrix_link_rooms.on_joined(|event, room| async move { this.on_joined(event, room).await });
}
async fn on_invitation(
&self,
room_member: StrippedRoomMemberEvent,
_room: Room,
) -> Result<InvitationDecision, CallbackError> {
tracing::debug!("Deciding on room invitation");
let global_config = self
.bot
.global_config()
.await
.map_err(|e| CallbackError::Unknown(e.into()))?;
let sender_is_admin = mxidwc::match_user_id(
room_member.sender.clone().as_str(),
self.bot.admin_pattern_regexes(),
);
let sender_is_allowed_user = match &global_config.access.user_patterns {
Some(user_patterns) => {
let allowed_user_regexes = mxidwc::parse_patterns_vector(user_patterns)
.map_err(|e| CallbackError::Unknown(e.into()))?;
mxidwc::match_user_id(room_member.sender.clone().as_str(), &allowed_user_regexes)
}
None => false,
};
if !(sender_is_admin || sender_is_allowed_user) {
return Ok(InvitationDecision::Reject);
}
Ok(InvitationDecision::Join)
}
#[tracing::instrument(name = "bot_on_joined", skip_all, fields(room_id = room.room_id().as_str(), event_id = event.event_id().as_str()))]
async fn on_joined(
&self,
event: AnySyncTimelineEvent,
room: Room,
) -> Result<(), CallbackError> {
if self
.bot
.is_caught_up(event.origin_server_ts())
.await
.map_err(|e| {
CallbackError::Unknown(
format!("Failed to determine catch-up state: {:?}", e).into(),
)
})?
{
tracing::debug!(
event_origin_server_ts = format!("{:?}", event.origin_server_ts()),
"Ignoring old room join event",
);
return Ok(());
}
tracing::info!("Handling room join");
let global_config = self
.bot
.global_config()
.await
.map_err(|e| CallbackError::Unknown(e.into()))?;
let room_config_manager = self.bot.room_config_manager().lock().await;
// We force-create a new config when we join anew to ensure we:
// - always start from a known clean state
// - record the last join timestamp, so we can accurately service the room (ignoring past messages, etc.)
let room_config = room_config_manager
.create_new_for_room(&room)
.await
.map_err(|e| CallbackError::Unknown(e.into()))?;
let room_config_context = RoomConfigContext::new(global_config, room_config);
let event_span = tracing::error_span!("join_controller");
let result = crate::controller::join::handle(&self.bot, &room, &room_config_context)
.instrument(event_span)
.await
.map_err(|e| CallbackError::Unknown(e.into()));
self.bot.catch_up(event.origin_server_ts()).await;
result
}
async fn on_being_last_member(
&self,
_event: AnySyncTimelineEvent,
room: mxlink::matrix_sdk::Room,
) -> Result<(), CallbackError> {
tracing::info!(
"Leaving room {} because we are the last member",
room.room_id()
);
// We are last in this room. Let's just leave
room.leave().await.map_err(|e| e.into())
}
}