#![deny(clippy::all)]
#![deny(rustdoc::all)]
#![cfg_attr(docsrs, feature(doc_cfg))]
mod builder;
mod config;
mod edit;
mod error;
#[cfg(feature = "upload")]
#[cfg_attr(docsrs, doc(cfg(feature = "upload")))]
pub mod file;
#[cfg(feature = "generators")]
#[cfg_attr(docsrs, doc(cfg(feature = "generators")))]
pub mod generators;
mod logging;
mod page;
mod siteinfo;
#[cfg(feature = "upload")]
#[cfg_attr(docsrs, doc(cfg(feature = "upload")))]
pub mod upload;
mod utils;
pub use error::{config::ConfigError, Error};
use fs_mistrust::Mistrust;
pub use mwapi::Client as ApiClient;
use mwapi::ErrorFormat;
pub use mwtimestamp as timestamp;
pub use mwtitle::{Namespace, Title, TitleCodec};
use once_cell::sync::OnceCell;
pub use parsoid;
use std::{path::Path, sync::Arc};
use tokio::{sync::Mutex, time};
use tracing::{debug, info};
pub type Result<T, E = Error> = std::result::Result<T, E>;
pub use builder::Builder;
pub use edit::SaveOptions;
pub use logging::init as init_logging;
pub use page::Page;
use parsoid::prelude::*;
#[derive(Clone, Debug)]
pub struct Bot {
api: ApiClient,
parsoid: ParsoidClient,
state: BotState,
config: Arc<BotConfig>,
}
#[derive(Clone, Debug)]
struct BotConfig {
username: Option<String>,
siteinfo: siteinfo::SiteInfo,
codec: TitleCodec,
mark_as_bot: bool,
respect_nobots: bool,
}
#[derive(Clone, Debug)]
struct BotState {
save_timer: Option<Arc<Mutex<time::Interval>>>,
}
impl Bot {
pub fn builder(wiki_url: String) -> Builder {
Builder::new(wiki_url)
}
pub fn builder_with_legacy_rest(
api_url: String,
rest_url: String,
) -> Builder {
Builder::new_with_api_url(api_url, rest_url)
}
pub async fn from_default_config() -> Result<Self, ConfigError> {
let path = {
let first = Path::new("mwbot.toml");
if first.exists() {
first.to_path_buf()
} else {
dirs::config_dir()
.expect("Cannot find config directory")
.join("mwbot.toml")
}
};
Self::from_path(&path).await
}
pub async fn from_path(path: &Path) -> Result<Self, ConfigError> {
debug!("Reading config from {:?}", path);
let config: config::Config =
toml::from_str(&std::fs::read_to_string(path)?)?;
if config.auth.is_some() {
check_file_permissions(path)?;
}
Self::from_config(config).await
}
async fn from_config(config: config::Config) -> Result<Self, ConfigError> {
let mut api = ApiClient::builder(
&config
.api_url
.clone()
.or_else(|| {
config.wiki_url.as_ref().map(|w| format!("{w}api.php"))
})
.ok_or(ConfigError::MissingSiteURL)?,
)
.set_maxlag(config.general.maxlag.unwrap_or(5))
.set_errorformat(ErrorFormat::Wikitext);
if let Some(limit) = config.general.retry_limit {
api = api.set_retry_limit(limit);
}
let mut user_agent = vec![];
if let Some(extra) = config.general.user_agent {
user_agent.push(extra);
}
let mut username = None;
if let Some(auth) = config.auth {
match &auth {
config::Auth::BotPassword { username, password } => {
info!("Logging in as {} with password", username);
api = api.set_botpassword(username, password);
}
config::Auth::OAuth2 {
username,
oauth2_token,
} => {
info!("Logging in as {} with OAuth2 token", username);
api = api.set_oauth2_token(oauth2_token);
}
}
let normalized = normalize_username(auth.username());
user_agent.push(format!("User:{}", &normalized));
username = Some(normalized);
}
user_agent.push(format!("mwbot-rs/{}", env!("CARGO_PKG_VERSION")));
let user_agent = user_agent.join(" ");
let save_delay = config.edit.save_delay.unwrap_or(10);
let save_timer = if save_delay > 0 {
let mut interval =
time::interval(time::Duration::from_secs(save_delay));
interval.set_missed_tick_behavior(time::MissedTickBehavior::Delay);
Some(Arc::new(Mutex::new(interval)))
} else {
None
};
let api = api
.set_user_agent(&user_agent)
.build()
.await
.map_err(Error::from)?;
let (site_info, title_site_info) = siteinfo::get_siteinfo(&api).await?;
let http = api.http_client().clone();
Ok(Self {
api,
parsoid: ParsoidClient::new_with_client(
&config
.rest_url
.clone()
.or_else(|| {
config.wiki_url.as_ref().map(|w| format!("{w}rest.php"))
})
.ok_or(ConfigError::MissingSiteURL)?,
http,
)?,
config: Arc::new(BotConfig {
username,
siteinfo: site_info,
codec: TitleCodec::from_site_info(title_site_info)
.map_err(Error::from)?,
mark_as_bot: config.edit.mark_as_bot.unwrap_or(true),
respect_nobots: config.edit.respect_nobots.unwrap_or(true),
}),
state: BotState { save_timer },
})
}
pub fn api(&self) -> &ApiClient {
&self.api
}
pub fn parsoid(&self) -> &ParsoidClient {
&self.parsoid
}
pub fn title_codec(&self) -> &TitleCodec {
&self.config.codec
}
pub fn page(&self, title: &str) -> Result<Page> {
let title = self.config.codec.new_title(title)?.remove_fragment();
self.page_from_title(title)
}
pub fn page_from_database(
&self,
namespace: i32,
dbkey: &str,
) -> Result<Page> {
let title = self
.config
.codec
.new_title_from_database(namespace, dbkey)?
.remove_fragment();
self.page_from_title(title)
}
pub fn page_from_title(&self, title: Title) -> Result<Page> {
if !title.is_local_page() {
return Err(Error::InvalidPage);
}
Ok(Page {
bot: self.clone(),
title,
title_text: Default::default(),
info: Default::default(),
baserevid: Default::default(),
})
}
pub async fn page_from_id(&self, page_id: u64) -> Result<Page> {
let mut resp: page::InfoResponse = mwapi_responses::query_api(
&self.api,
[("pageids", page_id.to_string())],
)
.await?;
let info = resp
.query
.pages
.pop()
.expect("API response returned 0 pages");
let title = self.config.codec.new_title(&info.title)?.remove_fragment();
if !title.is_local_page() {
return Err(Error::InvalidPage);
}
Ok(Page {
bot: self.clone(),
title,
title_text: Default::default(),
info: Arc::new(info.clone().into()),
baserevid: info
.lastrevid
.map_or_else(OnceCell::new, OnceCell::with_value),
})
}
pub fn server_name(&self) -> &str {
&self.config.siteinfo.general.server_name
}
pub fn wiki_id(&self) -> &str {
&self.config.siteinfo.general.wiki_id
}
pub fn mediawiki_version(&self) -> &str {
&self.config.siteinfo.general.version
}
pub fn namespace_id<'a, N: Into<Namespace<'a>>>(
&self,
namespace: N,
) -> Option<i32> {
self.title_codec().namespace_map().get_id(namespace)
}
pub fn namespace_name<'a, N: Into<Namespace<'a>>>(
&self,
namespace: N,
) -> Option<&str> {
self.title_codec().namespace_map().get_name(namespace)
}
}
fn check_file_permissions(path: &Path) -> Result<(), ConfigError> {
Ok(Mistrust::new()
.verifier()
.require_file()
.all_errors()
.check(path)?)
}
fn normalize_username(original: &str) -> String {
let name = original.replace('_', " ");
match name.split_once('@') {
Some((name, _)) => name.to_string(),
None => name,
}
}
#[cfg(test)]
mod tests {
use super::*;
pub(crate) fn is_authenticated() -> bool {
std::env::var("MWAPI_TOKEN").is_ok()
}
pub(crate) async fn testwp() -> Bot {
let username = std::env::var("MWAPI_USERNAME");
let token = std::env::var("MWAPI_TOKEN");
let auth = if let (Ok(username), Ok(oauth2_token)) = (username, token) {
Some(config::Auth::OAuth2 {
username,
oauth2_token,
})
} else {
None
};
Bot::from_config(config::Config {
wiki_url: Some("https://test.wikipedia.org/w/".to_string()),
api_url: None,
rest_url: None,
auth,
general: Default::default(),
edit: Default::default(),
})
.await
.unwrap()
}
pub(crate) async fn has_userright(bot: &Bot, right: &str) -> bool {
let resp = bot
.api()
.get_value(&[
("action", "query"),
("meta", "userinfo"),
("uiprop", "rights"),
])
.await
.unwrap();
resp["query"]["userinfo"]["rights"]
.as_array()
.unwrap()
.iter()
.any(|r| r == right)
}
fn assert_send_sync<T: Send + Sync>() {}
#[test]
fn test_send_sync() {
assert_send_sync::<Bot>();
assert_send_sync::<Page>();
}
#[test]
fn test_normalize_username() {
assert_eq!(&normalize_username("Foo"), "Foo");
assert_eq!(&normalize_username("Foo_bar"), "Foo bar");
assert_eq!(&normalize_username("Foo@bar"), "Foo");
}
#[tokio::test]
async fn test_get_api() {
let bot = testwp().await;
bot.api().get_value(&[("action", "query")]).await.unwrap();
}
#[tokio::test]
async fn test_page() {
let bot = testwp().await;
let page = bot.page("Example").unwrap();
assert_eq!(page.title(), "Example");
assert_eq!(page.namespace(), 0);
let page = bot.page_from_database(1, "Example").unwrap();
assert_eq!(page.title(), "Talk:Example");
assert_eq!(page.namespace(), 1);
let error = bot.page("mw:External").unwrap_err();
assert!(matches!(error, Error::InvalidPage));
}
#[tokio::test]
async fn test_page_from_id() {
let bot = testwp().await;
let page = bot.page_from_id(122863).await.unwrap();
assert_eq!(page.title(), "Mwbot-rs");
assert_eq!(page.namespace(), 0);
let page = bot.page_from_id(153569).await.unwrap();
assert_eq!(page.title(), "Talk:Mwbot-rs");
assert_eq!(page.namespace(), 1);
let invalid = bot.page_from_id(0).await.unwrap_err();
dbg!(&invalid);
assert!(matches!(invalid, Error::InvalidJson(_)));
}
#[tokio::test]
#[ignore] async fn test_user_agent() {
let bot = testwp().await;
let version = env!("CARGO_PKG_VERSION");
let resp: serde_json::Value = bot
.api()
.http_client()
.get("https://httpbin.org/user-agent")
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let user_agent = resp["user-agent"].as_str().unwrap();
match &bot.config.username {
Some(username) => {
assert_eq!(
user_agent,
&format!("User:{username} mwbot-rs/{version}")
);
}
None => {
assert_eq!(user_agent, &format!("mwbot-rs/{version}"));
}
}
}
#[tokio::test]
async fn test_zero_save_delay() {
Bot::builder("https://test.wikipedia.org/w/".to_string())
.set_save_delay(0)
.build()
.await
.unwrap();
}
#[tokio::test]
async fn test_siteinfo() {
let bot = testwp().await;
assert_eq!(bot.server_name(), "test.wikipedia.org");
assert_eq!(bot.wiki_id(), "testwiki");
assert!(bot.mediawiki_version().contains("0-wmf."));
}
#[tokio::test]
async fn test_namespace_id() {
let bot = testwp().await;
assert_eq!(bot.namespace_id(""), Some(0));
assert_eq!(bot.namespace_id("Wikipedia"), Some(4));
assert_eq!(bot.namespace_id("MediaWiki"), Some(8));
assert_eq!(bot.namespace_id("Special"), Some(-1));
assert_eq!(bot.namespace_id("NamespaceNotExist"), None);
assert_eq!(bot.namespace_id(-12345), None);
assert_eq!(bot.namespace_id(-1), Some(-1));
}
#[tokio::test]
async fn test_namespace_name() {
let bot = testwp().await;
assert_eq!(bot.namespace_name(""), Some(""));
assert_eq!(bot.namespace_name("Wikipedia"), Some("Wikipedia"));
assert_eq!(bot.namespace_name("Special"), Some("Special"));
assert_eq!(bot.namespace_name("NamespaceNotExist"), None);
assert_eq!(bot.namespace_name(-12345), None);
assert_eq!(bot.namespace_name(-1), Some("Special"));
assert_eq!(bot.namespace_name(0), Some(""));
assert_eq!(bot.namespace_name(1), Some("Talk"));
assert_eq!(bot.namespace_name(4), Some("Wikipedia"));
}
}