1#![cfg_attr(docsrs, feature(doc_cfg))]
91
92mod builder;
93mod config;
94mod edit;
95mod error;
96#[cfg(feature = "upload")]
97#[cfg_attr(docsrs, doc(cfg(feature = "upload")))]
98pub mod file;
99#[cfg(feature = "generators")]
100#[cfg_attr(docsrs, doc(cfg(feature = "generators")))]
101pub mod generators;
102mod logging;
103mod page;
104mod siteinfo;
105#[cfg(feature = "sitematrix")]
106#[cfg_attr(docsrs, doc(cfg(feature = "sitematrix")))]
107mod sitematrix;
108#[cfg(feature = "upload")]
109#[cfg_attr(docsrs, doc(cfg(feature = "upload")))]
110pub mod upload;
111mod utils;
112
113pub use error::{config::ConfigError, Error};
114use fs_mistrust::Mistrust;
115pub use mwapi::Client as ApiClient;
116use mwapi::{Builder as ApiBuilder, ErrorFormat};
117pub use mwtimestamp as timestamp;
118pub use mwtitle::{Namespace, Title, TitleCodec};
119use once_cell::sync::OnceCell;
120pub use parsoid;
121use std::{path::Path, sync::Arc};
122#[cfg(feature = "sitematrix")]
123use tokio::sync::OnceCell as AsyncOnceCell;
124use tokio::{sync::Mutex, time};
125use tracing::{debug, info};
126pub type Result<T, E = Error> = std::result::Result<T, E>;
127pub use builder::Builder;
128pub use edit::SaveOptions;
129pub use logging::init as init_logging;
130pub use page::Page;
131
132use parsoid::prelude::*;
133
134#[derive(Clone, Debug)]
136pub struct Bot {
137 api: ApiClient,
138 parsoid: ParsoidClient,
139 state: BotState,
140 config: Arc<BotConfig>,
141}
142
143#[derive(Clone, Debug)]
145struct BotConfig {
146 username: Option<String>,
148 using_botpassword: bool,
150 siteinfo: siteinfo::SiteInfo,
151 codec: TitleCodec,
152 #[cfg(feature = "sitematrix")]
153 sitematrix: AsyncOnceCell<sitematrix::SiteMapping>,
154 mark_as_bot: bool,
156 respect_nobots: bool,
158}
159
160#[derive(Clone, Debug)]
162struct BotState {
163 save_timer: Option<Arc<Mutex<time::Interval>>>,
164}
165
166impl Bot {
167 pub fn builder(wiki_url: String) -> Builder {
173 Builder::new(wiki_url)
174 }
175
176 pub fn builder_with_legacy_rest(
178 api_url: String,
179 rest_url: String,
180 ) -> Builder {
181 Builder::new_with_api_url(api_url, rest_url)
182 }
183
184 pub async fn from_default_config() -> Result<Self, ConfigError> {
194 let path = {
195 let first = Path::new("mwbot.toml");
196 if first.exists() {
197 first.to_path_buf()
198 } else {
199 dirs::config_dir()
200 .expect("Cannot find config directory")
201 .join("mwbot.toml")
202 }
203 };
204 Self::from_path(&path).await
205 }
206
207 pub async fn from_path(path: &Path) -> Result<Self, ConfigError> {
211 debug!("Reading config from {:?}", path);
212 let config: config::Config =
213 toml::from_str(&std::fs::read_to_string(path)?)?;
214 if config.auth.is_some() {
216 check_file_permissions(path)?;
217 }
218 Self::from_config(config).await
219 }
220
221 async fn from_config(config: config::Config) -> Result<Self, ConfigError> {
222 let mut api = ApiClient::builder(&config.api_url()?)
223 .set_maxlag(config.general.maxlag.unwrap_or(5))
224 .set_errorformat(ErrorFormat::Wikitext);
225 if let Some(limit) = config.general.retry_limit {
226 api = api.set_retry_limit(limit);
227 }
228 let mut user_agent = vec![];
229 if let Some(extra) = &config.general.user_agent {
230 user_agent.push(extra.to_string());
231 }
232 let mut username = None;
234 if let Some(auth) = &config.auth {
235 match auth {
236 config::Auth::BotPassword { username, password } => {
237 info!("Logging in as {} with password", username);
238 api = api.set_botpassword(username, password);
239 }
240 config::Auth::OAuth2 {
241 username,
242 oauth2_token,
243 } => {
244 info!("Logging in as {} with OAuth2 token", username);
245 api = api.set_oauth2_token(oauth2_token);
246 }
247 }
248 let normalized = normalize_username(auth.username());
249 user_agent.push(format!("User:{normalized}"));
250 username = Some(normalized);
251 }
252 user_agent.push(format!("mwbot-rs/{}", env!("CARGO_PKG_VERSION")));
253 let user_agent = user_agent.join(" ");
254 let save_delay = config.edit.save_delay.unwrap_or(10);
255 let save_timer = if save_delay > 0 {
256 let mut interval =
257 time::interval(time::Duration::from_secs(save_delay));
258 interval.set_missed_tick_behavior(time::MissedTickBehavior::Delay);
259 Some(Arc::new(Mutex::new(interval)))
260 } else {
261 None
262 };
263 let api = api
264 .set_user_agent(&user_agent)
265 .build()
266 .await
267 .map_err(Error::from)?;
268 let (site_info, title_site_info) = siteinfo::get_siteinfo(&api).await?;
269 let http = api.http_client().clone();
270 Ok(Self {
271 api,
272 parsoid: ParsoidClient::new_with_client(&config.rest_url()?, http)?,
273 config: Arc::new(BotConfig {
274 username,
275 using_botpassword: matches!(
276 config.auth,
277 Some(config::Auth::BotPassword { .. })
278 ),
279 siteinfo: site_info,
280 codec: TitleCodec::from_site_info(title_site_info)
281 .map_err(Error::from)?,
282 #[cfg(feature = "sitematrix")]
283 sitematrix: AsyncOnceCell::new(),
284 mark_as_bot: config.edit.mark_as_bot.unwrap_or(true),
285 respect_nobots: config.edit.respect_nobots.unwrap_or(true),
286 }),
287 state: BotState { save_timer },
288 })
289 }
290
291 pub async fn clone_for_family(
301 &self,
302 wiki_url: &str,
303 ) -> Result<Self, ConfigError> {
304 if self.config.using_botpassword {
305 return Err(ConfigError::CannotCloneBotPassword);
306 }
307 let api = ApiBuilder::from_client_with_url(
308 &self.api,
309 &format!("{wiki_url}api.php"),
310 )
311 .build()
312 .await
313 .map_err(Error::from)?;
314 let parsoid = ParsoidClient::new_with_client(
315 &format!("{wiki_url}rest.php"),
316 self.api.http_client().clone(),
317 )?;
318 let (site_info, title_site_info) = siteinfo::get_siteinfo(&api).await?;
319
320 Ok(Self {
321 api,
322 parsoid,
323 config: Arc::new(BotConfig {
324 username: self.config.username.clone(),
325 using_botpassword: false,
326 siteinfo: site_info,
327 codec: TitleCodec::from_site_info(title_site_info)
328 .map_err(Error::from)?,
329 #[cfg(feature = "sitematrix")]
330 sitematrix: self.config.sitematrix.clone(),
331 mark_as_bot: self.config.mark_as_bot,
332 respect_nobots: self.config.respect_nobots,
333 }),
334 state: self.state.clone(),
335 })
336 }
337
338 #[cfg(feature = "sitematrix")]
339 pub async fn clone_for_family_via_dbname(
340 &self,
341 dbname: &str,
342 ) -> Result<Self, ConfigError> {
343 let mapping = self
344 .config
345 .sitematrix
346 .get_or_try_init(|| async { sitematrix::fetch(&self.api).await })
347 .await?;
348 let new_url = match mapping.get(dbname) {
349 Some(new_url) => new_url,
350 None => Err(ConfigError::InvalidSitename(dbname.to_string()))?,
351 };
352 self.clone_for_family(&format!(
353 "{new_url}{}/",
354 self.config.siteinfo.general.script_path
355 ))
356 .await
357 }
358
359 pub fn api(&self) -> &ApiClient {
362 &self.api
363 }
364
365 pub fn parsoid(&self) -> &ParsoidClient {
368 &self.parsoid
369 }
370
371 pub fn title_codec(&self) -> &TitleCodec {
374 &self.config.codec
375 }
376
377 pub fn page(&self, title: &str) -> Result<Page> {
381 let title = self.config.codec.new_title(title)?.remove_fragment();
382 self.page_from_title(title)
383 }
384
385 pub fn page_from_database(
390 &self,
391 namespace: i32,
392 dbkey: &str,
393 ) -> Result<Page> {
394 let title = self
395 .config
396 .codec
397 .new_title_from_database(namespace, dbkey)?
398 .remove_fragment();
399 self.page_from_title(title)
400 }
401
402 pub fn page_from_title(&self, title: Title) -> Result<Page> {
404 if !title.is_local_page() {
405 return Err(Error::InvalidPage);
406 }
407 Ok(Page {
408 bot: self.clone(),
409 title,
410 title_text: Default::default(),
411 info: Default::default(),
412 baserevid: Default::default(),
413 })
414 }
415
416 pub async fn page_from_id(&self, page_id: u64) -> Result<Page> {
421 let mut resp: page::InfoResponse = mwapi_responses::query_api(
422 &self.api,
423 [("pageids", page_id.to_string())],
424 )
425 .await?;
426 let info = resp
427 .query
428 .pages
429 .pop()
430 .expect("API response returned 0 pages");
431 let title = self.config.codec.new_title(&info.title)?.remove_fragment();
432 if !title.is_local_page() {
433 return Err(Error::InvalidPage);
434 }
435 Ok(Page {
436 bot: self.clone(),
437 title,
438 title_text: Default::default(),
439 info: Arc::new(info.clone().into()),
440 baserevid: info
441 .lastrevid
442 .map_or_else(OnceCell::new, OnceCell::with_value),
443 })
444 }
445
446 pub fn server_name(&self) -> &str {
449 &self.config.siteinfo.general.server_name
450 }
451
452 pub fn wiki_id(&self) -> &str {
455 &self.config.siteinfo.general.wiki_id
456 }
457
458 pub fn mediawiki_version(&self) -> &str {
460 &self.config.siteinfo.general.version
461 }
462
463 pub fn namespace_id<'a, N: Into<Namespace<'a>>>(
465 &self,
466 namespace: N,
467 ) -> Option<i32> {
468 self.title_codec().namespace_map().get_id(namespace)
469 }
470
471 pub fn namespace_name<'a, N: Into<Namespace<'a>>>(
473 &self,
474 namespace: N,
475 ) -> Option<&str> {
476 self.title_codec().namespace_map().get_name(namespace)
477 }
478}
479
480fn check_file_permissions(path: &Path) -> Result<(), ConfigError> {
487 Ok(Mistrust::new()
488 .verifier()
489 .require_file()
490 .all_errors()
491 .check(path)?)
492}
493
494fn normalize_username(original: &str) -> String {
496 let name = original.replace('_', " ");
498 match name.split_once('@') {
500 Some((name, _)) => name.to_string(),
501 None => name,
502 }
503}
504
505#[cfg(test)]
506mod tests {
507 use super::*;
508
509 pub(crate) fn is_authenticated() -> bool {
510 std::env::var("MWAPI_TOKEN").is_ok()
511 }
512
513 pub(crate) async fn testwp() -> Bot {
514 let username = std::env::var("MWAPI_USERNAME");
515 let token = std::env::var("MWAPI_TOKEN");
516 let auth = if let (Ok(username), Ok(oauth2_token)) = (username, token) {
517 Some(config::Auth::OAuth2 {
518 username,
519 oauth2_token,
520 })
521 } else {
522 None
523 };
524
525 Bot::from_config(config::Config {
526 wiki_url: Some("https://test.wikipedia.org/w/".to_string()),
527 api_url: None,
528 rest_url: None,
529 auth,
530 general: Default::default(),
531 edit: Default::default(),
532 })
533 .await
534 .unwrap()
535 }
536
537 #[cfg(feature = "wikibase")]
538 pub(crate) async fn testwikidata() -> Bot {
539 let username = std::env::var("MWAPI_USERNAME");
540 let token = std::env::var("MWAPI_TOKEN");
541 let auth = if let (Ok(username), Ok(oauth2_token)) = (username, token) {
542 Some(config::Auth::OAuth2 {
543 username,
544 oauth2_token,
545 })
546 } else {
547 None
548 };
549
550 Bot::from_config(config::Config {
551 wiki_url: Some("https://test.wikidata.org/w/".to_string()),
552 api_url: None,
553 rest_url: None,
554 auth,
555 general: Default::default(),
556 edit: Default::default(),
557 })
558 .await
559 .unwrap()
560 }
561
562 pub(crate) async fn has_userright(bot: &Bot, right: &str) -> bool {
564 let resp = bot
565 .api()
566 .get_value(&[
567 ("action", "query"),
568 ("meta", "userinfo"),
569 ("uiprop", "rights"),
570 ])
571 .await
572 .unwrap();
573 resp["query"]["userinfo"]["rights"]
574 .as_array()
575 .unwrap()
576 .iter()
577 .any(|r| r == right)
578 }
579
580 fn assert_send_sync<T: Send + Sync>() {}
581
582 #[test]
584 fn test_send_sync() {
585 assert_send_sync::<Bot>();
586 assert_send_sync::<Page>();
587 }
588
589 #[test]
590 fn test_normalize_username() {
591 assert_eq!(&normalize_username("Foo"), "Foo");
592 assert_eq!(&normalize_username("Foo_bar"), "Foo bar");
593 assert_eq!(&normalize_username("Foo@bar"), "Foo");
594 }
595
596 #[tokio::test]
597 async fn test_get_api() {
598 let bot = testwp().await;
599 bot.api().get_value(&[("action", "query")]).await.unwrap();
601 }
602
603 #[tokio::test]
604 async fn test_page() {
605 let bot = testwp().await;
606 let page = bot.page("Example").unwrap();
607 assert_eq!(page.title(), "Example");
608 assert_eq!(page.namespace(), 0);
609 let page = bot.page_from_database(1, "Example").unwrap();
610 assert_eq!(page.title(), "Talk:Example");
611 assert_eq!(page.namespace(), 1);
612 let error = bot.page("mw:External").unwrap_err();
613 assert!(matches!(error, Error::InvalidPage));
614 }
615
616 #[tokio::test]
617 async fn test_page_from_id() {
618 let bot = testwp().await;
619 let page = bot.page_from_id(122863).await.unwrap();
620 assert_eq!(page.title(), "Mwbot-rs");
621 assert_eq!(page.namespace(), 0);
622 let page = bot.page_from_id(153569).await.unwrap();
623 assert_eq!(page.title(), "Talk:Mwbot-rs");
624 assert_eq!(page.namespace(), 1);
625 let invalid = bot.page_from_id(0).await.unwrap_err();
626 dbg!(&invalid);
627 assert!(matches!(invalid, Error::InvalidJson(_)));
629 }
630
631 #[tokio::test]
632 #[ignore] async fn test_user_agent() {
634 let bot = testwp().await;
635 let version = env!("CARGO_PKG_VERSION");
636 let resp: serde_json::Value = bot
637 .api()
638 .http_client()
639 .get("https://httpbin.org/user-agent")
640 .send()
641 .await
642 .unwrap()
643 .json()
644 .await
645 .unwrap();
646 let user_agent = resp["user-agent"].as_str().unwrap();
647 match &bot.config.username {
648 Some(username) => {
649 assert_eq!(
650 user_agent,
651 &format!("User:{username} mwbot-rs/{version}")
652 );
653 }
654 None => {
655 assert_eq!(user_agent, &format!("mwbot-rs/{version}"));
656 }
657 }
658 }
659
660 #[tokio::test]
662 async fn test_zero_save_delay() {
663 Bot::builder("https://test.wikipedia.org/w/".to_string())
664 .set_save_delay(0)
665 .build()
666 .await
667 .unwrap();
668 }
669
670 #[tokio::test]
671 async fn test_siteinfo() {
672 let bot = testwp().await;
673 assert_eq!(bot.server_name(), "test.wikipedia.org");
674 assert_eq!(bot.wiki_id(), "testwiki");
675 assert!(bot.mediawiki_version().contains("0-wmf."));
677 }
678
679 #[tokio::test]
680 async fn test_namespace_id() {
681 let bot = testwp().await;
682 assert_eq!(bot.namespace_id(""), Some(0));
683 assert_eq!(bot.namespace_id("Wikipedia"), Some(4));
684 assert_eq!(bot.namespace_id("MediaWiki"), Some(8));
685 assert_eq!(bot.namespace_id("Special"), Some(-1));
686 assert_eq!(bot.namespace_id("NamespaceNotExist"), None);
687 assert_eq!(bot.namespace_id(-12345), None);
688 assert_eq!(bot.namespace_id(-1), Some(-1));
689 }
690
691 #[tokio::test]
692 async fn test_namespace_name() {
693 let bot = testwp().await;
694 assert_eq!(bot.namespace_name(""), Some(""));
695 assert_eq!(bot.namespace_name("Wikipedia"), Some("Wikipedia"));
696 assert_eq!(bot.namespace_name("Special"), Some("Special"));
697 assert_eq!(bot.namespace_name("NamespaceNotExist"), None);
698 assert_eq!(bot.namespace_name(-12345), None);
699 assert_eq!(bot.namespace_name(-1), Some("Special"));
700 assert_eq!(bot.namespace_name(0), Some(""));
701 assert_eq!(bot.namespace_name(1), Some("Talk"));
702 assert_eq!(bot.namespace_name(4), Some("Wikipedia"));
703 }
704}