Initial commit
This commit is contained in:
181
src/entity/catch_up_marker/delayed_catch_up_marker_manager.rs
Normal file
181
src/entity/catch_up_marker/delayed_catch_up_marker_manager.rs
Normal file
@@ -0,0 +1,181 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::time::Duration;
|
||||
|
||||
use mxlink::helpers::account_data_config::ConfigError;
|
||||
|
||||
use super::CatchUpMarkerManager;
|
||||
|
||||
/// A service that records roughly until when we're caught up on processing events.
|
||||
/// Roughly, because we account for potential federation delay and we don't persist the marker too often.
|
||||
///
|
||||
/// If the matrix-sdk's state-store is kept intact, we (usually) won't be given the same event twice.
|
||||
/// In such a happy path, we don't need to keep track of anything and there's no problem.
|
||||
///
|
||||
/// If the state-store is lost (a very rare, but possible event), we can recover our encryption keys, etc.,
|
||||
/// but the Matrix SDK would try to feed us the same events again.
|
||||
/// Responding to many old events again is annoying to users and can be a huge waste of resources.
|
||||
///
|
||||
/// In order to handle state-store-loss better, we need to record until when we're caught up in storage that won't get lost (such as account data for the user).
|
||||
/// Because state-store-loss is a very rare event, we don't need to be very exact about the specific timestamp we're caught up to.
|
||||
/// In fact, being behind is necessary, to allow for federation delay (see `federation_delay_tolerance_duration`).
|
||||
pub struct DelayedCatchUpMarkerManager {
|
||||
catch_up_marker_manager: Arc<Mutex<CatchUpMarkerManager>>,
|
||||
|
||||
/// `persist_interval_duration` affects how often we persist the catch-up marker to Account Data
|
||||
/// A too small value means there's needless overhead.
|
||||
/// The downside to a larger interval value (and a larger federation delay tolerance value) is that that a state-store loss will mean that
|
||||
/// we will reprocess some of the same events.
|
||||
/// Since this is a very rare event and the downside is not so bad, a large value is recommended.
|
||||
persist_interval_duration: Duration,
|
||||
|
||||
/// `federation_delay_tolerance_duration` affects what federation delay we will tolerate.
|
||||
/// A larger delay than this may mean we ignore events that are actually new to us.
|
||||
/// This is necessary because the timestamp given to us (see `catch_up()`) is based on the "origin server" timestamp.
|
||||
/// If federation is slow, we may actually receive old events later on - they'd still be new to us,
|
||||
/// but we may ignore them if we've marked this "origin server timestamp" value as "caught up".
|
||||
federation_delay_tolerance_duration: Duration,
|
||||
|
||||
/// Holds the timestamp to use for updating the catch-up marker's `caught_up_until_event_origin_server_ts_millis`.
|
||||
/// A value of `0` is used to indicate that no update is scheduled and the next iteration should skip updating the marker.
|
||||
next_catch_up_marker_event_origin_server_ts_millis: Arc<tokio::sync::Mutex<i64>>,
|
||||
}
|
||||
|
||||
impl DelayedCatchUpMarkerManager {
|
||||
pub fn new(
|
||||
catch_up_marker_manager: CatchUpMarkerManager,
|
||||
persist_interval_duration: Duration,
|
||||
federation_delay_tolerance_duration: Duration,
|
||||
) -> Self {
|
||||
let next_catch_up_marker_event_origin_server_ts_millis =
|
||||
Arc::new(tokio::sync::Mutex::new(0));
|
||||
|
||||
let catch_up_marker_manager = Arc::new(Mutex::new(catch_up_marker_manager));
|
||||
|
||||
Self {
|
||||
catch_up_marker_manager,
|
||||
persist_interval_duration,
|
||||
federation_delay_tolerance_duration,
|
||||
|
||||
next_catch_up_marker_event_origin_server_ts_millis,
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(name = "catch_up", skip(self))]
|
||||
pub async fn catch_up(&self, event_origin_server_ts_millis: i64) {
|
||||
tracing::trace!("Locking to catch-up..");
|
||||
|
||||
let mut next_catch_up_marker_event_origin_server_ts_millis_guard = self
|
||||
.next_catch_up_marker_event_origin_server_ts_millis
|
||||
.lock()
|
||||
.await;
|
||||
|
||||
if *next_catch_up_marker_event_origin_server_ts_millis_guard > event_origin_server_ts_millis
|
||||
{
|
||||
tracing::trace!(
|
||||
?next_catch_up_marker_event_origin_server_ts_millis_guard,
|
||||
"Already have a more recent timestamp scheduled",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
*next_catch_up_marker_event_origin_server_ts_millis_guard = event_origin_server_ts_millis;
|
||||
|
||||
tracing::info!("Configured catch-up timestamp for the next update");
|
||||
}
|
||||
|
||||
/// Tells if we're caught up until the given timestamp.
|
||||
///
|
||||
/// This intentionally uses the latest (cached) data stored in catch_up_marker_manager (Account Data), not the `next_catch_up_marker_event_origin_server_ts_millis` value.
|
||||
/// `next_catch_up_marker_event_origin_server_ts_millis` is used for scheduling the next update only.
|
||||
/// The actual timestamp that will get persisted durign the update will actually be adjusted by `federation_delay_tolerance_duration`,
|
||||
/// so comparing against `next_catch_up_marker_event_origin_server_ts_millis` in its raw form would be incorrect.
|
||||
#[tracing::instrument(name = "is_caught_up", skip(self))]
|
||||
pub(crate) async fn is_caught_up(
|
||||
&self,
|
||||
event_origin_ts_millis: i64,
|
||||
) -> Result<bool, ConfigError> {
|
||||
tracing::trace!("Locking to check if caught up..");
|
||||
|
||||
let mut manager = self.catch_up_marker_manager.lock().await;
|
||||
|
||||
let marker = manager.get_or_create().await?;
|
||||
|
||||
let is_caught_up =
|
||||
marker.caught_up_until_event_origin_server_ts_millis >= event_origin_ts_millis;
|
||||
|
||||
tracing::debug!(
|
||||
?is_caught_up,
|
||||
?marker.caught_up_until_event_origin_server_ts_millis,
|
||||
"Determined caught-up status"
|
||||
);
|
||||
|
||||
Ok(is_caught_up)
|
||||
}
|
||||
|
||||
pub async fn start(&self) {
|
||||
let inner = Arc::clone(&self.catch_up_marker_manager);
|
||||
let persist_interval_duration = self.persist_interval_duration;
|
||||
let federation_delay_tolerance = self.federation_delay_tolerance_duration;
|
||||
let next_catch_up_marker_event_origin_server_ts_millis =
|
||||
Arc::clone(&self.next_catch_up_marker_event_origin_server_ts_millis);
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(persist_interval_duration);
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
tracing::trace!("Catch-up manager doing work..");
|
||||
|
||||
let mut next_catch_up_marker_event_origin_server_ts_millis_guard =
|
||||
next_catch_up_marker_event_origin_server_ts_millis
|
||||
.lock()
|
||||
.await;
|
||||
|
||||
if *next_catch_up_marker_event_origin_server_ts_millis_guard == 0 {
|
||||
tracing::trace!("No scheduled updates to the catch-up marker");
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut manager = inner.lock().await;
|
||||
|
||||
let marker = manager.get_or_create().await;
|
||||
let mut marker = match marker {
|
||||
Ok(marker) => marker,
|
||||
Err(err) => {
|
||||
tracing::error!(?err, "Failed to get or create catch-up marker");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// To allow for some federation delay (specified in federation_delay_tolerance),
|
||||
// we adjust the value we'll actually persist with that delay duration.
|
||||
// For more information, see the documentation for `Self`.
|
||||
let caught_up_until_event_origin_server_ts_millis =
|
||||
*next_catch_up_marker_event_origin_server_ts_millis_guard
|
||||
- (federation_delay_tolerance.as_millis() as i64);
|
||||
|
||||
marker.caught_up_until_event_origin_server_ts_millis =
|
||||
caught_up_until_event_origin_server_ts_millis;
|
||||
|
||||
tracing::debug!(
|
||||
?caught_up_until_event_origin_server_ts_millis,
|
||||
next_catch_up_marker_event_origin_server_ts_millis = format!(
|
||||
"{:?}",
|
||||
next_catch_up_marker_event_origin_server_ts_millis_guard
|
||||
),
|
||||
"Updating catch-up marker..",
|
||||
);
|
||||
|
||||
let result = manager.persist(&marker).await;
|
||||
if let Err(err) = result {
|
||||
tracing::error!(?err, "Failed to persist catch-up marker");
|
||||
}
|
||||
|
||||
*next_catch_up_marker_event_origin_server_ts_millis_guard = 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
37
src/entity/catch_up_marker/entity.rs
Normal file
37
src/entity/catch_up_marker/entity.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
use mxlink::matrix_sdk::ruma::events::macros::EventContent;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use mxlink::helpers::account_data_config::GlobalConfig;
|
||||
use mxlink::helpers::account_data_config::GlobalConfigCarrierContent;
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize, EventContent)]
|
||||
#[ruma_event(type = "cc.etke.baibot.catch_up_marker", kind = GlobalAccountData)]
|
||||
pub struct CatchUpMarkerCarrierContent {
|
||||
pub payload: String,
|
||||
}
|
||||
|
||||
impl GlobalConfigCarrierContent for CatchUpMarkerCarrierContent {
|
||||
fn payload(&self) -> &str {
|
||||
&self.payload
|
||||
}
|
||||
|
||||
fn new(payload: String) -> Self {
|
||||
Self { payload }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub struct CatchUpMarker {
|
||||
pub caught_up_until_event_origin_server_ts_millis: i64,
|
||||
}
|
||||
|
||||
impl CatchUpMarker {
|
||||
pub fn new(caught_up_until_event_origin_server_ts_millis: i64) -> Self {
|
||||
Self {
|
||||
caught_up_until_event_origin_server_ts_millis,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl GlobalConfig for CatchUpMarker {}
|
||||
11
src/entity/catch_up_marker/mod.rs
Normal file
11
src/entity/catch_up_marker/mod.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
mod delayed_catch_up_marker_manager;
|
||||
mod entity;
|
||||
|
||||
use mxlink::helpers::account_data_config::GlobalConfigManager as AccountDataGlobalConfigManager;
|
||||
|
||||
pub use entity::{CatchUpMarker, CatchUpMarkerCarrierContent};
|
||||
|
||||
pub type CatchUpMarkerManager =
|
||||
AccountDataGlobalConfigManager<CatchUpMarker, CatchUpMarkerCarrierContent>;
|
||||
|
||||
pub use delayed_catch_up_marker_manager::DelayedCatchUpMarkerManager;
|
||||
401
src/entity/cfg/config.rs
Normal file
401
src/entity/cfg/config.rs
Normal file
@@ -0,0 +1,401 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use mxlink::helpers::encryption::EncryptionKey;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
agent::{AgentDefinition, AgentPurpose, PublicIdentifier},
|
||||
entity::{globalconfig::GlobalConfig, roomconfig::RoomSettingsHandler},
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Config {
|
||||
pub homeserver: ConfigHomeserver,
|
||||
|
||||
pub user: ConfigUser,
|
||||
|
||||
pub persistence: PersistenceConfig,
|
||||
|
||||
#[serde(default = "super::defaults::command_prefix")]
|
||||
pub command_prefix: String,
|
||||
|
||||
pub access: ConfigAccess,
|
||||
|
||||
pub agents: ConfigAgents,
|
||||
|
||||
// Contains the initial global configuration values.
|
||||
// Not all properties of the object make sense to be configured statically,
|
||||
// so not all of them will be reflected onto the actual global configuration.
|
||||
pub initial_global_config: ConfigInitialGlobalConfig,
|
||||
|
||||
#[serde(default = "super::defaults::logging")]
|
||||
pub logging: String,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn validate(&self) -> anyhow::Result<()> {
|
||||
self.homeserver.validate()?;
|
||||
self.user.validate()?;
|
||||
self.persistence.validate()?;
|
||||
self.access.validate()?;
|
||||
|
||||
if self.command_prefix.is_empty() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"The command_prefix ({}) configuration must be set",
|
||||
super::env::BAIBOT_COMMAND_PREFIX
|
||||
));
|
||||
}
|
||||
|
||||
self.agents.validate()?;
|
||||
self.initial_global_config.clone().validate()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ConfigHomeserver {
|
||||
pub server_name: String,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
impl ConfigHomeserver {
|
||||
pub fn validate(&self) -> anyhow::Result<()> {
|
||||
if self.server_name.is_empty() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"The homeserver.server_name ({}) configuration must be set",
|
||||
super::env::BAIBOT_HOMESERVER_SERVER_NAME
|
||||
));
|
||||
}
|
||||
|
||||
if self.url.is_empty() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"The homeserver.url ({}) configuration must be set",
|
||||
super::env::BAIBOT_HOMESERVER_URL
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ConfigUser {
|
||||
pub mxid_localpart: String,
|
||||
pub password: String,
|
||||
|
||||
#[serde(default = "super::defaults::name")]
|
||||
pub name: String,
|
||||
|
||||
#[serde(default)]
|
||||
pub encryption: ConfigUserEncryption,
|
||||
}
|
||||
|
||||
impl ConfigUser {
|
||||
pub fn validate(&self) -> anyhow::Result<()> {
|
||||
if self.mxid_localpart.is_empty() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"The user.mxid_localpart ({}) configuration must be set",
|
||||
super::env::BAIBOT_USER_MXID_LOCALPART
|
||||
));
|
||||
}
|
||||
|
||||
if self.password.is_empty() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"The user.password ({}) configuration must be set",
|
||||
super::env::BAIBOT_USER_PASSWORD
|
||||
));
|
||||
}
|
||||
|
||||
if self.name.is_empty() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"The name ({}) configuration must be set",
|
||||
super::env::BAIBOT_USER_NAME
|
||||
));
|
||||
}
|
||||
|
||||
self.encryption.validate()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
pub struct ConfigUserEncryption {
|
||||
pub recovery_passphrase: Option<String>,
|
||||
pub recovery_reset_allowed: bool,
|
||||
}
|
||||
|
||||
impl ConfigUserEncryption {
|
||||
pub fn validate(&self) -> anyhow::Result<()> {
|
||||
if let Some(passphrase) = &self.recovery_passphrase {
|
||||
if passphrase.is_empty() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"The user.encryption.recovery_passphrase ({}) configuration must either be null or set to a non-empty passphrase",
|
||||
super::env::BAIBOT_USER_ENCRYPTION_RECOVERY_PASSPHRASE
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct PersistenceConfig {
|
||||
#[serde(default = "super::defaults::persistence_data_dir_path")]
|
||||
pub data_dir_path: Option<String>,
|
||||
|
||||
#[serde(default = "super::defaults::persistence_session_file_name")]
|
||||
session_file_name: String,
|
||||
|
||||
#[serde(default = "super::defaults::persistence_db_dir_name")]
|
||||
db_dir_name: String,
|
||||
|
||||
pub session_encryption_key: Option<String>,
|
||||
|
||||
pub config_encryption_key: Option<String>,
|
||||
}
|
||||
|
||||
impl PersistenceConfig {
|
||||
pub fn validate(&self) -> anyhow::Result<()> {
|
||||
if let Some(data_dir_path) = &self.data_dir_path {
|
||||
let path = PathBuf::from(data_dir_path);
|
||||
if !path.exists() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"The persistence.data_dir_path ({}) directory ({}) must exist",
|
||||
super::env::BAIBOT_PERSISTENCE_DATA_DIR_PATH,
|
||||
data_dir_path,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
self.config_encryption_key()
|
||||
.map_err(|e| anyhow::anyhow!(e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn session_file_path(&self) -> anyhow::Result<PathBuf> {
|
||||
let Some(data_dir_path) = &self.data_dir_path else {
|
||||
return Err(anyhow::anyhow!(
|
||||
"The persistence.data_dir_path ({}) directory must be set",
|
||||
super::env::BAIBOT_PERSISTENCE_DATA_DIR_PATH
|
||||
));
|
||||
};
|
||||
|
||||
let mut path = PathBuf::from(data_dir_path);
|
||||
path.push(&self.session_file_name);
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub fn db_dir_path(&self) -> anyhow::Result<PathBuf> {
|
||||
let Some(data_dir_path) = &self.data_dir_path else {
|
||||
return Err(anyhow::anyhow!(
|
||||
"The persistence.data_dir_path ({}) directory must be set",
|
||||
super::env::BAIBOT_PERSISTENCE_DATA_DIR_PATH
|
||||
));
|
||||
};
|
||||
|
||||
let mut path = PathBuf::from(data_dir_path);
|
||||
path.push(&self.db_dir_name);
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub fn session_encryption_key(&self) -> anyhow::Result<Option<EncryptionKey>> {
|
||||
self.parse_encryption_key(&self.session_encryption_key).map_err(|err| {
|
||||
anyhow::anyhow!(
|
||||
"Encryption key specified in persistence.session_encryption_key ({}) is not valid: {}",
|
||||
super::env::BAIBOT_PERSISTENCE_SESSION_ENCRYPTION_KEY,
|
||||
err
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn config_encryption_key(&self) -> anyhow::Result<Option<EncryptionKey>> {
|
||||
self.parse_encryption_key(&self.config_encryption_key).map_err(|err| {
|
||||
anyhow::anyhow!(
|
||||
"Encryption key specified in persistence.config_encryption_key ({}) is not valid: {}",
|
||||
super::env::BAIBOT_PERSISTENCE_CONFIG_ENCRYPTION_KEY,
|
||||
err
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_encryption_key(
|
||||
&self,
|
||||
value: &Option<String>,
|
||||
) -> anyhow::Result<Option<EncryptionKey>, String> {
|
||||
let key = match value {
|
||||
Some(key) => {
|
||||
if key.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(EncryptionKey::from_hex_str(key)?)
|
||||
}
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
Ok(key)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ConfigAccess {
|
||||
// Contains the admin whitelist patterns before parsing into regex.
|
||||
// Example: `["@*:example.com"]`
|
||||
pub admin_patterns: Vec<String>,
|
||||
}
|
||||
|
||||
impl ConfigAccess {
|
||||
// Returns the the mxidwc-parsed regexes for the admin whitelist.
|
||||
// Example: `["^@\.*:example\.com$"]`
|
||||
pub fn admin_pattern_regexes(&self) -> anyhow::Result<Vec<regex::Regex>> {
|
||||
mxidwc::parse_patterns_vector(&self.admin_patterns).map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"Failed parsing access.admin_patterns ({}): {:?}",
|
||||
super::env::BAIBOT_ACCESS_ADMIN_PATTERNS,
|
||||
e
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> anyhow::Result<()> {
|
||||
if self.admin_patterns.is_empty() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"The access.admin_patterns ({}) configuration must contain at least one pattern",
|
||||
super::env::BAIBOT_ACCESS_ADMIN_PATTERNS
|
||||
));
|
||||
}
|
||||
|
||||
self.admin_pattern_regexes()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ConfigAgents {
|
||||
pub static_definitions: Vec<AgentDefinition>,
|
||||
}
|
||||
|
||||
impl ConfigAgents {
|
||||
pub fn validate(&self) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConfigInitialGlobalConfig {
|
||||
#[serde(default)]
|
||||
pub handler: RoomSettingsHandler,
|
||||
|
||||
pub user_patterns: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl ConfigInitialGlobalConfig {
|
||||
fn user_pattern_regexes(&self) -> anyhow::Result<Option<Vec<regex::Regex>>> {
|
||||
match &self.user_patterns {
|
||||
Some(user_patterns) => {
|
||||
let user_patterns = mxidwc::parse_patterns_vector(user_patterns).map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"Failed parsing initial_global_config.user_patterns ({}): {}",
|
||||
super::env::BAIBOT_INITIAL_GLOBAL_CONFIG_USER_PATTERNS,
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Some(user_patterns))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate(self) -> anyhow::Result<()> {
|
||||
self.user_pattern_regexes()?;
|
||||
|
||||
for purpose in AgentPurpose::choices() {
|
||||
let agent_id = self.handler.get_by_purpose(*purpose);
|
||||
|
||||
let Some(agent_id) = agent_id else {
|
||||
// None is OK
|
||||
continue;
|
||||
};
|
||||
|
||||
let config_key = format!(
|
||||
"initial_global_config.handler.{}",
|
||||
purpose.as_str().replace("-", "_")
|
||||
);
|
||||
|
||||
if agent_id.is_empty() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"The {} configuration key must be pointing to a valid agent id or be set to null",
|
||||
config_key,
|
||||
));
|
||||
}
|
||||
|
||||
let agent_identifier = PublicIdentifier::from_str(&agent_id);
|
||||
|
||||
let Some(agent_identifier) = agent_identifier else {
|
||||
return Err(anyhow::anyhow!(
|
||||
"The {} configuration key specifies an agent id (`{}`) that cannot be parsed. {}",
|
||||
config_key,
|
||||
agent_id,
|
||||
crate::strings::agent::invalid_id_generic()
|
||||
));
|
||||
};
|
||||
|
||||
// We only allow statically-defined agents for now, although DynamicGlobal may make sense too.
|
||||
let PublicIdentifier::Static(_) = agent_identifier else {
|
||||
return Err(anyhow::anyhow!(
|
||||
"The {} configuration key specifies an agent id (`{}`) which does not refer to a static agent.",
|
||||
config_key,
|
||||
agent_id,
|
||||
));
|
||||
};
|
||||
}
|
||||
|
||||
let _: GlobalConfig = self.try_into()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl TryInto<GlobalConfig> for ConfigInitialGlobalConfig {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_into(self) -> anyhow::Result<GlobalConfig> {
|
||||
let mut entity = GlobalConfig::default();
|
||||
|
||||
if let Some(user_patterns) = self.user_patterns {
|
||||
// We'd rather fail parsing this during startup than at runtime
|
||||
let _ = mxidwc::parse_patterns_vector(&user_patterns).map_err(|err| {
|
||||
anyhow::anyhow!(
|
||||
"Bad initial_global_config.user_patterns ({}): {}",
|
||||
super::env::BAIBOT_INITIAL_GLOBAL_CONFIG_USER_PATTERNS,
|
||||
err
|
||||
)
|
||||
})?;
|
||||
|
||||
entity.access.user_patterns = if user_patterns.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(user_patterns)
|
||||
};
|
||||
}
|
||||
|
||||
for purpose in AgentPurpose::choices() {
|
||||
let agent_id = self.handler.get_by_purpose(*purpose);
|
||||
|
||||
entity
|
||||
.fallback_room_settings
|
||||
.handler
|
||||
.set_by_purpose(*purpose, agent_id);
|
||||
}
|
||||
|
||||
Ok(entity)
|
||||
}
|
||||
}
|
||||
35
src/entity/cfg/defaults.rs
Normal file
35
src/entity/cfg/defaults.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
const CONFIG_FILE_PATH: &str = "config.yml";
|
||||
|
||||
const NAME: &str = "baibot";
|
||||
const COMMAND_PREFIX: &str = "!bai";
|
||||
|
||||
const PERSISTENCE_SESSION_FILE_NAME: &str = "session.json";
|
||||
const PERSISTENCE_DB_DIR_NAME: &str = "db";
|
||||
|
||||
pub(crate) fn name() -> String {
|
||||
NAME.to_owned()
|
||||
}
|
||||
|
||||
pub(crate) fn config_file_path() -> String {
|
||||
CONFIG_FILE_PATH.to_owned()
|
||||
}
|
||||
|
||||
pub(super) fn command_prefix() -> String {
|
||||
COMMAND_PREFIX.to_owned()
|
||||
}
|
||||
|
||||
pub(super) fn persistence_data_dir_path() -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
pub(super) fn persistence_session_file_name() -> String {
|
||||
PERSISTENCE_SESSION_FILE_NAME.to_owned()
|
||||
}
|
||||
|
||||
pub(super) fn persistence_db_dir_name() -> String {
|
||||
PERSISTENCE_DB_DIR_NAME.to_owned()
|
||||
}
|
||||
|
||||
pub(super) fn logging() -> String {
|
||||
"warn,mxlink=debug,baibot=debug".to_owned()
|
||||
}
|
||||
38
src/entity/cfg/env.rs
Normal file
38
src/entity/cfg/env.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
pub const BAIBOT_CONFIG_FILE_PATH: &str = "BAIBOT_CONFIG_FILE_PATH";
|
||||
|
||||
pub const BAIBOT_HOMESERVER_SERVER_NAME: &str = "BAIBOT_HOMESERVER_SERVER_NAME";
|
||||
pub const BAIBOT_HOMESERVER_URL: &str = "BAIBOT_HOMESERVER_URL";
|
||||
|
||||
pub const BAIBOT_USER_MXID_LOCALPART: &str = "BAIBOT_USER_MXID_LOCALPART";
|
||||
pub const BAIBOT_USER_PASSWORD: &str = "BAIBOT_USER_PASSWORD";
|
||||
pub const BAIBOT_USER_NAME: &str = "BAIBOT_USER_NAME";
|
||||
pub const BAIBOT_USER_ENCRYPTION_RECOVERY_PASSPHRASE: &str =
|
||||
"BAIBOT_USER_ENCRYPTION_RECOVERY_PASSPHRASE";
|
||||
|
||||
pub const BAIBOT_COMMAND_PREFIX: &str = "BAIBOT_COMMAND_PREFIX";
|
||||
|
||||
pub const BAIBOT_LOGGING: &str = "BAIBOT_LOGGING";
|
||||
|
||||
pub const BAIBOT_ACCESS_ADMIN_PATTERNS: &str = "BAIBOT_ACCESS_ADMIN_PATTERNS";
|
||||
|
||||
pub const BAIBOT_INITIAL_GLOBAL_CONFIG_HANDLER_CATCH_ALL: &str =
|
||||
"BAIBOT_INITIAL_GLOBAL_CONFIG_HANDLER_CATCH_ALL";
|
||||
pub const BAIBOT_INITIAL_GLOBAL_CONFIG_HANDLER_TEXT_GENERATION: &str =
|
||||
"BAIBOT_INITIAL_GLOBAL_CONFIG_HANDLER_TEXT_GENERATION";
|
||||
pub const BAIBOT_INITIAL_GLOBAL_CONFIG_HANDLER_TEXT_TO_SPEECH: &str =
|
||||
"BAIBOT_INITIAL_GLOBAL_CONFIG_HANDLER_TEXT_TO_SPEECH";
|
||||
pub const BAIBOT_INITIAL_GLOBAL_CONFIG_HANDLER_SPEECH_TO_TEXT: &str =
|
||||
"BAIBOT_INITIAL_GLOBAL_CONFIG_HANDLER_SPEECH_TO_TEXT";
|
||||
pub const BAIBOT_INITIAL_GLOBAL_CONFIG_HANDLER_IMAGE_GENERATION: &str =
|
||||
"BAIBOT_INITIAL_GLOBAL_CONFIG_HANDLER_IMAGE_GENERATION";
|
||||
|
||||
pub const BAIBOT_INITIAL_GLOBAL_CONFIG_USER_PATTERNS: &str =
|
||||
"BAIBOT_INITIAL_GLOBAL_CONFIG_USER_PATTERNS";
|
||||
|
||||
pub const BAIBOT_PERSISTENCE_DATA_DIR_PATH: &str = "BAIBOT_PERSISTENCE_DATA_DIR_PATH";
|
||||
|
||||
pub const BAIBOT_PERSISTENCE_SESSION_ENCRYPTION_KEY: &str =
|
||||
"BAIBOT_PERSISTENCE_SESSION_ENCRYPTION_KEY";
|
||||
|
||||
pub const BAIBOT_PERSISTENCE_CONFIG_ENCRYPTION_KEY: &str =
|
||||
"BAIBOT_PERSISTENCE_CONFIG_ENCRYPTION_KEY";
|
||||
5
src/entity/cfg/mod.rs
Normal file
5
src/entity/cfg/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
mod config;
|
||||
pub mod defaults;
|
||||
pub mod env;
|
||||
|
||||
pub use config::Config;
|
||||
64
src/entity/globalconfig/entity.rs
Normal file
64
src/entity/globalconfig/entity.rs
Normal file
@@ -0,0 +1,64 @@
|
||||
use mxlink::matrix_sdk::ruma::events::macros::EventContent;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use mxlink::helpers::account_data_config::GlobalConfig as GlobalConfigTrait;
|
||||
use mxlink::helpers::account_data_config::GlobalConfigCarrierContent as GlobalConfigCarrierContentTrait;
|
||||
|
||||
use crate::agent::AgentDefinition;
|
||||
use crate::entity::roomconfig::RoomSettings;
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize, EventContent)]
|
||||
#[ruma_event(type = "cc.etke.baibot.global_config", kind = GlobalAccountData)]
|
||||
pub struct GlobalConfigCarrierContent {
|
||||
pub payload: String,
|
||||
}
|
||||
|
||||
impl GlobalConfigCarrierContentTrait for GlobalConfigCarrierContent {
|
||||
fn payload(&self) -> &str {
|
||||
&self.payload
|
||||
}
|
||||
|
||||
fn new(payload: String) -> Self {
|
||||
Self { payload }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub struct GlobalConfig {
|
||||
pub fallback_room_settings: RoomSettings,
|
||||
|
||||
pub access: GlobalConfigAccess,
|
||||
|
||||
pub agents: Vec<AgentDefinition>,
|
||||
}
|
||||
|
||||
impl GlobalConfig {
|
||||
pub fn new(user_patterns: Option<Vec<String>>) -> Self {
|
||||
Self {
|
||||
fallback_room_settings: RoomSettings::default(),
|
||||
|
||||
access: GlobalConfigAccess {
|
||||
user_patterns,
|
||||
room_local_agent_manager_patterns: None,
|
||||
},
|
||||
|
||||
agents: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl GlobalConfigTrait for GlobalConfig {}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub struct GlobalConfigAccess {
|
||||
// Contains a list of patterns that will be used to specify the "allowed bot users".
|
||||
// These remain as patterns and are turned into regex and made use of on demand.
|
||||
// Example: `["@*:example.com"]`
|
||||
pub user_patterns: Option<Vec<String>>,
|
||||
|
||||
// Contains a list of patterns that will be used to specify "allowed room-local agent managers".
|
||||
// These remain as patterns and are turned into regex and made use of on demand.
|
||||
// Example: `["@*:example.com"]`
|
||||
pub room_local_agent_manager_patterns: Option<Vec<String>>,
|
||||
}
|
||||
8
src/entity/globalconfig/mod.rs
Normal file
8
src/entity/globalconfig/mod.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
mod entity;
|
||||
|
||||
use mxlink::helpers::account_data_config::GlobalConfigManager as AccountDataGlobalConfigManager;
|
||||
|
||||
pub use entity::{GlobalConfig, GlobalConfigCarrierContent};
|
||||
|
||||
pub type GlobalConfigurationManager =
|
||||
AccountDataGlobalConfigManager<GlobalConfig, GlobalConfigCarrierContent>;
|
||||
122
src/entity/message_context.rs
Normal file
122
src/entity/message_context.rs
Normal file
@@ -0,0 +1,122 @@
|
||||
use mxlink::matrix_sdk::ruma::{OwnedEventId, OwnedUserId, RoomId};
|
||||
use mxlink::matrix_sdk::Room;
|
||||
|
||||
use mxlink::ThreadInfo;
|
||||
|
||||
use super::{
|
||||
globalconfig::GlobalConfig, roomconfig::RoomConfig, MessagePayload, RoomConfigContext,
|
||||
TriggerEventInfo,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct MessageContext {
|
||||
room: Room,
|
||||
room_config_context: RoomConfigContext,
|
||||
admin_whitelist_regexes: Vec<regex::Regex>,
|
||||
trigger_event_info: TriggerEventInfo,
|
||||
thread_info: ThreadInfo,
|
||||
}
|
||||
|
||||
impl MessageContext {
|
||||
pub fn new(
|
||||
room: Room,
|
||||
room_config_context: RoomConfigContext,
|
||||
admin_whitelist_regexes: Vec<regex::Regex>,
|
||||
trigger_event_info: TriggerEventInfo,
|
||||
thread_info: ThreadInfo,
|
||||
) -> Self {
|
||||
Self {
|
||||
room,
|
||||
room_config_context,
|
||||
admin_whitelist_regexes,
|
||||
trigger_event_info,
|
||||
thread_info,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn room(&self) -> &Room {
|
||||
&self.room
|
||||
}
|
||||
|
||||
pub fn room_id(&self) -> &RoomId {
|
||||
self.room.room_id()
|
||||
}
|
||||
|
||||
pub fn global_config(&self) -> &GlobalConfig {
|
||||
&self.room_config_context.global_config
|
||||
}
|
||||
|
||||
pub fn room_config(&self) -> &RoomConfig {
|
||||
&self.room_config_context.room_config
|
||||
}
|
||||
|
||||
pub fn room_config_context(&self) -> &RoomConfigContext {
|
||||
&self.room_config_context
|
||||
}
|
||||
|
||||
pub fn event_id(&self) -> &OwnedEventId {
|
||||
&self.trigger_event_info.event_id
|
||||
}
|
||||
|
||||
pub fn sender_id(&self) -> &OwnedUserId {
|
||||
&self.trigger_event_info.sender
|
||||
}
|
||||
|
||||
pub fn payload(&self) -> &MessagePayload {
|
||||
&self.trigger_event_info.payload
|
||||
}
|
||||
|
||||
pub fn thread_info(&self) -> &ThreadInfo {
|
||||
&self.thread_info
|
||||
}
|
||||
|
||||
pub fn sender_can_manage_global_config(&self) -> anyhow::Result<bool> {
|
||||
Ok(self.trigger_event_info.sender_is_admin)
|
||||
}
|
||||
|
||||
pub fn sender_can_manage_room_local_agents(&self) -> anyhow::Result<bool> {
|
||||
Ok(self.sender_can_manage_global_config()?
|
||||
|| self.sender_is_allowed_room_local_agent_manager()?)
|
||||
}
|
||||
|
||||
pub fn combined_admin_and_user_regexes(&self) -> Vec<regex::Regex> {
|
||||
let mut combined = self.admin_whitelist_regexes.clone();
|
||||
|
||||
if let Some(user_patterns) = &self.global_config().access.user_patterns {
|
||||
let user_regexes = mxidwc::parse_patterns_vector(user_patterns);
|
||||
|
||||
match user_regexes {
|
||||
Ok(user_regexes) => {
|
||||
combined.extend(user_regexes);
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
"Error parsing user patterns for room {}: {:?}",
|
||||
self.room.room_id(),
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
combined
|
||||
}
|
||||
|
||||
fn sender_is_allowed_room_local_agent_manager(&self) -> anyhow::Result<bool> {
|
||||
match &self
|
||||
.global_config()
|
||||
.access
|
||||
.room_local_agent_manager_patterns
|
||||
{
|
||||
None => Ok(false),
|
||||
Some(patterns) => {
|
||||
let allowed_regexes = mxidwc::parse_patterns_vector(patterns)?;
|
||||
|
||||
Ok(mxidwc::match_user_id(
|
||||
self.sender_id().as_str(),
|
||||
&allowed_regexes,
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
47
src/entity/message_payload.rs
Normal file
47
src/entity/message_payload.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
use mxlink::matrix_sdk::ruma::events::room::message::{
|
||||
AudioMessageEventContent, MessageType, TextMessageEventContent,
|
||||
};
|
||||
use mxlink::matrix_sdk::ruma::{OwnedEventId, OwnedUserId};
|
||||
|
||||
use mxlink::ThreadInfo;
|
||||
|
||||
/// MessagePayload is like matrix-sdk's MessageType, but represents only message types that the bot deals with and payloads are massaged a bit.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum MessagePayload {
|
||||
Text(TextMessageEventContent),
|
||||
|
||||
Audio(AudioMessageEventContent),
|
||||
|
||||
Reaction {
|
||||
key: String,
|
||||
reacted_to_event_payload: Box<Self>,
|
||||
reacted_to_event_id: OwnedEventId,
|
||||
reacted_to_event_sender_id: OwnedUserId,
|
||||
},
|
||||
|
||||
/// Represents an encrypted message
|
||||
Encrypted(ThreadInfo),
|
||||
}
|
||||
|
||||
impl TryInto<MessagePayload> for MessageType {
|
||||
type Error = String;
|
||||
|
||||
fn try_into(self) -> Result<MessagePayload, Self::Error> {
|
||||
let payload = match self {
|
||||
MessageType::Text(text_content) => MessagePayload::Text(text_content),
|
||||
MessageType::Audio(audio_content) => {
|
||||
// We can consider inspecting `audio_content.voice.is_some()` and ignoring audio which is not a voice message.
|
||||
//
|
||||
// However, at the time of this writing (2024-09-10), certain popular clients (Element iOS) send voice messages
|
||||
// as regular audio messages, without voice annotation as per MSC3245.
|
||||
// For this reason, we handle all audio.
|
||||
MessagePayload::Audio(audio_content)
|
||||
}
|
||||
other => {
|
||||
return Err(format!("Unsupported message type: {:?}", other));
|
||||
}
|
||||
};
|
||||
|
||||
Ok(payload)
|
||||
}
|
||||
}
|
||||
15
src/entity/mod.rs
Normal file
15
src/entity/mod.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
pub mod catch_up_marker;
|
||||
pub mod cfg;
|
||||
pub mod globalconfig;
|
||||
mod message_context;
|
||||
mod message_payload;
|
||||
mod room_config_context;
|
||||
pub mod roomconfig;
|
||||
mod thread_context;
|
||||
mod trigger_event_info;
|
||||
|
||||
pub use message_context::MessageContext;
|
||||
pub use message_payload::MessagePayload;
|
||||
pub use room_config_context::RoomConfigContext;
|
||||
pub use thread_context::{ThreadContext, ThreadContextFirstMessage};
|
||||
pub use trigger_event_info::TriggerEventInfo;
|
||||
183
src/entity/room_config_context.rs
Normal file
183
src/entity/room_config_context.rs
Normal file
@@ -0,0 +1,183 @@
|
||||
use super::globalconfig::GlobalConfig;
|
||||
use super::roomconfig::RoomConfig;
|
||||
|
||||
use crate::entity::roomconfig::{
|
||||
defaults as roomconfig_defaults, SpeechToTextFlowType, TextGenerationAutoUsage,
|
||||
TextGenerationPrefixRequirementType, TextToSpeechBotMessagesFlowType,
|
||||
TextToSpeechUserMessagesFlowType,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct RoomConfigContext {
|
||||
pub(crate) global_config: GlobalConfig,
|
||||
pub(crate) room_config: RoomConfig,
|
||||
}
|
||||
|
||||
impl RoomConfigContext {
|
||||
pub fn new(global_config: GlobalConfig, room_config: RoomConfig) -> RoomConfigContext {
|
||||
Self {
|
||||
global_config,
|
||||
room_config,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn speech_to_text_flow_type(&self) -> SpeechToTextFlowType {
|
||||
self.room_config
|
||||
.settings
|
||||
.speech_to_text
|
||||
.flow_type
|
||||
.or({
|
||||
self.global_config
|
||||
.fallback_room_settings
|
||||
.speech_to_text
|
||||
.flow_type
|
||||
})
|
||||
.unwrap_or(roomconfig_defaults::SPEECH_TO_TEXT_FLOW_TYPE)
|
||||
}
|
||||
|
||||
pub fn speech_to_text_language(&self) -> Option<String> {
|
||||
self.room_config
|
||||
.settings
|
||||
.speech_to_text
|
||||
.language
|
||||
.clone()
|
||||
.or({
|
||||
self.global_config
|
||||
.fallback_room_settings
|
||||
.speech_to_text
|
||||
.language
|
||||
.clone()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn auto_text_generation_usage(&self) -> TextGenerationAutoUsage {
|
||||
self.room_config
|
||||
.settings
|
||||
.text_generation
|
||||
.auto_usage
|
||||
.or({
|
||||
self.global_config
|
||||
.fallback_room_settings
|
||||
.text_generation
|
||||
.auto_usage
|
||||
})
|
||||
.unwrap_or(roomconfig_defaults::TEXT_GENERATION_AUTO_USAGE)
|
||||
}
|
||||
|
||||
pub fn should_auto_text_generate(&self, original_message_is_audio: bool) -> bool {
|
||||
match self.auto_text_generation_usage() {
|
||||
TextGenerationAutoUsage::Never => false,
|
||||
TextGenerationAutoUsage::Always => true,
|
||||
TextGenerationAutoUsage::OnlyForVoice => original_message_is_audio,
|
||||
TextGenerationAutoUsage::OnlyForText => !original_message_is_audio,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn text_generation_prompt_override(&self) -> Option<String> {
|
||||
self.room_config
|
||||
.settings
|
||||
.text_generation
|
||||
.prompt_override
|
||||
.clone()
|
||||
.or_else(|| {
|
||||
self.global_config
|
||||
.fallback_room_settings
|
||||
.text_generation
|
||||
.prompt_override
|
||||
.clone()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn text_generation_temperature_override(&self) -> Option<f32> {
|
||||
self.room_config
|
||||
.settings
|
||||
.text_generation
|
||||
.temperature_override
|
||||
.or({
|
||||
self.global_config
|
||||
.fallback_room_settings
|
||||
.text_generation
|
||||
.temperature_override
|
||||
})
|
||||
}
|
||||
|
||||
pub fn text_generation_context_management_enabled(&self) -> bool {
|
||||
self.room_config
|
||||
.settings
|
||||
.text_generation
|
||||
.context_management_enabled
|
||||
.or({
|
||||
self.global_config
|
||||
.fallback_room_settings
|
||||
.text_generation
|
||||
.context_management_enabled
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn text_generation_prefix_requirement_type(&self) -> TextGenerationPrefixRequirementType {
|
||||
self.room_config
|
||||
.settings
|
||||
.text_generation
|
||||
.prefix_requirement_type
|
||||
.or({
|
||||
self.global_config
|
||||
.fallback_room_settings
|
||||
.text_generation
|
||||
.prefix_requirement_type
|
||||
})
|
||||
.unwrap_or(roomconfig_defaults::TEXT_GENERATION_PREFIX_REQUIREMENT_TYPE)
|
||||
}
|
||||
|
||||
pub fn text_to_speech_bot_messages_flow_type(&self) -> TextToSpeechBotMessagesFlowType {
|
||||
self.room_config
|
||||
.settings
|
||||
.text_to_speech
|
||||
.bot_msgs_flow_type
|
||||
.or({
|
||||
self.global_config
|
||||
.fallback_room_settings
|
||||
.text_to_speech
|
||||
.bot_msgs_flow_type
|
||||
})
|
||||
.unwrap_or(roomconfig_defaults::TEXT_TO_SPEECH_BOT_MESSAGES_FLOW_TYPE)
|
||||
}
|
||||
|
||||
pub fn text_to_speech_user_messages_flow_type(&self) -> TextToSpeechUserMessagesFlowType {
|
||||
self.room_config
|
||||
.settings
|
||||
.text_to_speech
|
||||
.user_msgs_flow_type
|
||||
.or({
|
||||
self.global_config
|
||||
.fallback_room_settings
|
||||
.text_to_speech
|
||||
.user_msgs_flow_type
|
||||
})
|
||||
.unwrap_or(roomconfig_defaults::TEXT_TO_SPEECH_USER_MESSAGES_FLOW_TYPE)
|
||||
}
|
||||
|
||||
pub fn text_to_speech_speed_override(&self) -> Option<f32> {
|
||||
self.room_config.settings.text_to_speech.speed_override.or({
|
||||
self.global_config
|
||||
.fallback_room_settings
|
||||
.text_to_speech
|
||||
.speed_override
|
||||
})
|
||||
}
|
||||
|
||||
pub fn text_to_speech_voice_override(&self) -> Option<String> {
|
||||
self.room_config
|
||||
.settings
|
||||
.text_to_speech
|
||||
.voice_override
|
||||
.clone()
|
||||
.or_else(|| {
|
||||
self.global_config
|
||||
.fallback_room_settings
|
||||
.text_to_speech
|
||||
.voice_override
|
||||
.clone()
|
||||
})
|
||||
}
|
||||
}
|
||||
17
src/entity/roomconfig/defaults.rs
Normal file
17
src/entity/roomconfig/defaults.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
use super::SpeechToTextFlowType;
|
||||
use super::{TextGenerationAutoUsage, TextGenerationPrefixRequirementType};
|
||||
use super::{TextToSpeechBotMessagesFlowType, TextToSpeechUserMessagesFlowType};
|
||||
|
||||
pub const TEXT_GENERATION_PREFIX_REQUIREMENT_TYPE: TextGenerationPrefixRequirementType =
|
||||
TextGenerationPrefixRequirementType::No;
|
||||
|
||||
pub const TEXT_GENERATION_AUTO_USAGE: TextGenerationAutoUsage = TextGenerationAutoUsage::Always;
|
||||
|
||||
pub const TEXT_TO_SPEECH_BOT_MESSAGES_FLOW_TYPE: TextToSpeechBotMessagesFlowType =
|
||||
TextToSpeechBotMessagesFlowType::OnDemandForVoice;
|
||||
|
||||
pub const TEXT_TO_SPEECH_USER_MESSAGES_FLOW_TYPE: TextToSpeechUserMessagesFlowType =
|
||||
TextToSpeechUserMessagesFlowType::OnDemand;
|
||||
|
||||
pub const SPEECH_TO_TEXT_FLOW_TYPE: SpeechToTextFlowType =
|
||||
SpeechToTextFlowType::TranscribeAndGenerateText;
|
||||
60
src/entity/roomconfig/entity/handler.rs
Normal file
60
src/entity/roomconfig/entity/handler.rs
Normal file
@@ -0,0 +1,60 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::agent::AgentPurpose;
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub struct RoomSettingsHandler {
|
||||
/// The agent used for any of the tasks which do not have a dedicated agent for them
|
||||
catch_all: Option<String>,
|
||||
|
||||
/// The agent used for text generation
|
||||
text_generation: Option<String>,
|
||||
|
||||
/// The agent used for transcribing audio (voice) to text
|
||||
speech_to_text: Option<String>,
|
||||
|
||||
/// The agent used for converting text to audio (voice)
|
||||
text_to_speech: Option<String>,
|
||||
|
||||
/// The agent used for generating images
|
||||
image_generation: Option<String>,
|
||||
}
|
||||
|
||||
impl RoomSettingsHandler {
|
||||
pub fn get_by_purpose(&self, purpose: AgentPurpose) -> Option<String> {
|
||||
match purpose {
|
||||
AgentPurpose::CatchAll => self.catch_all.clone(),
|
||||
AgentPurpose::TextGeneration => self.text_generation.clone(),
|
||||
AgentPurpose::SpeechToText => self.speech_to_text.clone(),
|
||||
AgentPurpose::TextToSpeech => self.text_to_speech.clone(),
|
||||
AgentPurpose::ImageGeneration => self.image_generation.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_by_purpose_with_catch_all_fallback(&self, purpose: AgentPurpose) -> Option<String> {
|
||||
match self.get_by_purpose(purpose) {
|
||||
Some(agent_id) => Some(agent_id),
|
||||
None => self.catch_all.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_by_purpose(&mut self, purpose: AgentPurpose, agent_id: Option<String>) {
|
||||
match purpose {
|
||||
AgentPurpose::CatchAll => {
|
||||
self.catch_all = agent_id;
|
||||
}
|
||||
AgentPurpose::TextGeneration => {
|
||||
self.text_generation = agent_id;
|
||||
}
|
||||
AgentPurpose::SpeechToText => {
|
||||
self.speech_to_text = agent_id;
|
||||
}
|
||||
AgentPurpose::TextToSpeech => {
|
||||
self.text_to_speech = agent_id;
|
||||
}
|
||||
AgentPurpose::ImageGeneration => {
|
||||
self.image_generation = agent_id;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
89
src/entity/roomconfig/entity/mod.rs
Normal file
89
src/entity/roomconfig/entity/mod.rs
Normal file
@@ -0,0 +1,89 @@
|
||||
use mxlink::helpers::account_data_config::RoomConfig as RoomConfigTrait;
|
||||
use mxlink::helpers::account_data_config::RoomConfigCarrierContent as RoomConfigCarrierContentTrait;
|
||||
use mxlink::matrix_sdk::ruma::events::macros::EventContent;
|
||||
use mxlink::matrix_sdk::{Room, RoomMemberships};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::agent::AgentDefinition;
|
||||
|
||||
mod handler;
|
||||
mod speech_to_text;
|
||||
mod text_generation;
|
||||
mod text_to_speech;
|
||||
|
||||
pub use handler::RoomSettingsHandler;
|
||||
pub use speech_to_text::SpeechToTextFlowType;
|
||||
pub use text_generation::{TextGenerationAutoUsage, TextGenerationPrefixRequirementType};
|
||||
pub use text_to_speech::{TextToSpeechBotMessagesFlowType, TextToSpeechUserMessagesFlowType};
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, EventContent)]
|
||||
#[ruma_event(type = "cc.etke.baibot.room_config", kind = RoomAccountData)]
|
||||
pub struct RoomConfigCarrierContent {
|
||||
pub payload: String,
|
||||
}
|
||||
|
||||
impl RoomConfigCarrierContentTrait for RoomConfigCarrierContent {
|
||||
fn payload(&self) -> &str {
|
||||
&self.payload
|
||||
}
|
||||
|
||||
fn new(payload: String) -> Self {
|
||||
Self { payload }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub struct RoomConfig {
|
||||
pub settings: RoomSettings,
|
||||
|
||||
pub agents: Vec<AgentDefinition>,
|
||||
}
|
||||
|
||||
impl RoomConfigTrait for RoomConfig {}
|
||||
|
||||
impl RoomConfig {
|
||||
pub async fn with_room(mut self, room: Room) -> Self {
|
||||
tracing::trace!("Determining room members count to decide on a suitable text-generation/prefix-requirement-type default");
|
||||
|
||||
let members = room.members(RoomMemberships::ACTIVE).await;
|
||||
|
||||
let prefix_requirement_type = match members {
|
||||
Ok(members) => {
|
||||
let members_count = members.len();
|
||||
|
||||
let prefix_requirement_type = if members.len() > 2 {
|
||||
text_generation::TextGenerationPrefixRequirementType::CommandPrefix
|
||||
} else {
|
||||
text_generation::TextGenerationPrefixRequirementType::No
|
||||
};
|
||||
|
||||
tracing::info!(?members_count, ?prefix_requirement_type, "Determined text-generation/prefix-requirement-type based on room members count");
|
||||
|
||||
prefix_requirement_type
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::error!(?err, "Failed to get members of room - will default text-generation/prefix-requirement-type to No");
|
||||
text_generation::TextGenerationPrefixRequirementType::No
|
||||
}
|
||||
};
|
||||
|
||||
self.settings.text_generation.prefix_requirement_type = Some(prefix_requirement_type);
|
||||
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub struct RoomSettings {
|
||||
pub handler: handler::RoomSettingsHandler,
|
||||
|
||||
#[serde(default)]
|
||||
pub text_generation: text_generation::RoomSettingsTextGeneration,
|
||||
|
||||
#[serde(default)]
|
||||
pub speech_to_text: speech_to_text::RoomSettingsSpeechToText,
|
||||
|
||||
#[serde(default)]
|
||||
pub text_to_speech: text_to_speech::RoomSettingsTextToSpeech,
|
||||
}
|
||||
59
src/entity/roomconfig/entity/speech_to_text.rs
Normal file
59
src/entity/roomconfig/entity/speech_to_text.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub struct RoomSettingsSpeechToText {
|
||||
pub flow_type: Option<SpeechToTextFlowType>,
|
||||
|
||||
/// The language of the input audio.
|
||||
/// Supplying the input language in [ISO-639-1](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) format will improve accuracy and latency.
|
||||
pub language: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq)]
|
||||
pub enum SpeechToTextFlowType {
|
||||
/// Voice messages are to be ignored.
|
||||
#[serde(rename = "ignore")]
|
||||
Ignore,
|
||||
|
||||
/// Voice messages are to trigger text-generation.
|
||||
/// This may potentially trigger speech-to-text, but that's not what we care about here.
|
||||
#[serde(rename = "transcribe_and_generate_text")]
|
||||
TranscribeAndGenerateText,
|
||||
|
||||
// Voices messages are to trigger transcription.
|
||||
#[serde(rename = "only_transcribe")]
|
||||
OnlyTranscribe,
|
||||
}
|
||||
|
||||
impl SpeechToTextFlowType {
|
||||
pub fn choices() -> Vec<Self> {
|
||||
vec![
|
||||
Self::Ignore,
|
||||
Self::TranscribeAndGenerateText,
|
||||
Self::OnlyTranscribe,
|
||||
]
|
||||
}
|
||||
|
||||
pub fn from_str(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"ignore" => Some(Self::Ignore),
|
||||
"transcribe_and_generate_text" => Some(Self::TranscribeAndGenerateText),
|
||||
"only_transcribe" => Some(Self::OnlyTranscribe),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SpeechToTextFlowType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
SpeechToTextFlowType::Ignore => {
|
||||
write!(f, "ignore")
|
||||
}
|
||||
SpeechToTextFlowType::TranscribeAndGenerateText => {
|
||||
write!(f, "transcribe_and_generate_text")
|
||||
}
|
||||
SpeechToTextFlowType::OnlyTranscribe => write!(f, "only_transcribe"),
|
||||
}
|
||||
}
|
||||
}
|
||||
111
src/entity/roomconfig/entity/text_generation.rs
Normal file
111
src/entity/roomconfig/entity/text_generation.rs
Normal file
@@ -0,0 +1,111 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub struct RoomSettingsTextGeneration {
|
||||
/// Controls whether initial text messages require a prefix to trigger text generation.
|
||||
/// This could have been a bool, using an enum allows us to add more options (e.g. CustomPrefix) in the future.
|
||||
/// Even if set to "required", prefixless-triggering could still happen via an initial voice message (see auto_usage).
|
||||
pub prefix_requirement_type: Option<TextGenerationPrefixRequirementType>,
|
||||
|
||||
/// Controls whether text generation is automatically triggered (depending on message type).
|
||||
pub auto_usage: Option<TextGenerationAutoUsage>,
|
||||
|
||||
/// Controls whether conversation context management is enabled.
|
||||
/// When enabled, the bot will automatically tokenize messages and try to shorten the message context intelligently.
|
||||
pub context_management_enabled: Option<bool>,
|
||||
|
||||
/// Allows customizing the system prompt that the agent would use
|
||||
pub prompt_override: Option<String>,
|
||||
|
||||
/// Allows customizing the temperature that the agent would use
|
||||
pub temperature_override: Option<f32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq)]
|
||||
pub enum TextGenerationPrefixRequirementType {
|
||||
/// Text Generation is to be triggered for any text message
|
||||
#[serde(rename = "no")]
|
||||
No,
|
||||
|
||||
/// Text Generation is to be triggered only for messages that are prefixed with the command prefix
|
||||
#[serde(rename = "command_prefix")]
|
||||
CommandPrefix,
|
||||
}
|
||||
|
||||
impl TextGenerationPrefixRequirementType {
|
||||
pub fn choices() -> Vec<Self> {
|
||||
vec![Self::No, Self::CommandPrefix]
|
||||
}
|
||||
|
||||
pub fn from_str(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"no" => Some(Self::No),
|
||||
"command_prefix" => Some(Self::CommandPrefix),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for TextGenerationPrefixRequirementType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
TextGenerationPrefixRequirementType::No => write!(f, "no"),
|
||||
TextGenerationPrefixRequirementType::CommandPrefix => {
|
||||
write!(f, "command_prefix")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq)]
|
||||
pub enum TextGenerationAutoUsage {
|
||||
/// Text Generation is to never be performed
|
||||
#[serde(rename = "never")]
|
||||
Never,
|
||||
|
||||
/// Text Generation is to always be performed
|
||||
#[serde(rename = "always")]
|
||||
Always,
|
||||
|
||||
/// Text Generation is to be performed when the original message was sent as audio (voice).
|
||||
/// The voice message would be transcribed to text (subject to other configuration)
|
||||
/// and text generation would be triggered.
|
||||
#[serde(rename = "only_for_voice")]
|
||||
OnlyForVoice,
|
||||
|
||||
/// Text Generation is to be performed when the original message was sent as text
|
||||
#[serde(rename = "only_for_text")]
|
||||
OnlyForText,
|
||||
}
|
||||
|
||||
impl TextGenerationAutoUsage {
|
||||
pub fn choices() -> Vec<Self> {
|
||||
vec![
|
||||
Self::Never,
|
||||
Self::Always,
|
||||
Self::OnlyForVoice,
|
||||
Self::OnlyForText,
|
||||
]
|
||||
}
|
||||
|
||||
pub fn from_str(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"never" => Some(Self::Never),
|
||||
"always" => Some(Self::Always),
|
||||
"only_for_voice" => Some(Self::OnlyForVoice),
|
||||
"only_for_text" => Some(Self::OnlyForText),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for TextGenerationAutoUsage {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
TextGenerationAutoUsage::Never => write!(f, "never"),
|
||||
TextGenerationAutoUsage::Always => write!(f, "always"),
|
||||
TextGenerationAutoUsage::OnlyForVoice => write!(f, "only_for_voice"),
|
||||
TextGenerationAutoUsage::OnlyForText => write!(f, "only_for_text"),
|
||||
}
|
||||
}
|
||||
}
|
||||
110
src/entity/roomconfig/entity/text_to_speech.rs
Normal file
110
src/entity/roomconfig/entity/text_to_speech.rs
Normal file
@@ -0,0 +1,110 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub struct RoomSettingsTextToSpeech {
|
||||
pub bot_msgs_flow_type: Option<TextToSpeechBotMessagesFlowType>,
|
||||
|
||||
pub user_msgs_flow_type: Option<TextToSpeechUserMessagesFlowType>,
|
||||
|
||||
pub speed_override: Option<f32>,
|
||||
|
||||
pub voice_override: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq)]
|
||||
pub enum TextToSpeechBotMessagesFlowType {
|
||||
/// Never do text-to-speech for bot messages automatically and don't offer it
|
||||
#[serde(rename = "never")]
|
||||
Never,
|
||||
|
||||
/// Never do text-to-speech for bot messages automatically, but offer it via an emoji reaction for all messages
|
||||
#[serde(rename = "on_demand_always")]
|
||||
OnDemandAlways,
|
||||
|
||||
/// Never do text-to-speech for bot messages automatically, but offer it via an emoji reaction if the user message that prompted the bot message was audio (voice)
|
||||
#[serde(rename = "on_demand_for_voice")]
|
||||
OnDemandForVoice,
|
||||
|
||||
/// Convert all bot text messages to audio (voice) automatically if the user message that prompted the bot message was audio (voice)
|
||||
#[serde(rename = "only_for_voice")]
|
||||
OnlyForVoice,
|
||||
|
||||
/// Convert all bot text messages to audio (voice) automatically
|
||||
#[serde(rename = "always")]
|
||||
Always,
|
||||
}
|
||||
|
||||
impl TextToSpeechBotMessagesFlowType {
|
||||
pub fn choices() -> Vec<Self> {
|
||||
vec![
|
||||
Self::Never,
|
||||
Self::OnDemandAlways,
|
||||
Self::OnDemandForVoice,
|
||||
Self::Always,
|
||||
Self::OnlyForVoice,
|
||||
]
|
||||
}
|
||||
|
||||
pub fn from_str(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"never" => Some(Self::Never),
|
||||
"on_demand_always" => Some(Self::OnDemandAlways),
|
||||
"on_demand_for_voice" => Some(Self::OnDemandForVoice),
|
||||
"only_for_voice" => Some(Self::OnlyForVoice),
|
||||
"always" => Some(Self::Always),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for TextToSpeechBotMessagesFlowType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
TextToSpeechBotMessagesFlowType::Never => write!(f, "never"),
|
||||
TextToSpeechBotMessagesFlowType::OnDemandAlways => write!(f, "on_demand_always"),
|
||||
TextToSpeechBotMessagesFlowType::OnDemandForVoice => write!(f, "on_demand_for_voice"),
|
||||
TextToSpeechBotMessagesFlowType::Always => write!(f, "always"),
|
||||
TextToSpeechBotMessagesFlowType::OnlyForVoice => write!(f, "only_for_voice"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq)]
|
||||
pub enum TextToSpeechUserMessagesFlowType {
|
||||
/// Never do text-to-speech for user messages automatically and don't offer it
|
||||
#[serde(rename = "never")]
|
||||
Never,
|
||||
|
||||
/// Never do text-to-speech for user messages automatically, but offer it via an emoji reaction
|
||||
#[serde(rename = "on_demand")]
|
||||
OnDemand,
|
||||
|
||||
/// Convert all user text messages to audio (voice) automatically
|
||||
#[serde(rename = "always")]
|
||||
Always,
|
||||
}
|
||||
|
||||
impl TextToSpeechUserMessagesFlowType {
|
||||
pub fn choices() -> Vec<Self> {
|
||||
vec![Self::Never, Self::OnDemand, Self::Always]
|
||||
}
|
||||
|
||||
pub fn from_str(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"never" => Some(Self::Never),
|
||||
"on_demand" => Some(Self::OnDemand),
|
||||
"always" => Some(Self::Always),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for TextToSpeechUserMessagesFlowType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
TextToSpeechUserMessagesFlowType::Never => write!(f, "never"),
|
||||
TextToSpeechUserMessagesFlowType::OnDemand => write!(f, "on_demand"),
|
||||
TextToSpeechUserMessagesFlowType::Always => write!(f, "always"),
|
||||
}
|
||||
}
|
||||
}
|
||||
13
src/entity/roomconfig/mod.rs
Normal file
13
src/entity/roomconfig/mod.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
pub mod defaults;
|
||||
mod entity;
|
||||
|
||||
use mxlink::helpers::account_data_config::RoomConfigManager as AccountDataRoomConfigManager;
|
||||
|
||||
pub use entity::{RoomConfig, RoomConfigCarrierContent, RoomSettings, RoomSettingsHandler};
|
||||
pub use entity::{
|
||||
SpeechToTextFlowType, TextGenerationAutoUsage, TextGenerationPrefixRequirementType,
|
||||
TextToSpeechBotMessagesFlowType, TextToSpeechUserMessagesFlowType,
|
||||
};
|
||||
|
||||
pub type RoomConfigurationManager =
|
||||
AccountDataRoomConfigManager<RoomConfig, RoomConfigCarrierContent>;
|
||||
13
src/entity/thread_context.rs
Normal file
13
src/entity/thread_context.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
use mxlink::ThreadInfo;
|
||||
|
||||
use super::MessagePayload;
|
||||
|
||||
pub struct ThreadContext {
|
||||
pub info: ThreadInfo,
|
||||
pub first_message: ThreadContextFirstMessage,
|
||||
}
|
||||
|
||||
pub struct ThreadContextFirstMessage {
|
||||
pub is_mentioning_bot: bool,
|
||||
pub payload: MessagePayload,
|
||||
}
|
||||
27
src/entity/trigger_event_info.rs
Normal file
27
src/entity/trigger_event_info.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
use mxlink::matrix_sdk::ruma::{OwnedEventId, OwnedUserId};
|
||||
|
||||
use super::MessagePayload;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct TriggerEventInfo {
|
||||
pub event_id: OwnedEventId,
|
||||
pub sender: OwnedUserId,
|
||||
pub payload: MessagePayload,
|
||||
pub sender_is_admin: bool,
|
||||
}
|
||||
|
||||
impl TriggerEventInfo {
|
||||
pub fn new(
|
||||
event_id: OwnedEventId,
|
||||
sender: OwnedUserId,
|
||||
payload: MessagePayload,
|
||||
sender_is_admin: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
event_id,
|
||||
sender,
|
||||
payload,
|
||||
sender_is_admin,
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user