Skip to main content

mwbot/
lib.rs

1/*
2Copyright (C) 2021 Kunal Mehta <legoktm@debian.org>
3
4This program is free software: you can redistribute it and/or modify
5it under the terms of the GNU General Public License as published by
6the Free Software Foundation, either version 3 of the License, or
7(at your option) any later version.
8
9This program is distributed in the hope that it will be useful,
10but WITHOUT ANY WARRANTY; without even the implied warranty of
11MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12GNU General Public License for more details.
13
14You should have received a copy of the GNU General Public License
15along with this program.  If not, see <http://www.gnu.org/licenses/>.
16 */
17
18//! A MediaWiki bot and tool framework
19//!
20//! `mwbot` aims to provide a batteries-included framework for building bots
21//! and tools for MediaWiki wikis. It builds on top of the [mwapi](https://docs.rs/mwapi)
22//! and [parsoid](https://docs.rs/parsoid) crates, which offer lower-level APIs.
23//!
24//! ## Quickstart
25//! ### Configuration
26//! Create a `~/.config/mwbot.toml` file with the following structure:
27//! ```toml
28//! wiki_url = "https://en.wikipedia.org/w/"
29//! ```
30//!
31//! If want to authenticate, add an auth section:
32//! ```toml
33//! [auth]
34//! username = "Example"
35//! oauth2_token = "[...]"
36//! ```
37//! See [the OAuth documentation](https://www.mediawiki.org/wiki/OAuth/For_Developers#OAuth_2)
38//! for how to get an OAuth 2 token. Using an [owner-only consumer](https://www.mediawiki.org/wiki/OAuth/Owner-only_consumers#OAuth_2)
39//! is the easiest way to do so.
40//!
41//! <div class="warning">
42//!
43//! On UNIX-like systems, for security, you must make sure the config's
44//! file permissions don't permit reading by other users, or authorization
45//! will fail with a [`ReadableConfig`][ConfigError::ReadableConfig] error. In this case,
46//! `chmod 600 ~/.config/mwbot.toml` can be used to correct the permissions and
47//! make sure the file is only readable and writeable to its owner.
48//!
49//! </div>
50//!
51//! ### Reading a page
52//! ```
53//! # async fn demo() -> mwbot::Result<()> {
54//! # use parsoid::prelude::*;
55//! let bot = mwbot::Bot::from_default_config().await.unwrap();
56//! let page = bot.page("Rust (programming language)")?;
57//! let html = page.html().await?.into_mutable();
58//! // The lead section is the second p tag in the first section
59//! let lead = html.select("section > p")[1].text_contents();
60//! assert!(lead.starts_with("Rust is a multi-paradigm, general-purpose programming language"));
61//! # Ok(())
62//! # }
63//! ```
64//! Using [`Bot::from_default_config()`] will look in the current directory
65//! for `mwbot.toml` before looking in the user's config directory. A
66//! custom path can be specified by using `Bot::from_config(...)`.
67//!
68//! ### Editing a page
69//! ```
70//! # async fn demo() -> mwbot::Result<()> {
71//! # use mwbot::SaveOptions;
72//! let bot = mwbot::Bot::from_default_config().await.unwrap();
73//! let page = bot.page("Project:Sandbox")?;
74//! let wikitext = "This is a test edit!";
75//! page.save(wikitext, &SaveOptions::summary("test edit!")).await?;
76//! # Ok(())
77//! # }
78//! ```
79//! `Page.save()` accepts both HTML and wikitext and supports the [`{{nobots}}`](https://en.wikipedia.org/wiki/Template:Bots)
80//! exclusion mechanism, among other features.
81//!
82//! ### Next steps
83//! Try using one of the offered [page generators](./generators/index.html) to fetch and operate on
84//! multiple pages.
85//!
86//! ## Contributing
87//! `mwbot` is the flagship crate of the [`mwbot-rs` project](https://www.mediawiki.org/wiki/Mwbot-rs).
88//! We're always looking for new contributors, please [reach out](https://www.mediawiki.org/wiki/Mwbot-rs#Contributing)
89//! if you're interested!
90#![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/// Main bot class
135#[derive(Clone, Debug)]
136pub struct Bot {
137    api: ApiClient,
138    parsoid: ParsoidClient,
139    state: BotState,
140    config: Arc<BotConfig>,
141}
142
143/// Static, read-only settings
144#[derive(Clone, Debug)]
145struct BotConfig {
146    // TODO: figure out something better than this
147    username: Option<String>,
148    // TODO: is there a better way to track this?
149    using_botpassword: bool,
150    siteinfo: siteinfo::SiteInfo,
151    codec: TitleCodec,
152    #[cfg(feature = "sitematrix")]
153    sitematrix: AsyncOnceCell<sitematrix::SiteMapping>,
154    /// Whether edits should be marked as bot (default: true)
155    mark_as_bot: bool,
156    /// Whether to respect {{nobots}} (default: true)
157    respect_nobots: bool,
158}
159
160/// Dynamic state
161#[derive(Clone, Debug)]
162struct BotState {
163    save_timer: Option<Arc<Mutex<time::Interval>>>,
164}
165
166impl Bot {
167    /// Build a `Bot` instance programmatically.
168    ///
169    /// The wiki's URL should be the path where api.php and rest.php can be
170    /// found (aka the "script path"). By default it is usually something
171    /// like `https://wiki.example.org/w/`.
172    pub fn builder(wiki_url: String) -> Builder {
173        Builder::new(wiki_url)
174    }
175
176    /// For older wikis (pre-1.42) that use RESTBase or the old Parsoid rest.php API.
177    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    /// Load Bot configuration from a default location, first look at
185    /// `mwbot.toml` in the current directory, otherwise look in the
186    /// platform's config directory:
187    ///
188    /// * Linux: `$XDG_CONFIG_HOME` or `$HOME/.config`
189    /// * macOS: `$HOME/Library/Application Support`
190    /// * Windows: `{FOLDERID_RoamingAppData}`
191    ///
192    /// Under UNIX-like systems, the configuration file must only be readable by its owner!
193    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    /// Load Bot configuration from the specified path.
208    ///
209    /// Under UNIX-like systems, the configuration file must only be readable by its owner!
210    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        // Check file permissions if there are credentials
215        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        // FIXME: do better
233        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    /// Clone this bot for another wiki in the same family.
292    ///
293    /// This allows you to use the `Bot` to connect to another wiki in
294    /// the same family. For example, if you have a bot for `en.wikipedia.org`,
295    /// you can clone it to use with `de.wikipedia.org`.
296    ///
297    /// Behind the scenes, this will copy over the username, authentication info,
298    /// as well as internal HTTP connection pools and save state. Rate limits
299    /// like edit speed will apply across both instances.
300    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    /// Get a reference to the underlying [`mwapi::Client`](https://docs.rs/mwapi/latest/mwapi/struct.Client.html)
360    /// to make arbitrary API requests
361    pub fn api(&self) -> &ApiClient {
362        &self.api
363    }
364
365    /// Get a reference to the underlying [`parsoid::Client`](https://docs.rs/parsoid/latest/parsoid/struct.Client.html)
366    /// to make arbitrary Parsoid API requests
367    pub fn parsoid(&self) -> &ParsoidClient {
368        &self.parsoid
369    }
370
371    /// Get a reference to the internal [`mwtitle::TitleCodec`](https://docs.rs/mwtitle/latest/mwtitle/struct.TitleCodec.html)
372    /// to parse and construct arbitrary titles.
373    pub fn title_codec(&self) -> &TitleCodec {
374        &self.config.codec
375    }
376
377    /// Get a `Page` on this wiki. The specified title should be a full title,
378    /// including namespace. This does some validation on the
379    /// provided input.
380    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    /// Get a `Page` on this wiki using information from a database row.
386    ///
387    /// The provided input will be validated and may return an
388    /// error if invalid.
389    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    /// Get a `Page` for a given [`Title`](https://docs.rs/mwtitle/latest/mwtitle/struct.Title.html)
403    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    /// Get a `Page` on this wiki using page id.
417    ///
418    /// The provided input will be validated and may return an
419    /// error if invalid.
420    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    /// Get the site's server name, which is usually the domain name,
447    /// e.g. `"en.wikipedia.org"`
448    pub fn server_name(&self) -> &str {
449        &self.config.siteinfo.general.server_name
450    }
451
452    /// Get the site's "wikiid", which is usually the internal database name,
453    /// e.g. `"enwiki"`
454    pub fn wiki_id(&self) -> &str {
455        &self.config.siteinfo.general.wiki_id
456    }
457
458    /// Get the version of MediaWiki that the site is using, e.g. `"1.45.0"`
459    pub fn mediawiki_version(&self) -> &str {
460        &self.config.siteinfo.general.version
461    }
462
463    /// Get the numeric id of a [`Namespace`](https://docs.rs/mwtitle/latest/mwtitle/struct.Namespace.html).
464    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    /// Get the name of a [`Namespace`](https://docs.rs/mwtitle/latest/mwtitle/struct.Namespace.html).
472    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
480/// Verify file permissions are not obviously misconfigured:
481/// * If the file is owned by the current user, it should only be readable to
482///   that user
483/// * If the file is owned by another user, it should not be world readable
484///
485/// TODO: support [extended ACLs](https://wiki.archlinux.org/title/Access_Control_Lists)
486fn check_file_permissions(path: &Path) -> Result<(), ConfigError> {
487    Ok(Mistrust::new()
488        .verifier()
489        .require_file()
490        .all_errors()
491        .check(path)?)
492}
493
494// TODO: Can we reuse TitleCodec here?
495fn normalize_username(original: &str) -> String {
496    // MW normalization, underscores to spaces
497    let name = original.replace('_', " ");
498    // If it's a bot password, strip the @<name> part
499    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    // TODO: turn this into a proper public API; for now it's just for tests
563    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    /// Assert all these types are Send + Sync
583    #[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        // No errors
600        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        // This is the wrong error type, but at least it fails. See T354590.
628        assert!(matches!(invalid, Error::InvalidJson(_)));
629    }
630
631    #[tokio::test]
632    #[ignore] // flaky, see T341906
633    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    /// T333423 regression test
661    #[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        // testwp should always be on a WMF branch
676        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}