nsdotpy.session

   1# This file is part of NSDotPy, a wrapper around httpx that makes interacting
   2# with the HTML nationstates.net site legally and efficiently easier.
   3#
   4# NSDotPy is free software: you can redistribute it and/or modify
   5# it under the terms of the GNU Affero General Public License
   6# as published by the Free Software Foundation either version
   7# 3 of the License, or (at your option) any later version.
   8#
   9# NSDotPy is distributed in the hope that it will be useful but
  10# WITHOUT ANY WARRANTY; without even the implied warranty of
  11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
  12# See the GNU Affero General Public License for more details.
  13#
  14# You should have received a copy of the GNU Affero General Public License
  15# along with NSDotPy. If not, see <https://www.gnu.org/licenses/>.
  16
  17# standard library imports
  18import time  # for ratelimiting and userclick
  19import logging  # for logging
  20import logging.config  # for logging configuration
  21import mimetypes  # for flag and banner uploading
  22
  23# external library imports
  24import keyboard  # for the required user input
  25import httpx  # for http stuff
  26from bs4 import BeautifulSoup, Tag  # for parsing html and xml
  27from benedict import benedict
  28
  29# local imports
  30from . import valid  # for valid region tags
  31
  32
  33def canonicalize(string: str) -> str:
  34    """Converts a string to its canonical form used by the nationstates api.
  35
  36    Args:
  37        string (str): The string to convert
  38
  39    Returns:
  40        str: The canonical form of the string
  41    """
  42    return string.lower().strip().replace(" ", "_")
  43
  44
  45def uncanonicalize(string: str) -> str:
  46    """Converts a string into a user friendly form from it's canonical nationstates api form.
  47
  48    Args:
  49        string (str): The string to convert
  50
  51    Returns:
  52        str: The uncanonicale form of the string
  53    """
  54    return string.replace("_", " ").title()
  55
  56
  57class NSSession:
  58    def __init__(
  59        self,
  60        script_name: str,
  61        script_version: str,
  62        script_author: str,
  63        script_user: str,
  64        keybind: str = "space",
  65        link_to_src: str = "",
  66        logger: logging.Logger | None = None,
  67    ):
  68        """A wrapper around httpx that abstracts away
  69        interacting with the HTML nationstates.net site.
  70        Focused on legality, correctness, and ease of use.
  71
  72        Args:
  73            script_name (str): Name of your script
  74            script_version (str): Version number of your script
  75            script_author (str): Author of your script
  76            script_user (str): Nation name of the user running your script
  77            keybind (str, optional): Keybind to count as a user click. Defaults to "space".
  78            link_to_src (str, optional): Link to the source code of your script.
  79            logger (logging.Logger | None, optional): Logger to use. Will create its own with name "NSDotPy" if none is specified. Defaults to None.
  80        """
  81        self.VERSION = "2.3.0"
  82        # Initialize logger
  83        if not logger:
  84            self._init_logger()
  85        else:
  86            self.logger = logger
  87        # Create a new httpx session
  88        self._session = httpx.Client(
  89            http2=True, timeout=30
  90        )  # ns can b slow, 30 seconds is hopefully a good sweet spot
  91        # Set the user agent to the script name, version, author, and user as recommended in the script rules thread:
  92        # https://forum.nationstates.net/viewtopic.php?p=16394966&sid=be37623536dbc8cee42d8d043945b887#p16394966
  93        self._lock: bool = False
  94        self._set_user_agent(
  95            script_name, script_version, script_author, script_user, link_to_src
  96        )
  97        # Initialize nationstates specific stuff
  98        self._auth_region = "rwby"
  99        self.chk: str = ""
 100        self.localid: str = ""
 101        self.pin: str = ""
 102        self.nation: str = ""
 103        self.region: str = ""
 104        self.keybind = keybind
 105        # Make sure the nations in the user agent actually exist
 106        if not self._validate_nations({script_author, script_user}):
 107            raise ValueError(
 108                "One of, or both, of the nations in the user agent do not exist. Make sure you're only including the nation name in the constructor, e.g. 'Thorn1000' instead of 'Devved by Thorn1000'"
 109            )
 110        self.logger.info(f"Initialized. Keybind to continue is {self.keybind}.")
 111
 112    def _validate_shard(self, shard, valid_shards, api):
 113        """Helper function to validate a single shard."""
 114        if shard not in valid_shards:
 115            raise ValueError(f"{shard} is not a valid shard for {api}")
 116
 117    def _validate_shards(self, api: str, shards: set[str]) -> None:
 118        """Validates shards for a given API."""
 119        api_to_shard_set = {
 120            "nation": valid.NATION_SHARDS
 121            | valid.PRIVATE_NATION_SHARDS
 122            | valid.PRIVATE_NATION_SHARDS,
 123            "region": valid.REGION_SHARDS,
 124            "world": valid.WORLD_SHARDS,
 125            "wa": valid.WA_SHARDS,
 126        }
 127
 128        valid_shards = api_to_shard_set.get(api)
 129        if not valid_shards:
 130            raise ValueError(f"Invalid API type: {api}")
 131
 132        for shard in shards:
 133            self._validate_shard(shard, valid_shards, api)
 134
 135    def _set_user_agent(
 136        self,
 137        script_name: str,
 138        script_version: str,
 139        script_author: str,
 140        script_user: str,
 141        link_to_src: str,
 142    ):
 143        self.user_agent = (
 144            f"{script_name}/{script_version} (by:{script_author}; usedBy:{script_user})"
 145        )
 146        if link_to_src:
 147            self.user_agent = f"{self.user_agent}; src:{link_to_src}"
 148        self.user_agent = f"{self.user_agent}; Written with NSDotPy/{self.VERSION} (by:Sweeze; src:github.com/sw33ze/NSDotPy)"
 149        self._session.headers.update({"User-Agent": self.user_agent})
 150
 151    def _validate_nations(self, nations: set[str]) -> bool:
 152        """Checks if a list of nations exist using the NationStates API.
 153
 154        Args:
 155            nations (set[str]): List of nations to check
 156
 157        Returns:
 158            bool: True if all nations in the set exist, False otherwise.
 159        """
 160        response = self.api_request("world", shard="nations")
 161        world_nations = response.nations.split(",")
 162        # check if all nations in the list exist in the world nations
 163        return all(canonicalize(nation) in world_nations for nation in nations)
 164
 165    def _init_logger(self):
 166        self.logger = logging.getLogger("NSDotPy")
 167        config = {
 168            "version": 1,
 169            "disable_existing_loggers": False,
 170            "formatters": {
 171                "f": {
 172                    "format": "%(asctime)s %(message)s",
 173                    "datefmt": "%I:%M:%S %p",
 174                }
 175            },
 176            "handlers": {
 177                "console": {
 178                    "class": "logging.StreamHandler",
 179                    "formatter": "f",
 180                }
 181            },
 182            "loggers": {
 183                "NSDotPy": {"handlers": ["console"], "level": "INFO"},
 184                "httpx": {"handlers": ["console"], "level": "ERROR"},
 185            },
 186        }
 187        logging.config.dictConfig(config)
 188
 189    def _get_auth_values(self, response: httpx.Response):
 190        # make sure it's actually html first
 191        if not response.headers["Content-Type"].startswith("text/html"):
 192            return
 193        # parse the html
 194        soup = BeautifulSoup(response.text, "lxml")
 195        # gathering chk and localid so i dont have to worry about authenticating l8r
 196        if chk := soup.find("input", {"name": "chk"}):
 197            self.chk = chk["value"].strip()  # type: ignore
 198        if localid := soup.find("input", {"name": "localid"}):
 199            self.localid = localid["value"].strip()  # type: ignore
 200        if pin := self._session.cookies.get("pin"):
 201            # you should never really need the pin but just in case i'll store it
 202            # PAST ME WAS RIGHT, I NEEDED IT FOR THE PRIVATE API!!
 203            self.pin = pin
 204        if soup.find("a", {"class": "STANDOUT"}):
 205            self.region = canonicalize(
 206                soup.find_all("a", {"class": "STANDOUT"})[1].attrs["href"].split("=")[1]
 207            )
 208
 209    def _wait_for_input(self, key: str) -> int:
 210        """Blocks execution until the user presses a key. Used as the one click = one request action.
 211
 212        Args:
 213            key (str): The key to wait for
 214
 215        Returns:
 216            int: Userclick parameter, milliseconds since the epoch"""
 217        keyboard.wait(key)
 218        # the trigger_on_release parameter is broken on windows
 219        # because of a bug in keyboard so we have to do this
 220        while keyboard.is_pressed(key):
 221            pass
 222        return int(time.time() * 1000)
 223
 224    def _get_detag_wfe(self) -> str:
 225        """Gets the detagged WFE of the region you're in.
 226
 227        Returns:
 228            str: The detagged WFE"""
 229        self.logger.info(f"Getting detag WFE for {self.region}...")
 230        response = self.request(
 231            f"https://greywardens.xyz/tools/wfe_index/region={self.region}",
 232        )
 233        soup = BeautifulSoup(response.text, "lxml")
 234        # the safest bet for a detag wfe is the first wfe of the region
 235        return soup.find_all("pre")[-1].text
 236
 237    def _validate_fields(self, data: dict):
 238        max_lengths = {
 239            "pretitle": 28,
 240            "slogan": 55,
 241            "currency": 40,
 242            "animal": 40,
 243            "demonym_noun": 44,
 244            "demonym_adjective": 44,
 245            "demonym_plural": 44,
 246        }
 247
 248        # go through each key in the data dict and make sure they're below the max length
 249        for key, value in data.items():
 250            if key not in max_lengths:
 251                continue
 252            if len(value) > max_lengths[key]:
 253                raise ValueError(f"{key} is too long, max length is {max_lengths[key]}")
 254            if len(value) < 2 and key != "slogan":
 255                raise ValueError(f"{key} should have a minimum length of 2 characters.")
 256            # check if pretitle contains any non-alphanumeric characters (except spaces)
 257            if key == "pretitle" and not value.replace(" ", "").isalnum():
 258                raise ValueError(
 259                    "Pretitle should only contain alphanumeric characters or spaces."
 260                )
 261
 262    def _wait_for_ratelimit(self, head: dict, constant_rate_limit: bool):
 263        if "X-Pin" in head:
 264            self.pin = head["X-Pin"]
 265        if waiting_time := head.get("Retry-After"):
 266            self.logger.warning(f"Rate limited. Waiting {waiting_time} seconds.")
 267            time.sleep(int(waiting_time))
 268        # slow down requests so we dont hit the rate limit in the first place
 269        requests_left = int(head["RateLimit-Remaining"])
 270        if requests_left < 10 or constant_rate_limit:
 271            seconds_until_reset = int(head["RateLimit-Reset"])
 272            time.sleep(seconds_until_reset / requests_left)
 273
 274    def _html_request(
 275        self, url, data={}, files=None, follow_redirects=False
 276    ) -> httpx.Response:
 277        data |= {"chk": self.chk, "localid": self.localid}
 278        userclick = self._wait_for_input(self.keybind)
 279        # userclick is the number of milliseconds since the epoch, admin uses this for help enforcing the simultaneity rule
 280        response = self._session.post(
 281            f"{url}/userclick={userclick}",
 282            data=data,
 283            files=files,
 284            follow_redirects=follow_redirects,
 285        )
 286        if response.status_code >= 400:
 287            with open("error.html", "w") as f:
 288                f.write(response.text)
 289            raise httpx.HTTPError(
 290                f"Received status code {response.status_code} from {response.url}. Error page saved to error.html."
 291            )
 292        self._get_auth_values(response)
 293        return response
 294
 295    # --- end private methods --- #
 296
 297    def refresh_auth_values(self):
 298        self.logger.info("Refreshing authentication values...")
 299        response = self.request(
 300            f"https://www.nationstates.net/page=display_region/region={self._auth_region}",
 301            data={"theme": "century"},
 302        )
 303        self._get_auth_values(response)
 304
 305    def request(
 306        self,
 307        url: str,
 308        data: dict = {},
 309        files: dict = {},
 310        follow_redirects: bool = False,
 311    ) -> httpx.Response:
 312        """Sends a request to the given url with the given data and files.
 313
 314        Args:
 315            url (str): URL to send the request to
 316            data (dict, optional): Payload to send with the request
 317            files (dict, optional): Payload to send with requests that upload files
 318
 319        Returns:
 320            httpx.Response: The response from the server
 321        """
 322        if any(
 323            banned_page in canonicalize(url)
 324            for banned_page in {
 325                "page=telegrams",
 326                "page=dilemmas",
 327                "page=compose_telegram",
 328                "page=store",
 329                "page=help",
 330            }
 331        ):
 332            raise ValueError(
 333                "You cannot use a tool to interact with telegrams, issues, getting help, or the store. Read up on the script rules: https://forum.nationstates.net/viewtopic.php?p=16394966#p16394966"
 334            )
 335        if "api.cgi" in canonicalize(url):
 336            # you should be using api_request for api requests
 337            raise ValueError("You should be using api_request() for api requests.")
 338        elif "nationstates" in canonicalize(url):
 339            # do all the things that need to be done for html requests
 340            if self._lock:
 341                # if lock is true then we're already in the middle of a
 342                # request and we're in danger of breaking the simultaneity rule
 343                # so raise an error
 344                raise PermissionError(
 345                    "You're already in the middle of a request. Stop trying to violate simultaneity."
 346                )
 347            self._lock = True
 348            response = self._html_request(url, data, files, follow_redirects)
 349            self._lock = False
 350        else:
 351            # if its not nationstates then just pass the request through
 352            response = self._session.post(
 353                url, data=data, follow_redirects=follow_redirects
 354            )
 355        return response
 356
 357    def api_request(
 358        self,
 359        api: str,
 360        *,
 361        target: str = "",
 362        shard: str | set[str] = "",
 363        password: str = "",
 364        constant_rate_limit: bool = False,
 365    ) -> benedict:
 366        """Sends a request to the nationstates api with the given data.
 367
 368        Args:
 369            api (str): The api to send the request to. Must be "nation", "region", "world", or "wa"
 370            target (str, optional): The nation, region, or wa council to target. Required for non-world api requests.
 371            shard (str, optional): The shard, or shards, you're requesting for. Must be a valid shard for the given api. Only required for world and wa api requests.
 372            password (str, optional): The password to use for authenticating private api requests. Defaults to "". Not required if already signed in, whether through the api or through the HTML site.
 373            constant_rate_limit (bool, optional): If True, will always rate limit. If False, will only rate limit when there's less than 10 requests left in the current bucket. Defaults to False.
 374
 375        Returns:
 376            benedict: A benedict object containing the response from the server. Acts like a dictionary, with keypath and keylist support.
 377        """
 378        # TODO: probably move this responsibility to a third party api library to avoid reinventing the wheel
 379        # if one exists of sufficient quality thats AGPLv3 compatible
 380        if api not in {"nation", "region", "world", "wa"}:
 381            raise ValueError("api must be 'nation', 'region', 'world', or 'wa'")
 382        if api != "world" and not target:
 383            raise ValueError("target must be specified for non-world api requests")
 384        if api in {"wa", "world"} and not shard:
 385            raise ValueError("shard must be specified for world and wa api requests")
 386        # end argument validation
 387        # shard validation
 388        if type(shard) == str:
 389            shard = {shard}
 390        self._validate_shards(api, shard)  # type: ignore
 391        # end shard validation
 392        data = {
 393            "v": "12",
 394        }
 395        if api != "world":
 396            data[api] = target
 397        if shard:
 398            data["q"] = "+".join(shard)
 399        url = "https://www.nationstates.net/cgi-bin/api.cgi"
 400        if password:
 401            self._session.headers["X-Password"] = password
 402        if self.pin:
 403            self._session.headers["X-Pin"] = self.pin
 404        # rate limiting section
 405        response = self._session.post(url, data=data)
 406        # if the server tells us to wait, wait
 407        self._wait_for_ratelimit(response.headers, constant_rate_limit)
 408        response.raise_for_status()
 409        parsed_response = benedict.from_xml(response.text, keyattr_dynamic=True)
 410        parsed_response.standardize()
 411        parsed_response: benedict = parsed_response[api]  # type: ignore
 412        return parsed_response
 413
 414    def api_issue(
 415        self,
 416        nation: str,
 417        issue: int,
 418        option: int,
 419        password: str = "",
 420        constant_rate_limit: bool = False,
 421    ) -> benedict:
 422        """Answers an issue via the API.
 423
 424        Args:
 425            nation (str): The nation to perform the command with.
 426            issue (int): the ID of the issue.
 427            option (int): the issue option to choose.
 428            password (str, optional): The password to use for authenticating private api requests. Defaults to "". Not required if already signed in, whether through the api or through the HTML site.
 429            constant_rate_limit (bool, optional): If True, will always rate limit. If False, will only rate limit when there's less than 10 requests left in the current bucket. Defaults to False.
 430
 431        Returns:
 432            benedict: A benedict object containing the response from the server. Acts like a dictionary, with keypath and keylist support.
 433        """
 434        if not (password or self.pin):
 435            raise ValueError("must specify authentication")
 436        data = {
 437            "v": "12",
 438            "c": "issue",
 439            "nation": canonicalize(nation),
 440            "issue": issue,
 441            "option": option,
 442        }
 443        url = "https://www.nationstates.net/cgi-bin/api.cgi"
 444        if password:
 445            self._session.headers["X-Password"] = password
 446        if self.pin:
 447            self._session.headers["X-Pin"] = self.pin
 448        # rate limiting section
 449        response = self._session.get(url, params=data)
 450        # if the server tells us to wait, wait
 451        self._wait_for_ratelimit(response.headers, constant_rate_limit)
 452        response.raise_for_status()
 453        parsed_response = benedict.from_xml(response.text, keyattr_dynamic=True)
 454        parsed_response.standardize()
 455        parsed_response: benedict = parsed_response["nation"]  # type: ignore
 456        return parsed_response
 457
 458    def api_command(
 459        self,
 460        nation: str,
 461        command: str,
 462        data: dict,
 463        password: str = "",
 464        mode: str = "",
 465        constant_rate_limit: bool = False,
 466    ) -> benedict:
 467        """Sends a non-issue command to the nationstates api with the given data and password.
 468
 469        Args:
 470            nation (str): The nation to perform the command with.
 471            command (str): The command to perform. Must be "giftcard", "dispatch", "rmbpost"
 472            data (str, optional): The unique data to send with the parameters of the command; consult the API docs for more information.
 473            password (str, optional): The password to use for authenticating private api requests. Defaults to "". Not required if already signed in, whether through the api or through the HTML site.
 474            mode (str, optional): Whether to prepare or to execute the command. If value is given, does one of the two and returns result, if no value is given, does both and returns result of execute.
 475            constant_rate_limit (bool, optional): If True, will always rate limit. If False, will only rate limit when there's less than 10 requests left in the current bucket. Defaults to False.
 476
 477        Returns:
 478            benedict: A benedict object containing the response from the server. Acts like a dictionary, with keypath and keylist support.
 479        """
 480        if command not in {"giftcard", "dispatch", "rmbpost"}:
 481            raise ValueError("command must be 'giftcard', 'dispatch', or 'rmbpost'")
 482        if not (password or self.pin):
 483            raise ValueError("must specify authentication")
 484        if mode not in {"", "prepare", "execute"}:
 485            raise ValueError("mode must be prepare or execute")
 486        data["v"] = "12"
 487        data["nation"] = canonicalize(nation)
 488        data["c"] = command
 489        data["mode"] = mode if mode else "prepare"  # if no mode than first prepare
 490        url = "https://www.nationstates.net/cgi-bin/api.cgi"
 491        if password:
 492            self._session.headers["X-Password"] = password
 493        if self.pin:
 494            self._session.headers["X-Pin"] = self.pin
 495        # rate limiting section
 496        response = self._session.get(url, params=data)
 497        # if the server tells us to wait, wait
 498        self._wait_for_ratelimit(response.headers, constant_rate_limit)
 499        response.raise_for_status()
 500        parsed_response = benedict.from_xml(response.text, keyattr_dynamic=True)
 501        parsed_response.standardize()
 502        parsed_response: benedict = parsed_response["nation"]  # type: ignore
 503        if mode == "":
 504            # if no mode was specified earlier, repeat command with execute and token
 505            data["token"] = parsed_response["success"]
 506            return self.api_command(nation, command, data, mode="execute")
 507        else:
 508            return parsed_response
 509
 510    def api_giftcard(
 511        self,
 512        nation: str,
 513        card_id: int,
 514        season: int,
 515        recipient: str,
 516        password: str = "",
 517        constant_rate_limit: bool = False,
 518    ) -> benedict:
 519        """Gifts a card using the API.
 520
 521        Args:
 522            nation (str): The nation to perform the command with.
 523            card_id (int): The ID of the card to gift.
 524            season (int): The season of the card to gift.
 525            recipient (str): The nation to gift the card to.
 526            password (str, optional): The password to use for authenticating private api requests. Defaults to "". Not required if already signed in, whether through the api or through the HTML site.
 527            constant_rate_limit (bool, optional): If True, will always rate limit. If False, will only rate limit when there's less than 10 requests left in the current bucket. Defaults to False.
 528
 529        Returns:
 530            benedict: A benedict object containing the response from the server. Acts like a dictionary, with keypath and keylist support.
 531        """
 532        data = {"cardid": card_id, "season": season, "to": canonicalize(recipient)}
 533        return self.api_command(
 534            nation, "giftcard", data, password, constant_rate_limit=constant_rate_limit
 535        )
 536
 537    def api_dispatch(
 538        self,
 539        nation: str,
 540        action: str,
 541        title: str = "",
 542        text: str = "",
 543        category: int = 0,
 544        subcategory: int = 0,
 545        dispatchid: int = 0,
 546        password: str = "",
 547        constant_rate_limit: bool = False,
 548    ) -> benedict:
 549        """Add, edit, or remove a dispatch.
 550
 551        Args:
 552            nation (str): The nation to perform the command with.
 553            action (str): The action to take. Must be "add", "edit", "remove"
 554            title (str, optional): The dispatch title when adding or editing.
 555            text (str, optional): The dispatch text when adding or editing.
 556            category: (int, optional), The category ID when adding or editing.
 557            subcategory (int, optional): The subcategory ID when adding or editing.
 558            dispatchid (int, optional): The dispatch ID when editing or removing.
 559            password (str, optional): The password to use for authenticating private api requests. Defaults to "". Not required if already signed in, whether through the api or through the HTML site.
 560            constant_rate_limit (bool, optional): If True, will always rate limit. If False, will only rate limit when there's less than 10 requests left in the current bucket. Defaults to False.
 561
 562        Returns:
 563            benedict: A benedict object containing the response from the server. Acts like a dictionary, with keypath and keylist support.
 564        """
 565        # TODO: maybe consider splitting these three functions?
 566        # TODO: maybe create enums for category and subcategory
 567        if action not in {"add", "edit", "remove"}:
 568            raise ValueError("action must be 'add', 'edit', or 'remove'")
 569        if action != "remove" and not all({title, text, category, subcategory}):
 570            raise ValueError("must specify title, text, category, and subcategory")
 571        if action != "add" and not dispatchid:
 572            raise ValueError("must specify a dispatch id")
 573
 574        data = {"dispatch": action}
 575        if title:
 576            data["title"] = title
 577        if text:
 578            data["text"] = text
 579        if category:
 580            data["category"] = category
 581        if subcategory:
 582            data["subcategory"] = subcategory
 583        if dispatchid:
 584            data["dispatchid"] = dispatchid
 585        return self.api_command(
 586            nation, "dispatch", data, password, constant_rate_limit=constant_rate_limit
 587        )
 588
 589    def api_rmb(
 590        self,
 591        nation: str,
 592        region: str,
 593        text: str,
 594        password: str = "",
 595        constant_rate_limit: bool = False,
 596    ) -> benedict:
 597        """Post a message on the regional message board via the API.
 598
 599        Args:
 600            nation (str): The nation to perform the command with.
 601            region (str): the region to post the message in.
 602            text (str): the text to post.
 603            password (str, optional): The password to use for authenticating private api requests. Defaults to "". Not required if already signed in, whether through the api or through the HTML site.
 604            constant_rate_limit (bool, optional): If True, will always rate limit. If False, will only rate limit when there's less than 10 requests left in the current bucket. Defaults to False.
 605
 606        Returns:
 607            benedict: A benedict object containing the response from the server. Acts like a dictionary, with keypath and keylist support.
 608        """
 609        data = {"region": region, "text": text}
 610        return self.api_command(
 611            nation, "rmbpost", data, password, constant_rate_limit=constant_rate_limit
 612        )
 613
 614    def login(self, nation: str, password: str) -> bool:
 615        """Logs in to the nationstates site.
 616
 617        Args:
 618            nation (str): Nation name
 619            password (str): Nation password
 620
 621        Returns:
 622            bool: True if login was successful, False otherwise
 623        """
 624        self.logger.info(f"Logging in to {nation}")
 625        url = f"https://www.nationstates.net/page=display_region/region={self._auth_region}"
 626        # shoutouts to roavin for telling me i had to have page=display_region in the url so it'd work with a userclick parameter
 627
 628        data = {
 629            "nation": canonicalize(nation),
 630            "password": password,
 631            "theme": "century",
 632            "logging_in": "1",
 633            "submit": "Login",
 634        }
 635
 636        response = self.request(url, data)
 637
 638        soup = BeautifulSoup(response.text, "lxml")
 639        # checks if the body tag has your nation name in it; if it does, you're logged in
 640        if not soup.find("body", {"data-nname": canonicalize(nation)}):
 641            return False
 642
 643        self.nation = canonicalize(nation)
 644        return True
 645
 646    def change_nation_flag(self, flag_filename: str) -> bool:
 647        """Changes the nation flag to the given image.
 648
 649        Args:
 650            flag_filename (str): Filename of the flag to change to
 651
 652        Returns:
 653            bool: True if the flag was changed, False otherwise
 654        """
 655        self.logger.info(f"Changing flag on {self.nation}")
 656        # THIS WAS SO FUCKING FRUSTRATING BUT IT WORKS NOW AND IM NEVER TOUCHING THIS BULLSHIT UNLESS NS BREAKS IT AGAIN
 657        url = "https://www.nationstates.net/cgi-bin/upload.cgi"
 658
 659        data = {
 660            "nationname": self.nation,
 661        }
 662        files = {
 663            "file": (
 664                flag_filename,
 665                open(flag_filename, "rb"),
 666                mimetypes.guess_type(flag_filename)[0],
 667            )
 668        }
 669
 670        response = self.request(url, data=data, files=files)
 671
 672        if "page=settings" in response.headers["location"]:
 673            self.refresh_auth_values()
 674            return True
 675        elif "Just a moment..." in response.text:
 676            self.logger.warning(
 677                "Cloudflare blocked you idiot get fucked have fun with that like I had to lmaoooooooooo"
 678            )
 679        return False
 680
 681    def change_nation_settings(
 682        self,
 683        *,
 684        email: str = "",
 685        pretitle: str = "",
 686        slogan: str = "",
 687        currency: str = "",
 688        animal: str = "",
 689        demonym_noun: str = "",
 690        demonym_adjective: str = "",
 691        demonym_plural: str = "",
 692        new_password: str = "",
 693    ) -> bool:
 694        """Given a logged in session, changes customizable fields and settings of the logged in nation.
 695        Variables must be explicitly named in the call to the function, e.g. "session.change_nation_settings(pretitle='Join Lily', currency='Join Lily')"
 696
 697        Args:
 698            email (str, optional): New email for WA apps.
 699            pretitle (str, optional): New pretitle of the nation. Max length of 28. Nation must have minimum population of 250 million.
 700            slogan (str, optional): New Slogan/Motto of the nation. Max length of 55.
 701            currency (str, optional): New currency of the nation. Max length of 40.
 702            animal (str, optional): New national animal of the nation. Max length of 40.
 703            demonym_noun (str, optional): Noun the nation will refer to its citizens as. Max length of 44.
 704            demonym_adjective (str, optional): Adjective the nation will refer to its citizens as. Max length of 44.
 705            demonym_plural (str, optional): Plural form of "demonym_noun". Max length of 44.
 706            new_password (str, optional): New password to assign to the nation.
 707
 708        Returns:
 709            bool: True if changes were successful, False otherwise.
 710        """
 711        self.logger.info(f"Changing settings on {self.nation}")
 712        url = "https://www.nationstates.net/template-overall=none/page=settings"
 713
 714        data = {
 715            "type": pretitle,
 716            "slogan": slogan,
 717            "currency": currency,
 718            "animal": animal,
 719            "demonym2": demonym_noun,
 720            "demonym": demonym_adjective,
 721            "demonym2pl": demonym_plural,
 722            "email": email,
 723            "password": new_password,
 724            "confirm_password": new_password,
 725            "update": " Update ",
 726        }
 727        # remove keys that have empty values
 728        data = {k: v for k, v in data.items() if v}
 729        # make sure everything is following the proper length limits and only contains acceptable characters
 730        self._validate_fields(data)
 731
 732        response = self.request(url, data)
 733        return "Your settings have been successfully updated." in response.text
 734
 735    def move_to_region(self, region: str, password: str = "") -> bool:
 736        """Moves the nation to the given region.
 737
 738        Args:
 739            region (str): Region to move to
 740            password (str, optional): Region password, if the region is passworded
 741
 742        Returns:
 743            bool: True if the move was successful, False otherwise
 744        """
 745        self.logger.info(f"Moving {self.nation} to {region}")
 746        url = "https://www.nationstates.net/template-overall=none/page=change_region"
 747
 748        data = {"region_name": region, "move_region": "1"}
 749        if password:
 750            data["password"] = password
 751        response = self.request(url, data)
 752
 753        if "Success!" in response.text:
 754            self.region = canonicalize(region)
 755            return True
 756        return False
 757
 758    def vote(self, pollid: str, option: str) -> bool:
 759        """Votes on a poll.
 760
 761        Args:
 762            pollid (str): ID of the poll to vote on, e.g. "199747"
 763            option (str): Option to vote for (starts at 0)
 764
 765        Returns:
 766            bool: True if the vote was successful, False otherwise
 767        """
 768        self.logger.info(f"Voting on poll {pollid} with {self.nation}")
 769        url = f"https://www.nationstates.net/template-overall=none/page=poll/p={pollid}"
 770
 771        data = {"pollid": pollid, "q1": option, "poll_submit": "1"}
 772        response = self.request(url, data)
 773
 774        return "Your vote has been lodged." in response.text
 775
 776    # below are functions that are related to the WA
 777
 778    def join_wa(self, nation: str, app_id: str) -> bool:
 779        """Joins the WA with the given nation.
 780
 781        Args:
 782            nation (str): Nation to join the WA with
 783            app_id (str): ID of the WA application to use
 784
 785        Returns:
 786            bool: True if the join was successful, False otherwise
 787        """
 788        self.logger.info(f"Joining WA with {nation}")
 789        url = "https://www.nationstates.net/cgi-bin/join_un.cgi"
 790
 791        data = {"nation": canonicalize(nation), "appid": app_id.strip()}
 792        response = self.request(url, data)
 793
 794        if "?welcome=1" in response.headers["location"]:
 795            # since we're just getting thrown into a cgi script, we'll have to manually grab authentication values
 796            self.refresh_auth_values()
 797            return True
 798        return False
 799
 800    def resign_wa(self):
 801        """Resigns from the WA.
 802
 803        Returns:
 804            bool: True if the resignation was successful, False otherwise
 805        """
 806        self.logger.info("Resigning from WA")
 807        url = "https://www.nationstates.net/template-overall=none/page=UN_status"
 808
 809        data = {"action": "leave_UN", "submit": "1"}
 810        response = self.request(url, data)
 811
 812        return "From this moment forward, your nation is on its own." in response.text
 813
 814    def apply_wa(self, reapply: bool = True) -> bool:
 815        """Applies to the WA.
 816
 817        Args:
 818            reapply (bool, optional): Whether to reapply if you've been sent an application that's still valid. Defaults to True.
 819
 820        Returns:
 821            bool: True if the application was successful, False otherwise
 822        """
 823        self.logger.info(f"Applying to WA with {self.nation}")
 824        url = "https://www.nationstates.net/template-overall=none/page=UN_status"
 825
 826        data = {"action": "join_UN"}
 827        if reapply:
 828            data["resend"] = "1"
 829        else:
 830            data["submit"] = "1"
 831
 832        response = self.request(url, data)
 833        return (
 834            "Your application to join the World Assembly has been received!"
 835            in response.text
 836        )
 837
 838    def endorse(self, nation: str, endorse: bool = True) -> bool:
 839        """Endorses the given nation.
 840
 841        Args:
 842            nation (str): Nation to endorse
 843            endorse (bool, optional): True=endorse, False=unendorse. Defaults to True.
 844
 845        Returns:
 846            bool: True if the endorsement was successful, False otherwise
 847        """
 848        self.logger.info(
 849            f"{('Unendorsing', 'Endorsing')[endorse]} {nation} with {self.nation}"
 850        )
 851        url = "https://www.nationstates.net/cgi-bin/endorse.cgi"
 852
 853        data = {
 854            "nation": canonicalize(nation),
 855            "action": "endorse" if endorse else "unendorse",
 856        }
 857        response = self.request(url, data)
 858
 859        return f"nation={canonicalize(nation)}" in response.headers["location"]
 860
 861    def clear_dossier(self) -> bool:
 862        """Clears a logged in nation's dossier.
 863
 864        Returns:
 865            bool: Whether it was successful or not
 866        """
 867
 868        self.logger.info(f"Clearing dossier on {self.nation}")
 869        url = "https://www.nationstates.net/template-overall=none/page=dossier"
 870        data = {"clear_dossier": "1"}
 871        response = self.request(url, data)
 872
 873        return "Dossier cleared of nations." in response.text
 874
 875    def add_to_dossier(self, nations: list[str] | str) -> bool:
 876        """Adds nations to the logged in nation's dossier.
 877
 878        Args:
 879            nations (list[str] | str): List of nations to add, or a single nation
 880
 881        Returns:
 882            bool: Whether it was successful or not
 883        """
 884
 885        self.logger.info(f"Adding {nations} to dossier on {self.nation}")
 886        url = "https://www.nationstates.net/dossier.cgi"
 887        data = {
 888            "currentnation": canonicalize(self.nation),
 889            "action_append": "Upload Nation Dossier File",
 890        }
 891        files = {
 892            "file": (
 893                "dossier.txt",
 894                "\n".join(nations).strip() if type(nations) is list else nations,
 895                "text/plain",
 896            ),
 897        }
 898        response = self.request(url, data, files=files)
 899
 900        self.refresh_auth_values()
 901        return "appended=" in response.headers["location"]
 902
 903    def wa_vote(self, council: str, vote: str) -> bool:
 904        """Votes on the current WA resolution.
 905
 906        Args:
 907            council (str): Must be "ga" for general assembly, "sc" for security council.
 908            vote (str): Must be "for" or "against".
 909
 910        Returns:
 911            bool: Whether the vote was successful or not
 912        """
 913        self.logger.info(
 914            f"Voting {vote} on {council.upper()} resolution with {self.nation}"
 915        )
 916        if council not in {"ga", "sc"}:
 917            raise ValueError("council must be 'ga' or 'sc'")
 918        if vote not in {"for", "against"}:
 919            raise ValueError("vote must be 'for' or 'against'")
 920        self.logger.info("Voting on WA resolution")
 921
 922        url = f"https://www.nationstates.net/template-overall=none/page={council}"
 923        data = {
 924            "vote": f"Vote {vote.capitalize()}",
 925        }
 926        response = self.request(url, data)
 927
 928        return "Your vote has been lodged." in response.text
 929
 930    def refound_nation(self, nation: str, password: str) -> bool:
 931        """Refounds a nation.
 932
 933        Args:
 934            nation (str): Name of the nation to refound
 935            password (str): Password to the nation
 936
 937        Returns:
 938            bool: Whether the nation was successfully refounded or not
 939        """
 940        url = "https://www.nationstates.net/template-overall=none/"
 941        data = {
 942            "logging_in": "1",
 943            "restore_password": password,
 944            "restore_nation": "1",
 945            "nation": nation,
 946        }
 947        response = self.request(url, data=data)
 948        if response.status_code == 302:
 949            self.nation = nation
 950            self.refresh_auth_values()
 951            return True
 952        return False
 953
 954    # methods for region control
 955
 956    def create_region(
 957        self,
 958        region_name: str,
 959        wfe: str,
 960        *,
 961        password: str = "",
 962        frontier: bool = False,
 963        executive_delegacy: bool = False,
 964    ) -> bool:
 965        """Creates a new region.
 966
 967        Args:
 968            region_name (str): Name of the region
 969            wfe (str): WFE of the region
 970            password (str, optional): Password to the region. Defaults to "".
 971            frontier (bool, optional): Whether or not the region is a frontier. Defaults to False.
 972            executive_delegacy (bool, optional): Whether or not the region has an executive WA delegacy. Defaults to False. Ignored if frontier is True.
 973
 974        Returns:
 975            bool: Whether the region was successfully created or not
 976        """
 977        self.logger.info(f"Creating new region {region_name}")
 978        url = "https://www.nationstates.net/template-overall=none/page=create_region"
 979        data = {
 980            "page": "create_region",
 981            "region_name": region_name.strip(),
 982            "desc": wfe.strip(),
 983            "create_region": "1",
 984        }
 985        if password:
 986            data |= {"pw": "1", "rpassword": password}
 987        if frontier:
 988            data |= {"is_frontier": "1"}
 989        elif executive_delegacy:
 990            data |= {"delegate_control": "1"}
 991        response = self.request(url, data)
 992        return "Success! You have founded " in response.text
 993
 994    def upload_to_region(self, type: str, filename: str) -> str:
 995        """Uploads a file to the current region.
 996
 997        Args:
 998            type (str): Type of file to upload. Must be "flag" or "banner".
 999            filename (str): Name of the file to upload. e.g. "myflag.png"
1000
1001        Raises:
1002            ValueError: If type is not "flag" or "banner"
1003
1004        Returns:
1005            str: Empty string if the upload failed, otherwise the ID of the uploaded file
1006        """
1007        self.logger.info(f"Uploading {filename} to {self.region}")
1008        if type not in {"flag", "banner"}:
1009            raise ValueError("type must be 'flag' or 'banner'")
1010        url = "https://www.nationstates.net/cgi-bin/upload.cgi"
1011        data = {
1012            "uploadtype": f"r{type}",
1013            "page": "region_control",
1014            "region": self.region,
1015            "expect": "json",
1016        }
1017        files = {
1018            f"file_upload_r{type}": (
1019                filename,
1020                open(filename, "rb"),
1021                mimetypes.guess_type(filename)[0],
1022            )
1023        }
1024        response = self.request(url, data, files=files)
1025        return "" if "id" not in response.json() else response.json()["id"]
1026
1027    def set_flag_and_banner(
1028        self, flag_id: str = "", banner_id: str = "", flag_mode: str = ""
1029    ) -> bool:
1030        """Sets the uploaded flag and/or banner for the current region.
1031
1032        Args:
1033            flag_id (str, optional): ID of the flag, uploaded with upload_to_region(). Defaults to "".
1034            banner_id (str, optional): ID of the banner, uploaded with upload_to_region(). Defaults to "".
1035            flagmode (str, optional): Must be "flag" which will have a shadow, or "logo" which will not, or "" to not change it. Defaults to "".
1036
1037        Raises:
1038            ValueError: If flagmode is not "flag", "logo", or ""
1039
1040        Returns:
1041            bool: Whether the change was successful or not
1042        """
1043        if flag_mode not in {"flag", "logo", ""}:
1044            raise ValueError("flagmode must be 'flag', 'logo', or ''")
1045        self.logger.info(f"Setting flag and banner for {self.region}")
1046        url = "https://www.nationstates.net/template-overall=none/page=region_control/"
1047        data = {
1048            "newflag": flag_id,
1049            "newbanner": banner_id,
1050            "saveflagandbannerchanges": "1",
1051            "flagmode": flag_mode,
1052        }
1053        # remove entries with empty values
1054        data = {k: v for k, v in data.items() if v}
1055
1056        response = self.request(url, data)
1057
1058        return "Regional banner/flag updated!" in response.text
1059
1060    def change_wfe(self, wfe: str = "") -> bool:
1061        """Changes the WFE of the current region.
1062
1063        Args:
1064            wfe (str, optional): World Factbook Entry to change to. Defaults to the oldest WFE the region has, for detags.
1065
1066        Returns:
1067            bool: True if successful, False otherwise
1068        """
1069        self.logger.info(f"Changing WFE for {self.region}")
1070        if not wfe:
1071            wfe = self._get_detag_wfe()  # haku im sorry for hitting your site so much
1072        url = "https://www.nationstates.net/template-overall=none/page=region_control/"
1073        data = {
1074            "message": wfe.encode("iso-8859-1", "xmlcharrefreplace")
1075            .decode()
1076            .strip(),  # lol.
1077            "setwfebutton": "1",
1078        }
1079        response = self.request(url, data)
1080        return "World Factbook Entry updated!" in response.text
1081
1082    # methods for embassies
1083
1084    def request_embassy(self, target: str) -> bool:
1085        """Requests an embassy with a region.
1086
1087        Args:
1088            target (str): The region to request the embassy with.
1089
1090        Returns:
1091            bool: Whether the request was successfully sent or not
1092        """
1093        self.logger.info(f"Requesting embassy with {target}")
1094        url = "https://www.nationstates.net/template-overall=none/page=region_control/"
1095        data = {
1096            "requestembassyregion": target,
1097            "requestembassy": "1",  # it's silly that requesting needs this but not closing, aborting, or cancelling
1098        }
1099        response = self.request(url, data)
1100        return "Your proposal for the construction of embassies with" in response.text
1101
1102    def close_embassy(self, target: str) -> bool:
1103        """Closes an embassy with a region.
1104
1105        Args:
1106            target (str): The region with which to close the embassy.
1107
1108        Returns:
1109            bool: Whether the embassy was successfully closed or not
1110        """
1111        self.logger.info(f"Closing embassy with {target}")
1112        url = "https://www.nationstates.net/template-overall=none/page=region_control/"
1113        data = {"cancelembassyregion": target}
1114        response = self.request(url, data)
1115        return " has been scheduled for demolition." in response.text
1116
1117    def abort_embassy(self, target: str) -> bool:
1118        """Aborts an embassy with a region.
1119
1120        Args:
1121            target (str): The region with which to abort the embassy.
1122
1123        Returns:
1124            bool: Whether the embassy was successfully aborted or not
1125        """
1126        self.logger.info(f"Aborting embassy with {target}")
1127        url = "https://www.nationstates.net/template-overall=none/page=region_control/"
1128        data = {"abortembassyregion": target}
1129        response = self.request(url, data)
1130        return " aborted." in response.text
1131
1132    def cancel_embassy(self, target: str) -> bool:
1133        """Cancels an embassy with a region.
1134
1135        Args:
1136            target (str): The region with which to cancel the embassy.
1137
1138        Returns:
1139            bool: Whether the embassy was successfully cancelled or not
1140        """
1141        self.logger.info(f"Cancelling embassy with {target}")
1142        url = "https://www.nationstates.net/template-overall=none/page=region_control/"
1143        data = {"cancelembassyclosureregion": target}
1144        response = self.request(url, data)
1145        return "Embassy closure order cancelled." in response.text
1146
1147    # end methods for embassies
1148
1149    def tag(self, action: str, tag: str) -> bool:
1150        """Adds or removes a tag to the current region.
1151
1152        Args:
1153            action (str): The action to take. Must be "add" or "remove".
1154            tag (str): The tag to add or remove.
1155
1156        Raises:
1157            ValueError: If action is not "add" or "remove", or if tag is not a valid tag.
1158
1159        Returns:
1160            bool: Whether the tag was successfully added or removed
1161        """
1162        if action not in {"add", "remove"}:
1163            raise ValueError("action must be 'add' or 'remove'")
1164        if canonicalize(tag) not in valid.REGION_TAGS:
1165            raise ValueError(f"{tag} is not a valid tag")
1166        self.logger.info(f"{action.capitalize()}ing tag {tag} for {self.region}")
1167        url = "https://www.nationstates.net/template-overall=none/page=region_control/"
1168        data = {
1169            f"{action}_tag": canonicalize(tag),
1170            "updatetagsbutton": "1",
1171        }
1172        response = self.request(url, data)
1173        return "Region Tags updated!" in response.text
1174
1175    def eject(self, nation: str) -> bool:
1176        """Ejects a nation from the current region. Note that a 1 second delay is required before ejecting another nation.
1177
1178        Args:
1179            nation (str): The nation to eject.
1180
1181        Returns:
1182            bool: Whether the nation was successfully ejected or not
1183        """
1184        self.logger.info(f"Ejecting {nation} from {self.region}")
1185        url = "https://www.nationstates.net/template-overall=none/page=region_control/"
1186        data = {"nation_name": nation, "eject": "1"}
1187        response = self.request(url, data)
1188        return "has been ejected from " in response.text
1189
1190    def banject(self, nation: str) -> bool:
1191        """Bans a nation from the current region. Note that a 1 second delay is required before banjecting another nation.
1192
1193        Args:
1194            nation (str): The nation to banject.
1195
1196        Returns:
1197            bool: Whether the nation was successfully banjected or not
1198        """
1199        self.logger.info(f"Banjecting {nation} from {self.region}")
1200        url = "https://www.nationstates.net/template-overall=none/page=region_control/"
1201        data = {"nation_name": nation, "ban": "1"}
1202        response = self.request(url, data)
1203        return "has been ejected and banned from " in response.text
1204
1205    # end methods for region control
1206
1207    def junk_card(self, id: str, season: str) -> bool:
1208        """Junks a card from the current nation's deck.
1209        Args:
1210            id (str): ID of the card to junk
1211            season (str): Season of the card to junk
1212        Returns:
1213            bool: Whether the card was successfully junked or not
1214        """
1215        self.logger.info(f"Junking card {id} from season {season}")
1216        url = "https://www.nationstates.net/template-overall=none/page=deck"
1217
1218        data = {"page": "ajax3", "a": "junkcard", "card": id, "season": season}
1219        response = self.request(url, data)
1220
1221        return "Your Deck" in response.text
1222
1223    def open_pack(self) -> bool:
1224        """Opens a card pack.
1225
1226        Returns:
1227            bool: Whether the bid was successfully removed or not
1228        """
1229        self.logger.info("Opening trading card pack")
1230        url = "https://www.nationstates.net/template-overall=none/page=deck"
1231        data = {"open_loot_box": "1"}
1232        response = self.request(url, data)
1233        return "Tap cards to reveal..." in response.text
1234
1235    def ask(self, price: str, card_id: str, season: str) -> bool:
1236        """Puts an ask at price on a card in a season
1237
1238        Args:
1239            price (str): Price to ask
1240            card_id (str): ID of the card
1241            season (str): Season of the card
1242
1243        Returns:
1244            bool: Whether the ask was successfully lodged or not
1245        """
1246        self.logger.info(f"Asking for {price} on {card_id} season {season}")
1247        url = f"https://www.nationstates.net/template-overall=none/page=deck/card={card_id}/season={season}"
1248
1249        data = {"auction_ask": price, "auction_submit": "ask"}
1250        response = self.request(url, data)
1251        return f"Your ask of {price} has been lodged." in response.text
1252
1253    def bid(self, price: str, card_id: str, season: str) -> bool:
1254        """Places a bid on a card in a season
1255
1256        Args:
1257            price (str): Amount of bank to bid
1258            card_id (str): ID of the card
1259            season (str): Season of the card
1260
1261        Returns:
1262            bool: Whether the bid was successfully lodged or not
1263        """
1264        self.logger.info(f"Putting a bid for {price} on {card_id} season {season}")
1265        url = f"https://www.nationstates.net/template-overall=none/page=deck/card={card_id}/season={season}"
1266
1267        data = {"auction_bid": price, "auction_submit": "bid"}
1268        response = self.request(url, data)
1269
1270        return f"Your bid of {price} has been lodged." in response.text
1271
1272    def remove_ask(self, price: str, card_id: str, season: str) -> bool:
1273        """Removes an ask on card_id in season at price
1274
1275        Args:
1276            price (str): Price of the ask to remove
1277            card_id (str): ID of the card
1278            season (str): Season of the card
1279
1280        Returns:
1281            bool: Whether the ask was successfully removed or not
1282        """
1283
1284        self.logger.info(f"removing an ask for {price} on {card_id} season {season}")
1285        url = f"https://www.nationstates.net/template-overall=none/page=deck/card={card_id}/season={season}"
1286
1287        data = {"new_price": price, "remove_ask_price": price}
1288        response = self.request(url, data)
1289        return f"Removed your ask for {price}" in response.text
1290
1291    def remove_bid(self, price: str, card_id: str, season: str) -> bool:
1292        """Removes a bid on a card
1293
1294        Args:
1295            price (str): Price of the bid to remove
1296            card_id (str): ID of the card
1297            season (str): Season of the card
1298
1299        Returns:
1300            bool: Whether the bid was successfully removed or not
1301        """
1302
1303        self.logger.info(f"Removing a bid for {price} on {card_id} season {season}")
1304        url = f"https://www.nationstates.net/template-overall=none/page=deck/card={card_id}/season={season}"
1305
1306        data = {"new_price": price, "remove_bid_price": price}
1307        response = self.request(url, data)
1308
1309        return f"Removed your bid for {price}" in response.text
1310
1311    def expand_deck(self, price: str) -> bool:
1312        """Upgrades deck capacity
1313
1314        Args:
1315            price (str): Price of the Upgrade
1316
1317        Returns:
1318            bool: Whether the upgrade was successfully removed or not
1319        """
1320
1321        self.logger.info(f"Upgrading your deck at a cost of {price}")
1322        url = "https://www.nationstates.net/template-overall=none/page=deck"
1323
1324        data = {"embiggen_deck": price}
1325        response = self.request(url, data)
1326
1327        return "Increased deck capacity from" in response.text
1328
1329    def add_to_collection(self, card_id: str, card_season: str, collection_id: str):
1330        """Adds a card to collection_id
1331
1332        Args:
1333            card_id (str): Card ID
1334            card_season (str): Cards season
1335            collection_id (str): The ID of the collection you want to add to
1336
1337        Returns:
1338            bool: Whether the adding was successfully added or not
1339        """
1340        self.logger.info(f"Adding {card_id} of season {card_season} to {collection_id}")
1341        url = f"https://www.nationstates.net/template-overall=none/page=deck/card={card_id}/season={card_season}"
1342
1343        data = {
1344            "manage_collections": "1",
1345            "modify_card_in_collection": "1",
1346            f"collection_{collection_id}": "1",
1347            "save_collection": "1",
1348        }
1349        response = self.request(url, data)
1350
1351        return "Updated collections." in response.text
1352
1353    def remove_from_collection(
1354        self, card_id: str, card_season: str, collection_id: str
1355    ):
1356        """Removes a card from collection_id
1357
1358        Args:
1359            card_id (str): Card ID
1360            card_season (str): Cards season
1361            collection_id (str): The ID of the collection you want to remove from
1362
1363        Returns:
1364            bool: Whether the removal was successfully added or not
1365        """
1366        self.logger.info(
1367            f"Removing {card_id} of season {card_season} from {collection_id}"
1368        )
1369        url = f"https://www.nationstates.net/template-overall=none/page=deck/card={card_id}/season={card_season}"
1370
1371        data = {
1372            "manage_collections": "1",
1373            "modify_card_in_collection": "1",
1374            "start": "0",
1375            f"collection_{collection_id}": "0",
1376            "save_collection": "1",
1377        }
1378        response = self.request(url, data)
1379
1380        return "Updated collections." in response.text
1381
1382    def create_collection(self, name: str):
1383        """Creates a collection named name
1384
1385        Args:
1386            name (str): The name of the collection you want to create
1387
1388        Returns:
1389            bool: Whether the creating was successfully added or not
1390        """
1391        self.logger.info(f"Creating {name} collection")
1392        url = "https://www.nationstates.net/template-overall=none/page=deck"
1393
1394        data = {"edit": "1", "collection_name": name, "save_collection": "1"}
1395        response = self.request(url, data)
1396
1397        return "Created collection!" in response.text
1398
1399    def delete_collection(self, name: str):
1400        """Deletes a collection named name
1401
1402        Args:
1403            name (str): The name of the collection you want to delete
1404
1405        Returns:
1406            bool: Whether the deleting was successfully added or not
1407        """
1408        self.logger.info(f"Deleting {name} collection")
1409        url = "https://www.nationstates.net/template-overall=none/page=deck"
1410
1411        data = {"edit": "1", "collection_name": name, "delete_collection": "1"}
1412        response = self.request(url, data)
1413
1414        return "Created collection!" in response.text
1415
1416    def can_nation_be_founded(self, name: str):
1417        """Checks if a nation can be founded
1418
1419        Args:
1420            name (str): The name of the nation you want to check
1421
1422        Returns:
1423            bool: Whether the nation can be founded or not
1424        """
1425        self.logger.info(f"Checking {name} in boneyard")
1426        url = "https://www.nationstates.net/template-overall=none/page=boneyard"
1427
1428        data = {"nation": name, "submit": "1"}
1429        response = self.request(url, data)
1430
1431        return (
1432            "Available! This name may be used to found a new nation." in response.text
1433        )
1434
1435    def join_nday_faction(self, id: str):
1436        """Joins a faction in the N-Day event
1437
1438        Args:
1439            id (str): The ID of the faction you want to join
1440
1441        Returns:
1442            bool: Whether the joining was successful or not
1443        """
1444        self.logger.info(f"Joining faction {id}")
1445
1446        url = (
1447            f"https://www.nationstates.net/template-overall=none/page=faction/fid={id}"
1448        )
1449        data = {"join_faction": "1"}
1450        response = self.request(url, data)
1451
1452        return " has joined " in response.text
1453
1454    def leave_nday_faction(self, id: str):
1455        """Leaves a faction in the N-Day event
1456
1457        Args:
1458            id (str): The ID of the faction you want to leave
1459
1460        Returns:
1461            bool: Whether the leaving was successful or not
1462        """
1463        self.logger.info(f"Leaving faction {id}")
1464
1465        url = (
1466            f"https://www.nationstates.net/template-overall=none/page=faction/fid={id}"
1467        )
1468        data = {"leave_faction": "1"}
1469        response = self.request(url, data)
1470
1471        return " has left " in response.text
1472
1473
1474if __name__ == "__main__":
1475    print("this is a module/library, not a script")
def canonicalize(string: str) -> str:
34def canonicalize(string: str) -> str:
35    """Converts a string to its canonical form used by the nationstates api.
36
37    Args:
38        string (str): The string to convert
39
40    Returns:
41        str: The canonical form of the string
42    """
43    return string.lower().strip().replace(" ", "_")

Converts a string to its canonical form used by the nationstates api.

Arguments:
  • string (str): The string to convert
Returns:

str: The canonical form of the string

def uncanonicalize(string: str) -> str:
46def uncanonicalize(string: str) -> str:
47    """Converts a string into a user friendly form from it's canonical nationstates api form.
48
49    Args:
50        string (str): The string to convert
51
52    Returns:
53        str: The uncanonicale form of the string
54    """
55    return string.replace("_", " ").title()

Converts a string into a user friendly form from it's canonical nationstates api form.

Arguments:
  • string (str): The string to convert
Returns:

str: The uncanonicale form of the string

class NSSession:
  58class NSSession:
  59    def __init__(
  60        self,
  61        script_name: str,
  62        script_version: str,
  63        script_author: str,
  64        script_user: str,
  65        keybind: str = "space",
  66        link_to_src: str = "",
  67        logger: logging.Logger | None = None,
  68    ):
  69        """A wrapper around httpx that abstracts away
  70        interacting with the HTML nationstates.net site.
  71        Focused on legality, correctness, and ease of use.
  72
  73        Args:
  74            script_name (str): Name of your script
  75            script_version (str): Version number of your script
  76            script_author (str): Author of your script
  77            script_user (str): Nation name of the user running your script
  78            keybind (str, optional): Keybind to count as a user click. Defaults to "space".
  79            link_to_src (str, optional): Link to the source code of your script.
  80            logger (logging.Logger | None, optional): Logger to use. Will create its own with name "NSDotPy" if none is specified. Defaults to None.
  81        """
  82        self.VERSION = "2.3.0"
  83        # Initialize logger
  84        if not logger:
  85            self._init_logger()
  86        else:
  87            self.logger = logger
  88        # Create a new httpx session
  89        self._session = httpx.Client(
  90            http2=True, timeout=30
  91        )  # ns can b slow, 30 seconds is hopefully a good sweet spot
  92        # Set the user agent to the script name, version, author, and user as recommended in the script rules thread:
  93        # https://forum.nationstates.net/viewtopic.php?p=16394966&sid=be37623536dbc8cee42d8d043945b887#p16394966
  94        self._lock: bool = False
  95        self._set_user_agent(
  96            script_name, script_version, script_author, script_user, link_to_src
  97        )
  98        # Initialize nationstates specific stuff
  99        self._auth_region = "rwby"
 100        self.chk: str = ""
 101        self.localid: str = ""
 102        self.pin: str = ""
 103        self.nation: str = ""
 104        self.region: str = ""
 105        self.keybind = keybind
 106        # Make sure the nations in the user agent actually exist
 107        if not self._validate_nations({script_author, script_user}):
 108            raise ValueError(
 109                "One of, or both, of the nations in the user agent do not exist. Make sure you're only including the nation name in the constructor, e.g. 'Thorn1000' instead of 'Devved by Thorn1000'"
 110            )
 111        self.logger.info(f"Initialized. Keybind to continue is {self.keybind}.")
 112
 113    def _validate_shard(self, shard, valid_shards, api):
 114        """Helper function to validate a single shard."""
 115        if shard not in valid_shards:
 116            raise ValueError(f"{shard} is not a valid shard for {api}")
 117
 118    def _validate_shards(self, api: str, shards: set[str]) -> None:
 119        """Validates shards for a given API."""
 120        api_to_shard_set = {
 121            "nation": valid.NATION_SHARDS
 122            | valid.PRIVATE_NATION_SHARDS
 123            | valid.PRIVATE_NATION_SHARDS,
 124            "region": valid.REGION_SHARDS,
 125            "world": valid.WORLD_SHARDS,
 126            "wa": valid.WA_SHARDS,
 127        }
 128
 129        valid_shards = api_to_shard_set.get(api)
 130        if not valid_shards:
 131            raise ValueError(f"Invalid API type: {api}")
 132
 133        for shard in shards:
 134            self._validate_shard(shard, valid_shards, api)
 135
 136    def _set_user_agent(
 137        self,
 138        script_name: str,
 139        script_version: str,
 140        script_author: str,
 141        script_user: str,
 142        link_to_src: str,
 143    ):
 144        self.user_agent = (
 145            f"{script_name}/{script_version} (by:{script_author}; usedBy:{script_user})"
 146        )
 147        if link_to_src:
 148            self.user_agent = f"{self.user_agent}; src:{link_to_src}"
 149        self.user_agent = f"{self.user_agent}; Written with NSDotPy/{self.VERSION} (by:Sweeze; src:github.com/sw33ze/NSDotPy)"
 150        self._session.headers.update({"User-Agent": self.user_agent})
 151
 152    def _validate_nations(self, nations: set[str]) -> bool:
 153        """Checks if a list of nations exist using the NationStates API.
 154
 155        Args:
 156            nations (set[str]): List of nations to check
 157
 158        Returns:
 159            bool: True if all nations in the set exist, False otherwise.
 160        """
 161        response = self.api_request("world", shard="nations")
 162        world_nations = response.nations.split(",")
 163        # check if all nations in the list exist in the world nations
 164        return all(canonicalize(nation) in world_nations for nation in nations)
 165
 166    def _init_logger(self):
 167        self.logger = logging.getLogger("NSDotPy")
 168        config = {
 169            "version": 1,
 170            "disable_existing_loggers": False,
 171            "formatters": {
 172                "f": {
 173                    "format": "%(asctime)s %(message)s",
 174                    "datefmt": "%I:%M:%S %p",
 175                }
 176            },
 177            "handlers": {
 178                "console": {
 179                    "class": "logging.StreamHandler",
 180                    "formatter": "f",
 181                }
 182            },
 183            "loggers": {
 184                "NSDotPy": {"handlers": ["console"], "level": "INFO"},
 185                "httpx": {"handlers": ["console"], "level": "ERROR"},
 186            },
 187        }
 188        logging.config.dictConfig(config)
 189
 190    def _get_auth_values(self, response: httpx.Response):
 191        # make sure it's actually html first
 192        if not response.headers["Content-Type"].startswith("text/html"):
 193            return
 194        # parse the html
 195        soup = BeautifulSoup(response.text, "lxml")
 196        # gathering chk and localid so i dont have to worry about authenticating l8r
 197        if chk := soup.find("input", {"name": "chk"}):
 198            self.chk = chk["value"].strip()  # type: ignore
 199        if localid := soup.find("input", {"name": "localid"}):
 200            self.localid = localid["value"].strip()  # type: ignore
 201        if pin := self._session.cookies.get("pin"):
 202            # you should never really need the pin but just in case i'll store it
 203            # PAST ME WAS RIGHT, I NEEDED IT FOR THE PRIVATE API!!
 204            self.pin = pin
 205        if soup.find("a", {"class": "STANDOUT"}):
 206            self.region = canonicalize(
 207                soup.find_all("a", {"class": "STANDOUT"})[1].attrs["href"].split("=")[1]
 208            )
 209
 210    def _wait_for_input(self, key: str) -> int:
 211        """Blocks execution until the user presses a key. Used as the one click = one request action.
 212
 213        Args:
 214            key (str): The key to wait for
 215
 216        Returns:
 217            int: Userclick parameter, milliseconds since the epoch"""
 218        keyboard.wait(key)
 219        # the trigger_on_release parameter is broken on windows
 220        # because of a bug in keyboard so we have to do this
 221        while keyboard.is_pressed(key):
 222            pass
 223        return int(time.time() * 1000)
 224
 225    def _get_detag_wfe(self) -> str:
 226        """Gets the detagged WFE of the region you're in.
 227
 228        Returns:
 229            str: The detagged WFE"""
 230        self.logger.info(f"Getting detag WFE for {self.region}...")
 231        response = self.request(
 232            f"https://greywardens.xyz/tools/wfe_index/region={self.region}",
 233        )
 234        soup = BeautifulSoup(response.text, "lxml")
 235        # the safest bet for a detag wfe is the first wfe of the region
 236        return soup.find_all("pre")[-1].text
 237
 238    def _validate_fields(self, data: dict):
 239        max_lengths = {
 240            "pretitle": 28,
 241            "slogan": 55,
 242            "currency": 40,
 243            "animal": 40,
 244            "demonym_noun": 44,
 245            "demonym_adjective": 44,
 246            "demonym_plural": 44,
 247        }
 248
 249        # go through each key in the data dict and make sure they're below the max length
 250        for key, value in data.items():
 251            if key not in max_lengths:
 252                continue
 253            if len(value) > max_lengths[key]:
 254                raise ValueError(f"{key} is too long, max length is {max_lengths[key]}")
 255            if len(value) < 2 and key != "slogan":
 256                raise ValueError(f"{key} should have a minimum length of 2 characters.")
 257            # check if pretitle contains any non-alphanumeric characters (except spaces)
 258            if key == "pretitle" and not value.replace(" ", "").isalnum():
 259                raise ValueError(
 260                    "Pretitle should only contain alphanumeric characters or spaces."
 261                )
 262
 263    def _wait_for_ratelimit(self, head: dict, constant_rate_limit: bool):
 264        if "X-Pin" in head:
 265            self.pin = head["X-Pin"]
 266        if waiting_time := head.get("Retry-After"):
 267            self.logger.warning(f"Rate limited. Waiting {waiting_time} seconds.")
 268            time.sleep(int(waiting_time))
 269        # slow down requests so we dont hit the rate limit in the first place
 270        requests_left = int(head["RateLimit-Remaining"])
 271        if requests_left < 10 or constant_rate_limit:
 272            seconds_until_reset = int(head["RateLimit-Reset"])
 273            time.sleep(seconds_until_reset / requests_left)
 274
 275    def _html_request(
 276        self, url, data={}, files=None, follow_redirects=False
 277    ) -> httpx.Response:
 278        data |= {"chk": self.chk, "localid": self.localid}
 279        userclick = self._wait_for_input(self.keybind)
 280        # userclick is the number of milliseconds since the epoch, admin uses this for help enforcing the simultaneity rule
 281        response = self._session.post(
 282            f"{url}/userclick={userclick}",
 283            data=data,
 284            files=files,
 285            follow_redirects=follow_redirects,
 286        )
 287        if response.status_code >= 400:
 288            with open("error.html", "w") as f:
 289                f.write(response.text)
 290            raise httpx.HTTPError(
 291                f"Received status code {response.status_code} from {response.url}. Error page saved to error.html."
 292            )
 293        self._get_auth_values(response)
 294        return response
 295
 296    # --- end private methods --- #
 297
 298    def refresh_auth_values(self):
 299        self.logger.info("Refreshing authentication values...")
 300        response = self.request(
 301            f"https://www.nationstates.net/page=display_region/region={self._auth_region}",
 302            data={"theme": "century"},
 303        )
 304        self._get_auth_values(response)
 305
 306    def request(
 307        self,
 308        url: str,
 309        data: dict = {},
 310        files: dict = {},
 311        follow_redirects: bool = False,
 312    ) -> httpx.Response:
 313        """Sends a request to the given url with the given data and files.
 314
 315        Args:
 316            url (str): URL to send the request to
 317            data (dict, optional): Payload to send with the request
 318            files (dict, optional): Payload to send with requests that upload files
 319
 320        Returns:
 321            httpx.Response: The response from the server
 322        """
 323        if any(
 324            banned_page in canonicalize(url)
 325            for banned_page in {
 326                "page=telegrams",
 327                "page=dilemmas",
 328                "page=compose_telegram",
 329                "page=store",
 330                "page=help",
 331            }
 332        ):
 333            raise ValueError(
 334                "You cannot use a tool to interact with telegrams, issues, getting help, or the store. Read up on the script rules: https://forum.nationstates.net/viewtopic.php?p=16394966#p16394966"
 335            )
 336        if "api.cgi" in canonicalize(url):
 337            # you should be using api_request for api requests
 338            raise ValueError("You should be using api_request() for api requests.")
 339        elif "nationstates" in canonicalize(url):
 340            # do all the things that need to be done for html requests
 341            if self._lock:
 342                # if lock is true then we're already in the middle of a
 343                # request and we're in danger of breaking the simultaneity rule
 344                # so raise an error
 345                raise PermissionError(
 346                    "You're already in the middle of a request. Stop trying to violate simultaneity."
 347                )
 348            self._lock = True
 349            response = self._html_request(url, data, files, follow_redirects)
 350            self._lock = False
 351        else:
 352            # if its not nationstates then just pass the request through
 353            response = self._session.post(
 354                url, data=data, follow_redirects=follow_redirects
 355            )
 356        return response
 357
 358    def api_request(
 359        self,
 360        api: str,
 361        *,
 362        target: str = "",
 363        shard: str | set[str] = "",
 364        password: str = "",
 365        constant_rate_limit: bool = False,
 366    ) -> benedict:
 367        """Sends a request to the nationstates api with the given data.
 368
 369        Args:
 370            api (str): The api to send the request to. Must be "nation", "region", "world", or "wa"
 371            target (str, optional): The nation, region, or wa council to target. Required for non-world api requests.
 372            shard (str, optional): The shard, or shards, you're requesting for. Must be a valid shard for the given api. Only required for world and wa api requests.
 373            password (str, optional): The password to use for authenticating private api requests. Defaults to "". Not required if already signed in, whether through the api or through the HTML site.
 374            constant_rate_limit (bool, optional): If True, will always rate limit. If False, will only rate limit when there's less than 10 requests left in the current bucket. Defaults to False.
 375
 376        Returns:
 377            benedict: A benedict object containing the response from the server. Acts like a dictionary, with keypath and keylist support.
 378        """
 379        # TODO: probably move this responsibility to a third party api library to avoid reinventing the wheel
 380        # if one exists of sufficient quality thats AGPLv3 compatible
 381        if api not in {"nation", "region", "world", "wa"}:
 382            raise ValueError("api must be 'nation', 'region', 'world', or 'wa'")
 383        if api != "world" and not target:
 384            raise ValueError("target must be specified for non-world api requests")
 385        if api in {"wa", "world"} and not shard:
 386            raise ValueError("shard must be specified for world and wa api requests")
 387        # end argument validation
 388        # shard validation
 389        if type(shard) == str:
 390            shard = {shard}
 391        self._validate_shards(api, shard)  # type: ignore
 392        # end shard validation
 393        data = {
 394            "v": "12",
 395        }
 396        if api != "world":
 397            data[api] = target
 398        if shard:
 399            data["q"] = "+".join(shard)
 400        url = "https://www.nationstates.net/cgi-bin/api.cgi"
 401        if password:
 402            self._session.headers["X-Password"] = password
 403        if self.pin:
 404            self._session.headers["X-Pin"] = self.pin
 405        # rate limiting section
 406        response = self._session.post(url, data=data)
 407        # if the server tells us to wait, wait
 408        self._wait_for_ratelimit(response.headers, constant_rate_limit)
 409        response.raise_for_status()
 410        parsed_response = benedict.from_xml(response.text, keyattr_dynamic=True)
 411        parsed_response.standardize()
 412        parsed_response: benedict = parsed_response[api]  # type: ignore
 413        return parsed_response
 414
 415    def api_issue(
 416        self,
 417        nation: str,
 418        issue: int,
 419        option: int,
 420        password: str = "",
 421        constant_rate_limit: bool = False,
 422    ) -> benedict:
 423        """Answers an issue via the API.
 424
 425        Args:
 426            nation (str): The nation to perform the command with.
 427            issue (int): the ID of the issue.
 428            option (int): the issue option to choose.
 429            password (str, optional): The password to use for authenticating private api requests. Defaults to "". Not required if already signed in, whether through the api or through the HTML site.
 430            constant_rate_limit (bool, optional): If True, will always rate limit. If False, will only rate limit when there's less than 10 requests left in the current bucket. Defaults to False.
 431
 432        Returns:
 433            benedict: A benedict object containing the response from the server. Acts like a dictionary, with keypath and keylist support.
 434        """
 435        if not (password or self.pin):
 436            raise ValueError("must specify authentication")
 437        data = {
 438            "v": "12",
 439            "c": "issue",
 440            "nation": canonicalize(nation),
 441            "issue": issue,
 442            "option": option,
 443        }
 444        url = "https://www.nationstates.net/cgi-bin/api.cgi"
 445        if password:
 446            self._session.headers["X-Password"] = password
 447        if self.pin:
 448            self._session.headers["X-Pin"] = self.pin
 449        # rate limiting section
 450        response = self._session.get(url, params=data)
 451        # if the server tells us to wait, wait
 452        self._wait_for_ratelimit(response.headers, constant_rate_limit)
 453        response.raise_for_status()
 454        parsed_response = benedict.from_xml(response.text, keyattr_dynamic=True)
 455        parsed_response.standardize()
 456        parsed_response: benedict = parsed_response["nation"]  # type: ignore
 457        return parsed_response
 458
 459    def api_command(
 460        self,
 461        nation: str,
 462        command: str,
 463        data: dict,
 464        password: str = "",
 465        mode: str = "",
 466        constant_rate_limit: bool = False,
 467    ) -> benedict:
 468        """Sends a non-issue command to the nationstates api with the given data and password.
 469
 470        Args:
 471            nation (str): The nation to perform the command with.
 472            command (str): The command to perform. Must be "giftcard", "dispatch", "rmbpost"
 473            data (str, optional): The unique data to send with the parameters of the command; consult the API docs for more information.
 474            password (str, optional): The password to use for authenticating private api requests. Defaults to "". Not required if already signed in, whether through the api or through the HTML site.
 475            mode (str, optional): Whether to prepare or to execute the command. If value is given, does one of the two and returns result, if no value is given, does both and returns result of execute.
 476            constant_rate_limit (bool, optional): If True, will always rate limit. If False, will only rate limit when there's less than 10 requests left in the current bucket. Defaults to False.
 477
 478        Returns:
 479            benedict: A benedict object containing the response from the server. Acts like a dictionary, with keypath and keylist support.
 480        """
 481        if command not in {"giftcard", "dispatch", "rmbpost"}:
 482            raise ValueError("command must be 'giftcard', 'dispatch', or 'rmbpost'")
 483        if not (password or self.pin):
 484            raise ValueError("must specify authentication")
 485        if mode not in {"", "prepare", "execute"}:
 486            raise ValueError("mode must be prepare or execute")
 487        data["v"] = "12"
 488        data["nation"] = canonicalize(nation)
 489        data["c"] = command
 490        data["mode"] = mode if mode else "prepare"  # if no mode than first prepare
 491        url = "https://www.nationstates.net/cgi-bin/api.cgi"
 492        if password:
 493            self._session.headers["X-Password"] = password
 494        if self.pin:
 495            self._session.headers["X-Pin"] = self.pin
 496        # rate limiting section
 497        response = self._session.get(url, params=data)
 498        # if the server tells us to wait, wait
 499        self._wait_for_ratelimit(response.headers, constant_rate_limit)
 500        response.raise_for_status()
 501        parsed_response = benedict.from_xml(response.text, keyattr_dynamic=True)
 502        parsed_response.standardize()
 503        parsed_response: benedict = parsed_response["nation"]  # type: ignore
 504        if mode == "":
 505            # if no mode was specified earlier, repeat command with execute and token
 506            data["token"] = parsed_response["success"]
 507            return self.api_command(nation, command, data, mode="execute")
 508        else:
 509            return parsed_response
 510
 511    def api_giftcard(
 512        self,
 513        nation: str,
 514        card_id: int,
 515        season: int,
 516        recipient: str,
 517        password: str = "",
 518        constant_rate_limit: bool = False,
 519    ) -> benedict:
 520        """Gifts a card using the API.
 521
 522        Args:
 523            nation (str): The nation to perform the command with.
 524            card_id (int): The ID of the card to gift.
 525            season (int): The season of the card to gift.
 526            recipient (str): The nation to gift the card to.
 527            password (str, optional): The password to use for authenticating private api requests. Defaults to "". Not required if already signed in, whether through the api or through the HTML site.
 528            constant_rate_limit (bool, optional): If True, will always rate limit. If False, will only rate limit when there's less than 10 requests left in the current bucket. Defaults to False.
 529
 530        Returns:
 531            benedict: A benedict object containing the response from the server. Acts like a dictionary, with keypath and keylist support.
 532        """
 533        data = {"cardid": card_id, "season": season, "to": canonicalize(recipient)}
 534        return self.api_command(
 535            nation, "giftcard", data, password, constant_rate_limit=constant_rate_limit
 536        )
 537
 538    def api_dispatch(
 539        self,
 540        nation: str,
 541        action: str,
 542        title: str = "",
 543        text: str = "",
 544        category: int = 0,
 545        subcategory: int = 0,
 546        dispatchid: int = 0,
 547        password: str = "",
 548        constant_rate_limit: bool = False,
 549    ) -> benedict:
 550        """Add, edit, or remove a dispatch.
 551
 552        Args:
 553            nation (str): The nation to perform the command with.
 554            action (str): The action to take. Must be "add", "edit", "remove"
 555            title (str, optional): The dispatch title when adding or editing.
 556            text (str, optional): The dispatch text when adding or editing.
 557            category: (int, optional), The category ID when adding or editing.
 558            subcategory (int, optional): The subcategory ID when adding or editing.
 559            dispatchid (int, optional): The dispatch ID when editing or removing.
 560            password (str, optional): The password to use for authenticating private api requests. Defaults to "". Not required if already signed in, whether through the api or through the HTML site.
 561            constant_rate_limit (bool, optional): If True, will always rate limit. If False, will only rate limit when there's less than 10 requests left in the current bucket. Defaults to False.
 562
 563        Returns:
 564            benedict: A benedict object containing the response from the server. Acts like a dictionary, with keypath and keylist support.
 565        """
 566        # TODO: maybe consider splitting these three functions?
 567        # TODO: maybe create enums for category and subcategory
 568        if action not in {"add", "edit", "remove"}:
 569            raise ValueError("action must be 'add', 'edit', or 'remove'")
 570        if action != "remove" and not all({title, text, category, subcategory}):
 571            raise ValueError("must specify title, text, category, and subcategory")
 572        if action != "add" and not dispatchid:
 573            raise ValueError("must specify a dispatch id")
 574
 575        data = {"dispatch": action}
 576        if title:
 577            data["title"] = title
 578        if text:
 579            data["text"] = text
 580        if category:
 581            data["category"] = category
 582        if subcategory:
 583            data["subcategory"] = subcategory
 584        if dispatchid:
 585            data["dispatchid"] = dispatchid
 586        return self.api_command(
 587            nation, "dispatch", data, password, constant_rate_limit=constant_rate_limit
 588        )
 589
 590    def api_rmb(
 591        self,
 592        nation: str,
 593        region: str,
 594        text: str,
 595        password: str = "",
 596        constant_rate_limit: bool = False,
 597    ) -> benedict:
 598        """Post a message on the regional message board via the API.
 599
 600        Args:
 601            nation (str): The nation to perform the command with.
 602            region (str): the region to post the message in.
 603            text (str): the text to post.
 604            password (str, optional): The password to use for authenticating private api requests. Defaults to "". Not required if already signed in, whether through the api or through the HTML site.
 605            constant_rate_limit (bool, optional): If True, will always rate limit. If False, will only rate limit when there's less than 10 requests left in the current bucket. Defaults to False.
 606
 607        Returns:
 608            benedict: A benedict object containing the response from the server. Acts like a dictionary, with keypath and keylist support.
 609        """
 610        data = {"region": region, "text": text}
 611        return self.api_command(
 612            nation, "rmbpost", data, password, constant_rate_limit=constant_rate_limit
 613        )
 614
 615    def login(self, nation: str, password: str) -> bool:
 616        """Logs in to the nationstates site.
 617
 618        Args:
 619            nation (str): Nation name
 620            password (str): Nation password
 621
 622        Returns:
 623            bool: True if login was successful, False otherwise
 624        """
 625        self.logger.info(f"Logging in to {nation}")
 626        url = f"https://www.nationstates.net/page=display_region/region={self._auth_region}"
 627        # shoutouts to roavin for telling me i had to have page=display_region in the url so it'd work with a userclick parameter
 628
 629        data = {
 630            "nation": canonicalize(nation),
 631            "password": password,
 632            "theme": "century",
 633            "logging_in": "1",
 634            "submit": "Login",
 635        }
 636
 637        response = self.request(url, data)
 638
 639        soup = BeautifulSoup(response.text, "lxml")
 640        # checks if the body tag has your nation name in it; if it does, you're logged in
 641        if not soup.find("body", {"data-nname": canonicalize(nation)}):
 642            return False
 643
 644        self.nation = canonicalize(nation)
 645        return True
 646
 647    def change_nation_flag(self, flag_filename: str) -> bool:
 648        """Changes the nation flag to the given image.
 649
 650        Args:
 651            flag_filename (str): Filename of the flag to change to
 652
 653        Returns:
 654            bool: True if the flag was changed, False otherwise
 655        """
 656        self.logger.info(f"Changing flag on {self.nation}")
 657        # THIS WAS SO FUCKING FRUSTRATING BUT IT WORKS NOW AND IM NEVER TOUCHING THIS BULLSHIT UNLESS NS BREAKS IT AGAIN
 658        url = "https://www.nationstates.net/cgi-bin/upload.cgi"
 659
 660        data = {
 661            "nationname": self.nation,
 662        }
 663        files = {
 664            "file": (
 665                flag_filename,
 666                open(flag_filename, "rb"),
 667                mimetypes.guess_type(flag_filename)[0],
 668            )
 669        }
 670
 671        response = self.request(url, data=data, files=files)
 672
 673        if "page=settings" in response.headers["location"]:
 674            self.refresh_auth_values()
 675            return True
 676        elif "Just a moment..." in response.text:
 677            self.logger.warning(
 678                "Cloudflare blocked you idiot get fucked have fun with that like I had to lmaoooooooooo"
 679            )
 680        return False
 681
 682    def change_nation_settings(
 683        self,
 684        *,
 685        email: str = "",
 686        pretitle: str = "",
 687        slogan: str = "",
 688        currency: str = "",
 689        animal: str = "",
 690        demonym_noun: str = "",
 691        demonym_adjective: str = "",
 692        demonym_plural: str = "",
 693        new_password: str = "",
 694    ) -> bool:
 695        """Given a logged in session, changes customizable fields and settings of the logged in nation.
 696        Variables must be explicitly named in the call to the function, e.g. "session.change_nation_settings(pretitle='Join Lily', currency='Join Lily')"
 697
 698        Args:
 699            email (str, optional): New email for WA apps.
 700            pretitle (str, optional): New pretitle of the nation. Max length of 28. Nation must have minimum population of 250 million.
 701            slogan (str, optional): New Slogan/Motto of the nation. Max length of 55.
 702            currency (str, optional): New currency of the nation. Max length of 40.
 703            animal (str, optional): New national animal of the nation. Max length of 40.
 704            demonym_noun (str, optional): Noun the nation will refer to its citizens as. Max length of 44.
 705            demonym_adjective (str, optional): Adjective the nation will refer to its citizens as. Max length of 44.
 706            demonym_plural (str, optional): Plural form of "demonym_noun". Max length of 44.
 707            new_password (str, optional): New password to assign to the nation.
 708
 709        Returns:
 710            bool: True if changes were successful, False otherwise.
 711        """
 712        self.logger.info(f"Changing settings on {self.nation}")
 713        url = "https://www.nationstates.net/template-overall=none/page=settings"
 714
 715        data = {
 716            "type": pretitle,
 717            "slogan": slogan,
 718            "currency": currency,
 719            "animal": animal,
 720            "demonym2": demonym_noun,
 721            "demonym": demonym_adjective,
 722            "demonym2pl": demonym_plural,
 723            "email": email,
 724            "password": new_password,
 725            "confirm_password": new_password,
 726            "update": " Update ",
 727        }
 728        # remove keys that have empty values
 729        data = {k: v for k, v in data.items() if v}
 730        # make sure everything is following the proper length limits and only contains acceptable characters
 731        self._validate_fields(data)
 732
 733        response = self.request(url, data)
 734        return "Your settings have been successfully updated." in response.text
 735
 736    def move_to_region(self, region: str, password: str = "") -> bool:
 737        """Moves the nation to the given region.
 738
 739        Args:
 740            region (str): Region to move to
 741            password (str, optional): Region password, if the region is passworded
 742
 743        Returns:
 744            bool: True if the move was successful, False otherwise
 745        """
 746        self.logger.info(f"Moving {self.nation} to {region}")
 747        url = "https://www.nationstates.net/template-overall=none/page=change_region"
 748
 749        data = {"region_name": region, "move_region": "1"}
 750        if password:
 751            data["password"] = password
 752        response = self.request(url, data)
 753
 754        if "Success!" in response.text:
 755            self.region = canonicalize(region)
 756            return True
 757        return False
 758
 759    def vote(self, pollid: str, option: str) -> bool:
 760        """Votes on a poll.
 761
 762        Args:
 763            pollid (str): ID of the poll to vote on, e.g. "199747"
 764            option (str): Option to vote for (starts at 0)
 765
 766        Returns:
 767            bool: True if the vote was successful, False otherwise
 768        """
 769        self.logger.info(f"Voting on poll {pollid} with {self.nation}")
 770        url = f"https://www.nationstates.net/template-overall=none/page=poll/p={pollid}"
 771
 772        data = {"pollid": pollid, "q1": option, "poll_submit": "1"}
 773        response = self.request(url, data)
 774
 775        return "Your vote has been lodged." in response.text
 776
 777    # below are functions that are related to the WA
 778
 779    def join_wa(self, nation: str, app_id: str) -> bool:
 780        """Joins the WA with the given nation.
 781
 782        Args:
 783            nation (str): Nation to join the WA with
 784            app_id (str): ID of the WA application to use
 785
 786        Returns:
 787            bool: True if the join was successful, False otherwise
 788        """
 789        self.logger.info(f"Joining WA with {nation}")
 790        url = "https://www.nationstates.net/cgi-bin/join_un.cgi"
 791
 792        data = {"nation": canonicalize(nation), "appid": app_id.strip()}
 793        response = self.request(url, data)
 794
 795        if "?welcome=1" in response.headers["location"]:
 796            # since we're just getting thrown into a cgi script, we'll have to manually grab authentication values
 797            self.refresh_auth_values()
 798            return True
 799        return False
 800
 801    def resign_wa(self):
 802        """Resigns from the WA.
 803
 804        Returns:
 805            bool: True if the resignation was successful, False otherwise
 806        """
 807        self.logger.info("Resigning from WA")
 808        url = "https://www.nationstates.net/template-overall=none/page=UN_status"
 809
 810        data = {"action": "leave_UN", "submit": "1"}
 811        response = self.request(url, data)
 812
 813        return "From this moment forward, your nation is on its own." in response.text
 814
 815    def apply_wa(self, reapply: bool = True) -> bool:
 816        """Applies to the WA.
 817
 818        Args:
 819            reapply (bool, optional): Whether to reapply if you've been sent an application that's still valid. Defaults to True.
 820
 821        Returns:
 822            bool: True if the application was successful, False otherwise
 823        """
 824        self.logger.info(f"Applying to WA with {self.nation}")
 825        url = "https://www.nationstates.net/template-overall=none/page=UN_status"
 826
 827        data = {"action": "join_UN"}
 828        if reapply:
 829            data["resend"] = "1"
 830        else:
 831            data["submit"] = "1"
 832
 833        response = self.request(url, data)
 834        return (
 835            "Your application to join the World Assembly has been received!"
 836            in response.text
 837        )
 838
 839    def endorse(self, nation: str, endorse: bool = True) -> bool:
 840        """Endorses the given nation.
 841
 842        Args:
 843            nation (str): Nation to endorse
 844            endorse (bool, optional): True=endorse, False=unendorse. Defaults to True.
 845
 846        Returns:
 847            bool: True if the endorsement was successful, False otherwise
 848        """
 849        self.logger.info(
 850            f"{('Unendorsing', 'Endorsing')[endorse]} {nation} with {self.nation}"
 851        )
 852        url = "https://www.nationstates.net/cgi-bin/endorse.cgi"
 853
 854        data = {
 855            "nation": canonicalize(nation),
 856            "action": "endorse" if endorse else "unendorse",
 857        }
 858        response = self.request(url, data)
 859
 860        return f"nation={canonicalize(nation)}" in response.headers["location"]
 861
 862    def clear_dossier(self) -> bool:
 863        """Clears a logged in nation's dossier.
 864
 865        Returns:
 866            bool: Whether it was successful or not
 867        """
 868
 869        self.logger.info(f"Clearing dossier on {self.nation}")
 870        url = "https://www.nationstates.net/template-overall=none/page=dossier"
 871        data = {"clear_dossier": "1"}
 872        response = self.request(url, data)
 873
 874        return "Dossier cleared of nations." in response.text
 875
 876    def add_to_dossier(self, nations: list[str] | str) -> bool:
 877        """Adds nations to the logged in nation's dossier.
 878
 879        Args:
 880            nations (list[str] | str): List of nations to add, or a single nation
 881
 882        Returns:
 883            bool: Whether it was successful or not
 884        """
 885
 886        self.logger.info(f"Adding {nations} to dossier on {self.nation}")
 887        url = "https://www.nationstates.net/dossier.cgi"
 888        data = {
 889            "currentnation": canonicalize(self.nation),
 890            "action_append": "Upload Nation Dossier File",
 891        }
 892        files = {
 893            "file": (
 894                "dossier.txt",
 895                "\n".join(nations).strip() if type(nations) is list else nations,
 896                "text/plain",
 897            ),
 898        }
 899        response = self.request(url, data, files=files)
 900
 901        self.refresh_auth_values()
 902        return "appended=" in response.headers["location"]
 903
 904    def wa_vote(self, council: str, vote: str) -> bool:
 905        """Votes on the current WA resolution.
 906
 907        Args:
 908            council (str): Must be "ga" for general assembly, "sc" for security council.
 909            vote (str): Must be "for" or "against".
 910
 911        Returns:
 912            bool: Whether the vote was successful or not
 913        """
 914        self.logger.info(
 915            f"Voting {vote} on {council.upper()} resolution with {self.nation}"
 916        )
 917        if council not in {"ga", "sc"}:
 918            raise ValueError("council must be 'ga' or 'sc'")
 919        if vote not in {"for", "against"}:
 920            raise ValueError("vote must be 'for' or 'against'")
 921        self.logger.info("Voting on WA resolution")
 922
 923        url = f"https://www.nationstates.net/template-overall=none/page={council}"
 924        data = {
 925            "vote": f"Vote {vote.capitalize()}",
 926        }
 927        response = self.request(url, data)
 928
 929        return "Your vote has been lodged." in response.text
 930
 931    def refound_nation(self, nation: str, password: str) -> bool:
 932        """Refounds a nation.
 933
 934        Args:
 935            nation (str): Name of the nation to refound
 936            password (str): Password to the nation
 937
 938        Returns:
 939            bool: Whether the nation was successfully refounded or not
 940        """
 941        url = "https://www.nationstates.net/template-overall=none/"
 942        data = {
 943            "logging_in": "1",
 944            "restore_password": password,
 945            "restore_nation": "1",
 946            "nation": nation,
 947        }
 948        response = self.request(url, data=data)
 949        if response.status_code == 302:
 950            self.nation = nation
 951            self.refresh_auth_values()
 952            return True
 953        return False
 954
 955    # methods for region control
 956
 957    def create_region(
 958        self,
 959        region_name: str,
 960        wfe: str,
 961        *,
 962        password: str = "",
 963        frontier: bool = False,
 964        executive_delegacy: bool = False,
 965    ) -> bool:
 966        """Creates a new region.
 967
 968        Args:
 969            region_name (str): Name of the region
 970            wfe (str): WFE of the region
 971            password (str, optional): Password to the region. Defaults to "".
 972            frontier (bool, optional): Whether or not the region is a frontier. Defaults to False.
 973            executive_delegacy (bool, optional): Whether or not the region has an executive WA delegacy. Defaults to False. Ignored if frontier is True.
 974
 975        Returns:
 976            bool: Whether the region was successfully created or not
 977        """
 978        self.logger.info(f"Creating new region {region_name}")
 979        url = "https://www.nationstates.net/template-overall=none/page=create_region"
 980        data = {
 981            "page": "create_region",
 982            "region_name": region_name.strip(),
 983            "desc": wfe.strip(),
 984            "create_region": "1",
 985        }
 986        if password:
 987            data |= {"pw": "1", "rpassword": password}
 988        if frontier:
 989            data |= {"is_frontier": "1"}
 990        elif executive_delegacy:
 991            data |= {"delegate_control": "1"}
 992        response = self.request(url, data)
 993        return "Success! You have founded " in response.text
 994
 995    def upload_to_region(self, type: str, filename: str) -> str:
 996        """Uploads a file to the current region.
 997
 998        Args:
 999            type (str): Type of file to upload. Must be "flag" or "banner".
1000            filename (str): Name of the file to upload. e.g. "myflag.png"
1001
1002        Raises:
1003            ValueError: If type is not "flag" or "banner"
1004
1005        Returns:
1006            str: Empty string if the upload failed, otherwise the ID of the uploaded file
1007        """
1008        self.logger.info(f"Uploading {filename} to {self.region}")
1009        if type not in {"flag", "banner"}:
1010            raise ValueError("type must be 'flag' or 'banner'")
1011        url = "https://www.nationstates.net/cgi-bin/upload.cgi"
1012        data = {
1013            "uploadtype": f"r{type}",
1014            "page": "region_control",
1015            "region": self.region,
1016            "expect": "json",
1017        }
1018        files = {
1019            f"file_upload_r{type}": (
1020                filename,
1021                open(filename, "rb"),
1022                mimetypes.guess_type(filename)[0],
1023            )
1024        }
1025        response = self.request(url, data, files=files)
1026        return "" if "id" not in response.json() else response.json()["id"]
1027
1028    def set_flag_and_banner(
1029        self, flag_id: str = "", banner_id: str = "", flag_mode: str = ""
1030    ) -> bool:
1031        """Sets the uploaded flag and/or banner for the current region.
1032
1033        Args:
1034            flag_id (str, optional): ID of the flag, uploaded with upload_to_region(). Defaults to "".
1035            banner_id (str, optional): ID of the banner, uploaded with upload_to_region(). Defaults to "".
1036            flagmode (str, optional): Must be "flag" which will have a shadow, or "logo" which will not, or "" to not change it. Defaults to "".
1037
1038        Raises:
1039            ValueError: If flagmode is not "flag", "logo", or ""
1040
1041        Returns:
1042            bool: Whether the change was successful or not
1043        """
1044        if flag_mode not in {"flag", "logo", ""}:
1045            raise ValueError("flagmode must be 'flag', 'logo', or ''")
1046        self.logger.info(f"Setting flag and banner for {self.region}")
1047        url = "https://www.nationstates.net/template-overall=none/page=region_control/"
1048        data = {
1049            "newflag": flag_id,
1050            "newbanner": banner_id,
1051            "saveflagandbannerchanges": "1",
1052            "flagmode": flag_mode,
1053        }
1054        # remove entries with empty values
1055        data = {k: v for k, v in data.items() if v}
1056
1057        response = self.request(url, data)
1058
1059        return "Regional banner/flag updated!" in response.text
1060
1061    def change_wfe(self, wfe: str = "") -> bool:
1062        """Changes the WFE of the current region.
1063
1064        Args:
1065            wfe (str, optional): World Factbook Entry to change to. Defaults to the oldest WFE the region has, for detags.
1066
1067        Returns:
1068            bool: True if successful, False otherwise
1069        """
1070        self.logger.info(f"Changing WFE for {self.region}")
1071        if not wfe:
1072            wfe = self._get_detag_wfe()  # haku im sorry for hitting your site so much
1073        url = "https://www.nationstates.net/template-overall=none/page=region_control/"
1074        data = {
1075            "message": wfe.encode("iso-8859-1", "xmlcharrefreplace")
1076            .decode()
1077            .strip(),  # lol.
1078            "setwfebutton": "1",
1079        }
1080        response = self.request(url, data)
1081        return "World Factbook Entry updated!" in response.text
1082
1083    # methods for embassies
1084
1085    def request_embassy(self, target: str) -> bool:
1086        """Requests an embassy with a region.
1087
1088        Args:
1089            target (str): The region to request the embassy with.
1090
1091        Returns:
1092            bool: Whether the request was successfully sent or not
1093        """
1094        self.logger.info(f"Requesting embassy with {target}")
1095        url = "https://www.nationstates.net/template-overall=none/page=region_control/"
1096        data = {
1097            "requestembassyregion": target,
1098            "requestembassy": "1",  # it's silly that requesting needs this but not closing, aborting, or cancelling
1099        }
1100        response = self.request(url, data)
1101        return "Your proposal for the construction of embassies with" in response.text
1102
1103    def close_embassy(self, target: str) -> bool:
1104        """Closes an embassy with a region.
1105
1106        Args:
1107            target (str): The region with which to close the embassy.
1108
1109        Returns:
1110            bool: Whether the embassy was successfully closed or not
1111        """
1112        self.logger.info(f"Closing embassy with {target}")
1113        url = "https://www.nationstates.net/template-overall=none/page=region_control/"
1114        data = {"cancelembassyregion": target}
1115        response = self.request(url, data)
1116        return " has been scheduled for demolition." in response.text
1117
1118    def abort_embassy(self, target: str) -> bool:
1119        """Aborts an embassy with a region.
1120
1121        Args:
1122            target (str): The region with which to abort the embassy.
1123
1124        Returns:
1125            bool: Whether the embassy was successfully aborted or not
1126        """
1127        self.logger.info(f"Aborting embassy with {target}")
1128        url = "https://www.nationstates.net/template-overall=none/page=region_control/"
1129        data = {"abortembassyregion": target}
1130        response = self.request(url, data)
1131        return " aborted." in response.text
1132
1133    def cancel_embassy(self, target: str) -> bool:
1134        """Cancels an embassy with a region.
1135
1136        Args:
1137            target (str): The region with which to cancel the embassy.
1138
1139        Returns:
1140            bool: Whether the embassy was successfully cancelled or not
1141        """
1142        self.logger.info(f"Cancelling embassy with {target}")
1143        url = "https://www.nationstates.net/template-overall=none/page=region_control/"
1144        data = {"cancelembassyclosureregion": target}
1145        response = self.request(url, data)
1146        return "Embassy closure order cancelled." in response.text
1147
1148    # end methods for embassies
1149
1150    def tag(self, action: str, tag: str) -> bool:
1151        """Adds or removes a tag to the current region.
1152
1153        Args:
1154            action (str): The action to take. Must be "add" or "remove".
1155            tag (str): The tag to add or remove.
1156
1157        Raises:
1158            ValueError: If action is not "add" or "remove", or if tag is not a valid tag.
1159
1160        Returns:
1161            bool: Whether the tag was successfully added or removed
1162        """
1163        if action not in {"add", "remove"}:
1164            raise ValueError("action must be 'add' or 'remove'")
1165        if canonicalize(tag) not in valid.REGION_TAGS:
1166            raise ValueError(f"{tag} is not a valid tag")
1167        self.logger.info(f"{action.capitalize()}ing tag {tag} for {self.region}")
1168        url = "https://www.nationstates.net/template-overall=none/page=region_control/"
1169        data = {
1170            f"{action}_tag": canonicalize(tag),
1171            "updatetagsbutton": "1",
1172        }
1173        response = self.request(url, data)
1174        return "Region Tags updated!" in response.text
1175
1176    def eject(self, nation: str) -> bool:
1177        """Ejects a nation from the current region. Note that a 1 second delay is required before ejecting another nation.
1178
1179        Args:
1180            nation (str): The nation to eject.
1181
1182        Returns:
1183            bool: Whether the nation was successfully ejected or not
1184        """
1185        self.logger.info(f"Ejecting {nation} from {self.region}")
1186        url = "https://www.nationstates.net/template-overall=none/page=region_control/"
1187        data = {"nation_name": nation, "eject": "1"}
1188        response = self.request(url, data)
1189        return "has been ejected from " in response.text
1190
1191    def banject(self, nation: str) -> bool:
1192        """Bans a nation from the current region. Note that a 1 second delay is required before banjecting another nation.
1193
1194        Args:
1195            nation (str): The nation to banject.
1196
1197        Returns:
1198            bool: Whether the nation was successfully banjected or not
1199        """
1200        self.logger.info(f"Banjecting {nation} from {self.region}")
1201        url = "https://www.nationstates.net/template-overall=none/page=region_control/"
1202        data = {"nation_name": nation, "ban": "1"}
1203        response = self.request(url, data)
1204        return "has been ejected and banned from " in response.text
1205
1206    # end methods for region control
1207
1208    def junk_card(self, id: str, season: str) -> bool:
1209        """Junks a card from the current nation's deck.
1210        Args:
1211            id (str): ID of the card to junk
1212            season (str): Season of the card to junk
1213        Returns:
1214            bool: Whether the card was successfully junked or not
1215        """
1216        self.logger.info(f"Junking card {id} from season {season}")
1217        url = "https://www.nationstates.net/template-overall=none/page=deck"
1218
1219        data = {"page": "ajax3", "a": "junkcard", "card": id, "season": season}
1220        response = self.request(url, data)
1221
1222        return "Your Deck" in response.text
1223
1224    def open_pack(self) -> bool:
1225        """Opens a card pack.
1226
1227        Returns:
1228            bool: Whether the bid was successfully removed or not
1229        """
1230        self.logger.info("Opening trading card pack")
1231        url = "https://www.nationstates.net/template-overall=none/page=deck"
1232        data = {"open_loot_box": "1"}
1233        response = self.request(url, data)
1234        return "Tap cards to reveal..." in response.text
1235
1236    def ask(self, price: str, card_id: str, season: str) -> bool:
1237        """Puts an ask at price on a card in a season
1238
1239        Args:
1240            price (str): Price to ask
1241            card_id (str): ID of the card
1242            season (str): Season of the card
1243
1244        Returns:
1245            bool: Whether the ask was successfully lodged or not
1246        """
1247        self.logger.info(f"Asking for {price} on {card_id} season {season}")
1248        url = f"https://www.nationstates.net/template-overall=none/page=deck/card={card_id}/season={season}"
1249
1250        data = {"auction_ask": price, "auction_submit": "ask"}
1251        response = self.request(url, data)
1252        return f"Your ask of {price} has been lodged." in response.text
1253
1254    def bid(self, price: str, card_id: str, season: str) -> bool:
1255        """Places a bid on a card in a season
1256
1257        Args:
1258            price (str): Amount of bank to bid
1259            card_id (str): ID of the card
1260            season (str): Season of the card
1261
1262        Returns:
1263            bool: Whether the bid was successfully lodged or not
1264        """
1265        self.logger.info(f"Putting a bid for {price} on {card_id} season {season}")
1266        url = f"https://www.nationstates.net/template-overall=none/page=deck/card={card_id}/season={season}"
1267
1268        data = {"auction_bid": price, "auction_submit": "bid"}
1269        response = self.request(url, data)
1270
1271        return f"Your bid of {price} has been lodged." in response.text
1272
1273    def remove_ask(self, price: str, card_id: str, season: str) -> bool:
1274        """Removes an ask on card_id in season at price
1275
1276        Args:
1277            price (str): Price of the ask to remove
1278            card_id (str): ID of the card
1279            season (str): Season of the card
1280
1281        Returns:
1282            bool: Whether the ask was successfully removed or not
1283        """
1284
1285        self.logger.info(f"removing an ask for {price} on {card_id} season {season}")
1286        url = f"https://www.nationstates.net/template-overall=none/page=deck/card={card_id}/season={season}"
1287
1288        data = {"new_price": price, "remove_ask_price": price}
1289        response = self.request(url, data)
1290        return f"Removed your ask for {price}" in response.text
1291
1292    def remove_bid(self, price: str, card_id: str, season: str) -> bool:
1293        """Removes a bid on a card
1294
1295        Args:
1296            price (str): Price of the bid to remove
1297            card_id (str): ID of the card
1298            season (str): Season of the card
1299
1300        Returns:
1301            bool: Whether the bid was successfully removed or not
1302        """
1303
1304        self.logger.info(f"Removing a bid for {price} on {card_id} season {season}")
1305        url = f"https://www.nationstates.net/template-overall=none/page=deck/card={card_id}/season={season}"
1306
1307        data = {"new_price": price, "remove_bid_price": price}
1308        response = self.request(url, data)
1309
1310        return f"Removed your bid for {price}" in response.text
1311
1312    def expand_deck(self, price: str) -> bool:
1313        """Upgrades deck capacity
1314
1315        Args:
1316            price (str): Price of the Upgrade
1317
1318        Returns:
1319            bool: Whether the upgrade was successfully removed or not
1320        """
1321
1322        self.logger.info(f"Upgrading your deck at a cost of {price}")
1323        url = "https://www.nationstates.net/template-overall=none/page=deck"
1324
1325        data = {"embiggen_deck": price}
1326        response = self.request(url, data)
1327
1328        return "Increased deck capacity from" in response.text
1329
1330    def add_to_collection(self, card_id: str, card_season: str, collection_id: str):
1331        """Adds a card to collection_id
1332
1333        Args:
1334            card_id (str): Card ID
1335            card_season (str): Cards season
1336            collection_id (str): The ID of the collection you want to add to
1337
1338        Returns:
1339            bool: Whether the adding was successfully added or not
1340        """
1341        self.logger.info(f"Adding {card_id} of season {card_season} to {collection_id}")
1342        url = f"https://www.nationstates.net/template-overall=none/page=deck/card={card_id}/season={card_season}"
1343
1344        data = {
1345            "manage_collections": "1",
1346            "modify_card_in_collection": "1",
1347            f"collection_{collection_id}": "1",
1348            "save_collection": "1",
1349        }
1350        response = self.request(url, data)
1351
1352        return "Updated collections." in response.text
1353
1354    def remove_from_collection(
1355        self, card_id: str, card_season: str, collection_id: str
1356    ):
1357        """Removes a card from collection_id
1358
1359        Args:
1360            card_id (str): Card ID
1361            card_season (str): Cards season
1362            collection_id (str): The ID of the collection you want to remove from
1363
1364        Returns:
1365            bool: Whether the removal was successfully added or not
1366        """
1367        self.logger.info(
1368            f"Removing {card_id} of season {card_season} from {collection_id}"
1369        )
1370        url = f"https://www.nationstates.net/template-overall=none/page=deck/card={card_id}/season={card_season}"
1371
1372        data = {
1373            "manage_collections": "1",
1374            "modify_card_in_collection": "1",
1375            "start": "0",
1376            f"collection_{collection_id}": "0",
1377            "save_collection": "1",
1378        }
1379        response = self.request(url, data)
1380
1381        return "Updated collections." in response.text
1382
1383    def create_collection(self, name: str):
1384        """Creates a collection named name
1385
1386        Args:
1387            name (str): The name of the collection you want to create
1388
1389        Returns:
1390            bool: Whether the creating was successfully added or not
1391        """
1392        self.logger.info(f"Creating {name} collection")
1393        url = "https://www.nationstates.net/template-overall=none/page=deck"
1394
1395        data = {"edit": "1", "collection_name": name, "save_collection": "1"}
1396        response = self.request(url, data)
1397
1398        return "Created collection!" in response.text
1399
1400    def delete_collection(self, name: str):
1401        """Deletes a collection named name
1402
1403        Args:
1404            name (str): The name of the collection you want to delete
1405
1406        Returns:
1407            bool: Whether the deleting was successfully added or not
1408        """
1409        self.logger.info(f"Deleting {name} collection")
1410        url = "https://www.nationstates.net/template-overall=none/page=deck"
1411
1412        data = {"edit": "1", "collection_name": name, "delete_collection": "1"}
1413        response = self.request(url, data)
1414
1415        return "Created collection!" in response.text
1416
1417    def can_nation_be_founded(self, name: str):
1418        """Checks if a nation can be founded
1419
1420        Args:
1421            name (str): The name of the nation you want to check
1422
1423        Returns:
1424            bool: Whether the nation can be founded or not
1425        """
1426        self.logger.info(f"Checking {name} in boneyard")
1427        url = "https://www.nationstates.net/template-overall=none/page=boneyard"
1428
1429        data = {"nation": name, "submit": "1"}
1430        response = self.request(url, data)
1431
1432        return (
1433            "Available! This name may be used to found a new nation." in response.text
1434        )
1435
1436    def join_nday_faction(self, id: str):
1437        """Joins a faction in the N-Day event
1438
1439        Args:
1440            id (str): The ID of the faction you want to join
1441
1442        Returns:
1443            bool: Whether the joining was successful or not
1444        """
1445        self.logger.info(f"Joining faction {id}")
1446
1447        url = (
1448            f"https://www.nationstates.net/template-overall=none/page=faction/fid={id}"
1449        )
1450        data = {"join_faction": "1"}
1451        response = self.request(url, data)
1452
1453        return " has joined " in response.text
1454
1455    def leave_nday_faction(self, id: str):
1456        """Leaves a faction in the N-Day event
1457
1458        Args:
1459            id (str): The ID of the faction you want to leave
1460
1461        Returns:
1462            bool: Whether the leaving was successful or not
1463        """
1464        self.logger.info(f"Leaving faction {id}")
1465
1466        url = (
1467            f"https://www.nationstates.net/template-overall=none/page=faction/fid={id}"
1468        )
1469        data = {"leave_faction": "1"}
1470        response = self.request(url, data)
1471
1472        return " has left " in response.text
NSSession( script_name: str, script_version: str, script_author: str, script_user: str, keybind: str = 'space', link_to_src: str = '', logger: logging.Logger | None = None)
 59    def __init__(
 60        self,
 61        script_name: str,
 62        script_version: str,
 63        script_author: str,
 64        script_user: str,
 65        keybind: str = "space",
 66        link_to_src: str = "",
 67        logger: logging.Logger | None = None,
 68    ):
 69        """A wrapper around httpx that abstracts away
 70        interacting with the HTML nationstates.net site.
 71        Focused on legality, correctness, and ease of use.
 72
 73        Args:
 74            script_name (str): Name of your script
 75            script_version (str): Version number of your script
 76            script_author (str): Author of your script
 77            script_user (str): Nation name of the user running your script
 78            keybind (str, optional): Keybind to count as a user click. Defaults to "space".
 79            link_to_src (str, optional): Link to the source code of your script.
 80            logger (logging.Logger | None, optional): Logger to use. Will create its own with name "NSDotPy" if none is specified. Defaults to None.
 81        """
 82        self.VERSION = "2.3.0"
 83        # Initialize logger
 84        if not logger:
 85            self._init_logger()
 86        else:
 87            self.logger = logger
 88        # Create a new httpx session
 89        self._session = httpx.Client(
 90            http2=True, timeout=30
 91        )  # ns can b slow, 30 seconds is hopefully a good sweet spot
 92        # Set the user agent to the script name, version, author, and user as recommended in the script rules thread:
 93        # https://forum.nationstates.net/viewtopic.php?p=16394966&sid=be37623536dbc8cee42d8d043945b887#p16394966
 94        self._lock: bool = False
 95        self._set_user_agent(
 96            script_name, script_version, script_author, script_user, link_to_src
 97        )
 98        # Initialize nationstates specific stuff
 99        self._auth_region = "rwby"
100        self.chk: str = ""
101        self.localid: str = ""
102        self.pin: str = ""
103        self.nation: str = ""
104        self.region: str = ""
105        self.keybind = keybind
106        # Make sure the nations in the user agent actually exist
107        if not self._validate_nations({script_author, script_user}):
108            raise ValueError(
109                "One of, or both, of the nations in the user agent do not exist. Make sure you're only including the nation name in the constructor, e.g. 'Thorn1000' instead of 'Devved by Thorn1000'"
110            )
111        self.logger.info(f"Initialized. Keybind to continue is {self.keybind}.")

A wrapper around httpx that abstracts away interacting with the HTML nationstates.net site. Focused on legality, correctness, and ease of use.

Arguments:
  • script_name (str): Name of your script
  • script_version (str): Version number of your script
  • script_author (str): Author of your script
  • script_user (str): Nation name of the user running your script
  • keybind (str, optional): Keybind to count as a user click. Defaults to "space".
  • link_to_src (str, optional): Link to the source code of your script.
  • logger (logging.Logger | None, optional): Logger to use. Will create its own with name "NSDotPy" if none is specified. Defaults to None.
def refresh_auth_values(self):
298    def refresh_auth_values(self):
299        self.logger.info("Refreshing authentication values...")
300        response = self.request(
301            f"https://www.nationstates.net/page=display_region/region={self._auth_region}",
302            data={"theme": "century"},
303        )
304        self._get_auth_values(response)
def request( self, url: str, data: dict = {}, files: dict = {}, follow_redirects: bool = False) -> httpx.Response:
306    def request(
307        self,
308        url: str,
309        data: dict = {},
310        files: dict = {},
311        follow_redirects: bool = False,
312    ) -> httpx.Response:
313        """Sends a request to the given url with the given data and files.
314
315        Args:
316            url (str): URL to send the request to
317            data (dict, optional): Payload to send with the request
318            files (dict, optional): Payload to send with requests that upload files
319
320        Returns:
321            httpx.Response: The response from the server
322        """
323        if any(
324            banned_page in canonicalize(url)
325            for banned_page in {
326                "page=telegrams",
327                "page=dilemmas",
328                "page=compose_telegram",
329                "page=store",
330                "page=help",
331            }
332        ):
333            raise ValueError(
334                "You cannot use a tool to interact with telegrams, issues, getting help, or the store. Read up on the script rules: https://forum.nationstates.net/viewtopic.php?p=16394966#p16394966"
335            )
336        if "api.cgi" in canonicalize(url):
337            # you should be using api_request for api requests
338            raise ValueError("You should be using api_request() for api requests.")
339        elif "nationstates" in canonicalize(url):
340            # do all the things that need to be done for html requests
341            if self._lock:
342                # if lock is true then we're already in the middle of a
343                # request and we're in danger of breaking the simultaneity rule
344                # so raise an error
345                raise PermissionError(
346                    "You're already in the middle of a request. Stop trying to violate simultaneity."
347                )
348            self._lock = True
349            response = self._html_request(url, data, files, follow_redirects)
350            self._lock = False
351        else:
352            # if its not nationstates then just pass the request through
353            response = self._session.post(
354                url, data=data, follow_redirects=follow_redirects
355            )
356        return response

Sends a request to the given url with the given data and files.

Arguments:
  • url (str): URL to send the request to
  • data (dict, optional): Payload to send with the request
  • files (dict, optional): Payload to send with requests that upload files
Returns:

httpx.Response: The response from the server

def api_request( self, api: str, *, target: str = '', shard: str | set[str] = '', password: str = '', constant_rate_limit: bool = False) -> benedict.dicts.benedict:
358    def api_request(
359        self,
360        api: str,
361        *,
362        target: str = "",
363        shard: str | set[str] = "",
364        password: str = "",
365        constant_rate_limit: bool = False,
366    ) -> benedict:
367        """Sends a request to the nationstates api with the given data.
368
369        Args:
370            api (str): The api to send the request to. Must be "nation", "region", "world", or "wa"
371            target (str, optional): The nation, region, or wa council to target. Required for non-world api requests.
372            shard (str, optional): The shard, or shards, you're requesting for. Must be a valid shard for the given api. Only required for world and wa api requests.
373            password (str, optional): The password to use for authenticating private api requests. Defaults to "". Not required if already signed in, whether through the api or through the HTML site.
374            constant_rate_limit (bool, optional): If True, will always rate limit. If False, will only rate limit when there's less than 10 requests left in the current bucket. Defaults to False.
375
376        Returns:
377            benedict: A benedict object containing the response from the server. Acts like a dictionary, with keypath and keylist support.
378        """
379        # TODO: probably move this responsibility to a third party api library to avoid reinventing the wheel
380        # if one exists of sufficient quality thats AGPLv3 compatible
381        if api not in {"nation", "region", "world", "wa"}:
382            raise ValueError("api must be 'nation', 'region', 'world', or 'wa'")
383        if api != "world" and not target:
384            raise ValueError("target must be specified for non-world api requests")
385        if api in {"wa", "world"} and not shard:
386            raise ValueError("shard must be specified for world and wa api requests")
387        # end argument validation
388        # shard validation
389        if type(shard) == str:
390            shard = {shard}
391        self._validate_shards(api, shard)  # type: ignore
392        # end shard validation
393        data = {
394            "v": "12",
395        }
396        if api != "world":
397            data[api] = target
398        if shard:
399            data["q"] = "+".join(shard)
400        url = "https://www.nationstates.net/cgi-bin/api.cgi"
401        if password:
402            self._session.headers["X-Password"] = password
403        if self.pin:
404            self._session.headers["X-Pin"] = self.pin
405        # rate limiting section
406        response = self._session.post(url, data=data)
407        # if the server tells us to wait, wait
408        self._wait_for_ratelimit(response.headers, constant_rate_limit)
409        response.raise_for_status()
410        parsed_response = benedict.from_xml(response.text, keyattr_dynamic=True)
411        parsed_response.standardize()
412        parsed_response: benedict = parsed_response[api]  # type: ignore
413        return parsed_response

Sends a request to the nationstates api with the given data.

Arguments:
  • api (str): The api to send the request to. Must be "nation", "region", "world", or "wa"
  • target (str, optional): The nation, region, or wa council to target. Required for non-world api requests.
  • shard (str, optional): The shard, or shards, you're requesting for. Must be a valid shard for the given api. Only required for world and wa api requests.
  • password (str, optional): The password to use for authenticating private api requests. Defaults to "". Not required if already signed in, whether through the api or through the HTML site.
  • constant_rate_limit (bool, optional): If True, will always rate limit. If False, will only rate limit when there's less than 10 requests left in the current bucket. Defaults to False.
Returns:

benedict: A benedict object containing the response from the server. Acts like a dictionary, with keypath and keylist support.

def api_issue( self, nation: str, issue: int, option: int, password: str = '', constant_rate_limit: bool = False) -> benedict.dicts.benedict:
415    def api_issue(
416        self,
417        nation: str,
418        issue: int,
419        option: int,
420        password: str = "",
421        constant_rate_limit: bool = False,
422    ) -> benedict:
423        """Answers an issue via the API.
424
425        Args:
426            nation (str): The nation to perform the command with.
427            issue (int): the ID of the issue.
428            option (int): the issue option to choose.
429            password (str, optional): The password to use for authenticating private api requests. Defaults to "". Not required if already signed in, whether through the api or through the HTML site.
430            constant_rate_limit (bool, optional): If True, will always rate limit. If False, will only rate limit when there's less than 10 requests left in the current bucket. Defaults to False.
431
432        Returns:
433            benedict: A benedict object containing the response from the server. Acts like a dictionary, with keypath and keylist support.
434        """
435        if not (password or self.pin):
436            raise ValueError("must specify authentication")
437        data = {
438            "v": "12",
439            "c": "issue",
440            "nation": canonicalize(nation),
441            "issue": issue,
442            "option": option,
443        }
444        url = "https://www.nationstates.net/cgi-bin/api.cgi"
445        if password:
446            self._session.headers["X-Password"] = password
447        if self.pin:
448            self._session.headers["X-Pin"] = self.pin
449        # rate limiting section
450        response = self._session.get(url, params=data)
451        # if the server tells us to wait, wait
452        self._wait_for_ratelimit(response.headers, constant_rate_limit)
453        response.raise_for_status()
454        parsed_response = benedict.from_xml(response.text, keyattr_dynamic=True)
455        parsed_response.standardize()
456        parsed_response: benedict = parsed_response["nation"]  # type: ignore
457        return parsed_response

Answers an issue via the API.

Arguments:
  • nation (str): The nation to perform the command with.
  • issue (int): the ID of the issue.
  • option (int): the issue option to choose.
  • password (str, optional): The password to use for authenticating private api requests. Defaults to "". Not required if already signed in, whether through the api or through the HTML site.
  • constant_rate_limit (bool, optional): If True, will always rate limit. If False, will only rate limit when there's less than 10 requests left in the current bucket. Defaults to False.
Returns:

benedict: A benedict object containing the response from the server. Acts like a dictionary, with keypath and keylist support.

def api_command( self, nation: str, command: str, data: dict, password: str = '', mode: str = '', constant_rate_limit: bool = False) -> benedict.dicts.benedict:
459    def api_command(
460        self,
461        nation: str,
462        command: str,
463        data: dict,
464        password: str = "",
465        mode: str = "",
466        constant_rate_limit: bool = False,
467    ) -> benedict:
468        """Sends a non-issue command to the nationstates api with the given data and password.
469
470        Args:
471            nation (str): The nation to perform the command with.
472            command (str): The command to perform. Must be "giftcard", "dispatch", "rmbpost"
473            data (str, optional): The unique data to send with the parameters of the command; consult the API docs for more information.
474            password (str, optional): The password to use for authenticating private api requests. Defaults to "". Not required if already signed in, whether through the api or through the HTML site.
475            mode (str, optional): Whether to prepare or to execute the command. If value is given, does one of the two and returns result, if no value is given, does both and returns result of execute.
476            constant_rate_limit (bool, optional): If True, will always rate limit. If False, will only rate limit when there's less than 10 requests left in the current bucket. Defaults to False.
477
478        Returns:
479            benedict: A benedict object containing the response from the server. Acts like a dictionary, with keypath and keylist support.
480        """
481        if command not in {"giftcard", "dispatch", "rmbpost"}:
482            raise ValueError("command must be 'giftcard', 'dispatch', or 'rmbpost'")
483        if not (password or self.pin):
484            raise ValueError("must specify authentication")
485        if mode not in {"", "prepare", "execute"}:
486            raise ValueError("mode must be prepare or execute")
487        data["v"] = "12"
488        data["nation"] = canonicalize(nation)
489        data["c"] = command
490        data["mode"] = mode if mode else "prepare"  # if no mode than first prepare
491        url = "https://www.nationstates.net/cgi-bin/api.cgi"
492        if password:
493            self._session.headers["X-Password"] = password
494        if self.pin:
495            self._session.headers["X-Pin"] = self.pin
496        # rate limiting section
497        response = self._session.get(url, params=data)
498        # if the server tells us to wait, wait
499        self._wait_for_ratelimit(response.headers, constant_rate_limit)
500        response.raise_for_status()
501        parsed_response = benedict.from_xml(response.text, keyattr_dynamic=True)
502        parsed_response.standardize()
503        parsed_response: benedict = parsed_response["nation"]  # type: ignore
504        if mode == "":
505            # if no mode was specified earlier, repeat command with execute and token
506            data["token"] = parsed_response["success"]
507            return self.api_command(nation, command, data, mode="execute")
508        else:
509            return parsed_response

Sends a non-issue command to the nationstates api with the given data and password.

Arguments:
  • nation (str): The nation to perform the command with.
  • command (str): The command to perform. Must be "giftcard", "dispatch", "rmbpost"
  • data (str, optional): The unique data to send with the parameters of the command; consult the API docs for more information.
  • password (str, optional): The password to use for authenticating private api requests. Defaults to "". Not required if already signed in, whether through the api or through the HTML site.
  • mode (str, optional): Whether to prepare or to execute the command. If value is given, does one of the two and returns result, if no value is given, does both and returns result of execute.
  • constant_rate_limit (bool, optional): If True, will always rate limit. If False, will only rate limit when there's less than 10 requests left in the current bucket. Defaults to False.
Returns:

benedict: A benedict object containing the response from the server. Acts like a dictionary, with keypath and keylist support.

def api_giftcard( self, nation: str, card_id: int, season: int, recipient: str, password: str = '', constant_rate_limit: bool = False) -> benedict.dicts.benedict:
511    def api_giftcard(
512        self,
513        nation: str,
514        card_id: int,
515        season: int,
516        recipient: str,
517        password: str = "",
518        constant_rate_limit: bool = False,
519    ) -> benedict:
520        """Gifts a card using the API.
521
522        Args:
523            nation (str): The nation to perform the command with.
524            card_id (int): The ID of the card to gift.
525            season (int): The season of the card to gift.
526            recipient (str): The nation to gift the card to.
527            password (str, optional): The password to use for authenticating private api requests. Defaults to "". Not required if already signed in, whether through the api or through the HTML site.
528            constant_rate_limit (bool, optional): If True, will always rate limit. If False, will only rate limit when there's less than 10 requests left in the current bucket. Defaults to False.
529
530        Returns:
531            benedict: A benedict object containing the response from the server. Acts like a dictionary, with keypath and keylist support.
532        """
533        data = {"cardid": card_id, "season": season, "to": canonicalize(recipient)}
534        return self.api_command(
535            nation, "giftcard", data, password, constant_rate_limit=constant_rate_limit
536        )

Gifts a card using the API.

Arguments:
  • nation (str): The nation to perform the command with.
  • card_id (int): The ID of the card to gift.
  • season (int): The season of the card to gift.
  • recipient (str): The nation to gift the card to.
  • password (str, optional): The password to use for authenticating private api requests. Defaults to "". Not required if already signed in, whether through the api or through the HTML site.
  • constant_rate_limit (bool, optional): If True, will always rate limit. If False, will only rate limit when there's less than 10 requests left in the current bucket. Defaults to False.
Returns:

benedict: A benedict object containing the response from the server. Acts like a dictionary, with keypath and keylist support.

def api_dispatch( self, nation: str, action: str, title: str = '', text: str = '', category: int = 0, subcategory: int = 0, dispatchid: int = 0, password: str = '', constant_rate_limit: bool = False) -> benedict.dicts.benedict:
538    def api_dispatch(
539        self,
540        nation: str,
541        action: str,
542        title: str = "",
543        text: str = "",
544        category: int = 0,
545        subcategory: int = 0,
546        dispatchid: int = 0,
547        password: str = "",
548        constant_rate_limit: bool = False,
549    ) -> benedict:
550        """Add, edit, or remove a dispatch.
551
552        Args:
553            nation (str): The nation to perform the command with.
554            action (str): The action to take. Must be "add", "edit", "remove"
555            title (str, optional): The dispatch title when adding or editing.
556            text (str, optional): The dispatch text when adding or editing.
557            category: (int, optional), The category ID when adding or editing.
558            subcategory (int, optional): The subcategory ID when adding or editing.
559            dispatchid (int, optional): The dispatch ID when editing or removing.
560            password (str, optional): The password to use for authenticating private api requests. Defaults to "". Not required if already signed in, whether through the api or through the HTML site.
561            constant_rate_limit (bool, optional): If True, will always rate limit. If False, will only rate limit when there's less than 10 requests left in the current bucket. Defaults to False.
562
563        Returns:
564            benedict: A benedict object containing the response from the server. Acts like a dictionary, with keypath and keylist support.
565        """
566        # TODO: maybe consider splitting these three functions?
567        # TODO: maybe create enums for category and subcategory
568        if action not in {"add", "edit", "remove"}:
569            raise ValueError("action must be 'add', 'edit', or 'remove'")
570        if action != "remove" and not all({title, text, category, subcategory}):
571            raise ValueError("must specify title, text, category, and subcategory")
572        if action != "add" and not dispatchid:
573            raise ValueError("must specify a dispatch id")
574
575        data = {"dispatch": action}
576        if title:
577            data["title"] = title
578        if text:
579            data["text"] = text
580        if category:
581            data["category"] = category
582        if subcategory:
583            data["subcategory"] = subcategory
584        if dispatchid:
585            data["dispatchid"] = dispatchid
586        return self.api_command(
587            nation, "dispatch", data, password, constant_rate_limit=constant_rate_limit
588        )

Add, edit, or remove a dispatch.

Arguments:
  • nation (str): The nation to perform the command with.
  • action (str): The action to take. Must be "add", "edit", "remove"
  • title (str, optional): The dispatch title when adding or editing.
  • text (str, optional): The dispatch text when adding or editing.
  • category: (int, optional), The category ID when adding or editing.
  • subcategory (int, optional): The subcategory ID when adding or editing.
  • dispatchid (int, optional): The dispatch ID when editing or removing.
  • password (str, optional): The password to use for authenticating private api requests. Defaults to "". Not required if already signed in, whether through the api or through the HTML site.
  • constant_rate_limit (bool, optional): If True, will always rate limit. If False, will only rate limit when there's less than 10 requests left in the current bucket. Defaults to False.
Returns:

benedict: A benedict object containing the response from the server. Acts like a dictionary, with keypath and keylist support.

def api_rmb( self, nation: str, region: str, text: str, password: str = '', constant_rate_limit: bool = False) -> benedict.dicts.benedict:
590    def api_rmb(
591        self,
592        nation: str,
593        region: str,
594        text: str,
595        password: str = "",
596        constant_rate_limit: bool = False,
597    ) -> benedict:
598        """Post a message on the regional message board via the API.
599
600        Args:
601            nation (str): The nation to perform the command with.
602            region (str): the region to post the message in.
603            text (str): the text to post.
604            password (str, optional): The password to use for authenticating private api requests. Defaults to "". Not required if already signed in, whether through the api or through the HTML site.
605            constant_rate_limit (bool, optional): If True, will always rate limit. If False, will only rate limit when there's less than 10 requests left in the current bucket. Defaults to False.
606
607        Returns:
608            benedict: A benedict object containing the response from the server. Acts like a dictionary, with keypath and keylist support.
609        """
610        data = {"region": region, "text": text}
611        return self.api_command(
612            nation, "rmbpost", data, password, constant_rate_limit=constant_rate_limit
613        )

Post a message on the regional message board via the API.

Arguments:
  • nation (str): The nation to perform the command with.
  • region (str): the region to post the message in.
  • text (str): the text to post.
  • password (str, optional): The password to use for authenticating private api requests. Defaults to "". Not required if already signed in, whether through the api or through the HTML site.
  • constant_rate_limit (bool, optional): If True, will always rate limit. If False, will only rate limit when there's less than 10 requests left in the current bucket. Defaults to False.
Returns:

benedict: A benedict object containing the response from the server. Acts like a dictionary, with keypath and keylist support.

def login(self, nation: str, password: str) -> bool:
615    def login(self, nation: str, password: str) -> bool:
616        """Logs in to the nationstates site.
617
618        Args:
619            nation (str): Nation name
620            password (str): Nation password
621
622        Returns:
623            bool: True if login was successful, False otherwise
624        """
625        self.logger.info(f"Logging in to {nation}")
626        url = f"https://www.nationstates.net/page=display_region/region={self._auth_region}"
627        # shoutouts to roavin for telling me i had to have page=display_region in the url so it'd work with a userclick parameter
628
629        data = {
630            "nation": canonicalize(nation),
631            "password": password,
632            "theme": "century",
633            "logging_in": "1",
634            "submit": "Login",
635        }
636
637        response = self.request(url, data)
638
639        soup = BeautifulSoup(response.text, "lxml")
640        # checks if the body tag has your nation name in it; if it does, you're logged in
641        if not soup.find("body", {"data-nname": canonicalize(nation)}):
642            return False
643
644        self.nation = canonicalize(nation)
645        return True

Logs in to the nationstates site.

Arguments:
  • nation (str): Nation name
  • password (str): Nation password
Returns:

bool: True if login was successful, False otherwise

def change_nation_flag(self, flag_filename: str) -> bool:
647    def change_nation_flag(self, flag_filename: str) -> bool:
648        """Changes the nation flag to the given image.
649
650        Args:
651            flag_filename (str): Filename of the flag to change to
652
653        Returns:
654            bool: True if the flag was changed, False otherwise
655        """
656        self.logger.info(f"Changing flag on {self.nation}")
657        # THIS WAS SO FUCKING FRUSTRATING BUT IT WORKS NOW AND IM NEVER TOUCHING THIS BULLSHIT UNLESS NS BREAKS IT AGAIN
658        url = "https://www.nationstates.net/cgi-bin/upload.cgi"
659
660        data = {
661            "nationname": self.nation,
662        }
663        files = {
664            "file": (
665                flag_filename,
666                open(flag_filename, "rb"),
667                mimetypes.guess_type(flag_filename)[0],
668            )
669        }
670
671        response = self.request(url, data=data, files=files)
672
673        if "page=settings" in response.headers["location"]:
674            self.refresh_auth_values()
675            return True
676        elif "Just a moment..." in response.text:
677            self.logger.warning(
678                "Cloudflare blocked you idiot get fucked have fun with that like I had to lmaoooooooooo"
679            )
680        return False

Changes the nation flag to the given image.

Arguments:
  • flag_filename (str): Filename of the flag to change to
Returns:

bool: True if the flag was changed, False otherwise

def change_nation_settings( self, *, email: str = '', pretitle: str = '', slogan: str = '', currency: str = '', animal: str = '', demonym_noun: str = '', demonym_adjective: str = '', demonym_plural: str = '', new_password: str = '') -> bool:
682    def change_nation_settings(
683        self,
684        *,
685        email: str = "",
686        pretitle: str = "",
687        slogan: str = "",
688        currency: str = "",
689        animal: str = "",
690        demonym_noun: str = "",
691        demonym_adjective: str = "",
692        demonym_plural: str = "",
693        new_password: str = "",
694    ) -> bool:
695        """Given a logged in session, changes customizable fields and settings of the logged in nation.
696        Variables must be explicitly named in the call to the function, e.g. "session.change_nation_settings(pretitle='Join Lily', currency='Join Lily')"
697
698        Args:
699            email (str, optional): New email for WA apps.
700            pretitle (str, optional): New pretitle of the nation. Max length of 28. Nation must have minimum population of 250 million.
701            slogan (str, optional): New Slogan/Motto of the nation. Max length of 55.
702            currency (str, optional): New currency of the nation. Max length of 40.
703            animal (str, optional): New national animal of the nation. Max length of 40.
704            demonym_noun (str, optional): Noun the nation will refer to its citizens as. Max length of 44.
705            demonym_adjective (str, optional): Adjective the nation will refer to its citizens as. Max length of 44.
706            demonym_plural (str, optional): Plural form of "demonym_noun". Max length of 44.
707            new_password (str, optional): New password to assign to the nation.
708
709        Returns:
710            bool: True if changes were successful, False otherwise.
711        """
712        self.logger.info(f"Changing settings on {self.nation}")
713        url = "https://www.nationstates.net/template-overall=none/page=settings"
714
715        data = {
716            "type": pretitle,
717            "slogan": slogan,
718            "currency": currency,
719            "animal": animal,
720            "demonym2": demonym_noun,
721            "demonym": demonym_adjective,
722            "demonym2pl": demonym_plural,
723            "email": email,
724            "password": new_password,
725            "confirm_password": new_password,
726            "update": " Update ",
727        }
728        # remove keys that have empty values
729        data = {k: v for k, v in data.items() if v}
730        # make sure everything is following the proper length limits and only contains acceptable characters
731        self._validate_fields(data)
732
733        response = self.request(url, data)
734        return "Your settings have been successfully updated." in response.text

Given a logged in session, changes customizable fields and settings of the logged in nation. Variables must be explicitly named in the call to the function, e.g. "session.change_nation_settings(pretitle='Join Lily', currency='Join Lily')"

Arguments:
  • email (str, optional): New email for WA apps.
  • pretitle (str, optional): New pretitle of the nation. Max length of 28. Nation must have minimum population of 250 million.
  • slogan (str, optional): New Slogan/Motto of the nation. Max length of 55.
  • currency (str, optional): New currency of the nation. Max length of 40.
  • animal (str, optional): New national animal of the nation. Max length of 40.
  • demonym_noun (str, optional): Noun the nation will refer to its citizens as. Max length of 44.
  • demonym_adjective (str, optional): Adjective the nation will refer to its citizens as. Max length of 44.
  • demonym_plural (str, optional): Plural form of "demonym_noun". Max length of 44.
  • new_password (str, optional): New password to assign to the nation.
Returns:

bool: True if changes were successful, False otherwise.

def move_to_region(self, region: str, password: str = '') -> bool:
736    def move_to_region(self, region: str, password: str = "") -> bool:
737        """Moves the nation to the given region.
738
739        Args:
740            region (str): Region to move to
741            password (str, optional): Region password, if the region is passworded
742
743        Returns:
744            bool: True if the move was successful, False otherwise
745        """
746        self.logger.info(f"Moving {self.nation} to {region}")
747        url = "https://www.nationstates.net/template-overall=none/page=change_region"
748
749        data = {"region_name": region, "move_region": "1"}
750        if password:
751            data["password"] = password
752        response = self.request(url, data)
753
754        if "Success!" in response.text:
755            self.region = canonicalize(region)
756            return True
757        return False

Moves the nation to the given region.

Arguments:
  • region (str): Region to move to
  • password (str, optional): Region password, if the region is passworded
Returns:

bool: True if the move was successful, False otherwise

def vote(self, pollid: str, option: str) -> bool:
759    def vote(self, pollid: str, option: str) -> bool:
760        """Votes on a poll.
761
762        Args:
763            pollid (str): ID of the poll to vote on, e.g. "199747"
764            option (str): Option to vote for (starts at 0)
765
766        Returns:
767            bool: True if the vote was successful, False otherwise
768        """
769        self.logger.info(f"Voting on poll {pollid} with {self.nation}")
770        url = f"https://www.nationstates.net/template-overall=none/page=poll/p={pollid}"
771
772        data = {"pollid": pollid, "q1": option, "poll_submit": "1"}
773        response = self.request(url, data)
774
775        return "Your vote has been lodged." in response.text

Votes on a poll.

Arguments:
  • pollid (str): ID of the poll to vote on, e.g. "199747"
  • option (str): Option to vote for (starts at 0)
Returns:

bool: True if the vote was successful, False otherwise

def join_wa(self, nation: str, app_id: str) -> bool:
779    def join_wa(self, nation: str, app_id: str) -> bool:
780        """Joins the WA with the given nation.
781
782        Args:
783            nation (str): Nation to join the WA with
784            app_id (str): ID of the WA application to use
785
786        Returns:
787            bool: True if the join was successful, False otherwise
788        """
789        self.logger.info(f"Joining WA with {nation}")
790        url = "https://www.nationstates.net/cgi-bin/join_un.cgi"
791
792        data = {"nation": canonicalize(nation), "appid": app_id.strip()}
793        response = self.request(url, data)
794
795        if "?welcome=1" in response.headers["location"]:
796            # since we're just getting thrown into a cgi script, we'll have to manually grab authentication values
797            self.refresh_auth_values()
798            return True
799        return False

Joins the WA with the given nation.

Arguments:
  • nation (str): Nation to join the WA with
  • app_id (str): ID of the WA application to use
Returns:

bool: True if the join was successful, False otherwise

def resign_wa(self):
801    def resign_wa(self):
802        """Resigns from the WA.
803
804        Returns:
805            bool: True if the resignation was successful, False otherwise
806        """
807        self.logger.info("Resigning from WA")
808        url = "https://www.nationstates.net/template-overall=none/page=UN_status"
809
810        data = {"action": "leave_UN", "submit": "1"}
811        response = self.request(url, data)
812
813        return "From this moment forward, your nation is on its own." in response.text

Resigns from the WA.

Returns:

bool: True if the resignation was successful, False otherwise

def apply_wa(self, reapply: bool = True) -> bool:
815    def apply_wa(self, reapply: bool = True) -> bool:
816        """Applies to the WA.
817
818        Args:
819            reapply (bool, optional): Whether to reapply if you've been sent an application that's still valid. Defaults to True.
820
821        Returns:
822            bool: True if the application was successful, False otherwise
823        """
824        self.logger.info(f"Applying to WA with {self.nation}")
825        url = "https://www.nationstates.net/template-overall=none/page=UN_status"
826
827        data = {"action": "join_UN"}
828        if reapply:
829            data["resend"] = "1"
830        else:
831            data["submit"] = "1"
832
833        response = self.request(url, data)
834        return (
835            "Your application to join the World Assembly has been received!"
836            in response.text
837        )

Applies to the WA.

Arguments:
  • reapply (bool, optional): Whether to reapply if you've been sent an application that's still valid. Defaults to True.
Returns:

bool: True if the application was successful, False otherwise

def endorse(self, nation: str, endorse: bool = True) -> bool:
839    def endorse(self, nation: str, endorse: bool = True) -> bool:
840        """Endorses the given nation.
841
842        Args:
843            nation (str): Nation to endorse
844            endorse (bool, optional): True=endorse, False=unendorse. Defaults to True.
845
846        Returns:
847            bool: True if the endorsement was successful, False otherwise
848        """
849        self.logger.info(
850            f"{('Unendorsing', 'Endorsing')[endorse]} {nation} with {self.nation}"
851        )
852        url = "https://www.nationstates.net/cgi-bin/endorse.cgi"
853
854        data = {
855            "nation": canonicalize(nation),
856            "action": "endorse" if endorse else "unendorse",
857        }
858        response = self.request(url, data)
859
860        return f"nation={canonicalize(nation)}" in response.headers["location"]

Endorses the given nation.

Arguments:
  • nation (str): Nation to endorse
  • endorse (bool, optional): True=endorse, False=unendorse. Defaults to True.
Returns:

bool: True if the endorsement was successful, False otherwise

def clear_dossier(self) -> bool:
862    def clear_dossier(self) -> bool:
863        """Clears a logged in nation's dossier.
864
865        Returns:
866            bool: Whether it was successful or not
867        """
868
869        self.logger.info(f"Clearing dossier on {self.nation}")
870        url = "https://www.nationstates.net/template-overall=none/page=dossier"
871        data = {"clear_dossier": "1"}
872        response = self.request(url, data)
873
874        return "Dossier cleared of nations." in response.text

Clears a logged in nation's dossier.

Returns:

bool: Whether it was successful or not

def add_to_dossier(self, nations: list[str] | str) -> bool:
876    def add_to_dossier(self, nations: list[str] | str) -> bool:
877        """Adds nations to the logged in nation's dossier.
878
879        Args:
880            nations (list[str] | str): List of nations to add, or a single nation
881
882        Returns:
883            bool: Whether it was successful or not
884        """
885
886        self.logger.info(f"Adding {nations} to dossier on {self.nation}")
887        url = "https://www.nationstates.net/dossier.cgi"
888        data = {
889            "currentnation": canonicalize(self.nation),
890            "action_append": "Upload Nation Dossier File",
891        }
892        files = {
893            "file": (
894                "dossier.txt",
895                "\n".join(nations).strip() if type(nations) is list else nations,
896                "text/plain",
897            ),
898        }
899        response = self.request(url, data, files=files)
900
901        self.refresh_auth_values()
902        return "appended=" in response.headers["location"]

Adds nations to the logged in nation's dossier.

Arguments:
  • nations (list[str] | str): List of nations to add, or a single nation
Returns:

bool: Whether it was successful or not

def wa_vote(self, council: str, vote: str) -> bool:
904    def wa_vote(self, council: str, vote: str) -> bool:
905        """Votes on the current WA resolution.
906
907        Args:
908            council (str): Must be "ga" for general assembly, "sc" for security council.
909            vote (str): Must be "for" or "against".
910
911        Returns:
912            bool: Whether the vote was successful or not
913        """
914        self.logger.info(
915            f"Voting {vote} on {council.upper()} resolution with {self.nation}"
916        )
917        if council not in {"ga", "sc"}:
918            raise ValueError("council must be 'ga' or 'sc'")
919        if vote not in {"for", "against"}:
920            raise ValueError("vote must be 'for' or 'against'")
921        self.logger.info("Voting on WA resolution")
922
923        url = f"https://www.nationstates.net/template-overall=none/page={council}"
924        data = {
925            "vote": f"Vote {vote.capitalize()}",
926        }
927        response = self.request(url, data)
928
929        return "Your vote has been lodged." in response.text

Votes on the current WA resolution.

Arguments:
  • council (str): Must be "ga" for general assembly, "sc" for security council.
  • vote (str): Must be "for" or "against".
Returns:

bool: Whether the vote was successful or not

def refound_nation(self, nation: str, password: str) -> bool:
931    def refound_nation(self, nation: str, password: str) -> bool:
932        """Refounds a nation.
933
934        Args:
935            nation (str): Name of the nation to refound
936            password (str): Password to the nation
937
938        Returns:
939            bool: Whether the nation was successfully refounded or not
940        """
941        url = "https://www.nationstates.net/template-overall=none/"
942        data = {
943            "logging_in": "1",
944            "restore_password": password,
945            "restore_nation": "1",
946            "nation": nation,
947        }
948        response = self.request(url, data=data)
949        if response.status_code == 302:
950            self.nation = nation
951            self.refresh_auth_values()
952            return True
953        return False

Refounds a nation.

Arguments:
  • nation (str): Name of the nation to refound
  • password (str): Password to the nation
Returns:

bool: Whether the nation was successfully refounded or not

def create_region( self, region_name: str, wfe: str, *, password: str = '', frontier: bool = False, executive_delegacy: bool = False) -> bool:
957    def create_region(
958        self,
959        region_name: str,
960        wfe: str,
961        *,
962        password: str = "",
963        frontier: bool = False,
964        executive_delegacy: bool = False,
965    ) -> bool:
966        """Creates a new region.
967
968        Args:
969            region_name (str): Name of the region
970            wfe (str): WFE of the region
971            password (str, optional): Password to the region. Defaults to "".
972            frontier (bool, optional): Whether or not the region is a frontier. Defaults to False.
973            executive_delegacy (bool, optional): Whether or not the region has an executive WA delegacy. Defaults to False. Ignored if frontier is True.
974
975        Returns:
976            bool: Whether the region was successfully created or not
977        """
978        self.logger.info(f"Creating new region {region_name}")
979        url = "https://www.nationstates.net/template-overall=none/page=create_region"
980        data = {
981            "page": "create_region",
982            "region_name": region_name.strip(),
983            "desc": wfe.strip(),
984            "create_region": "1",
985        }
986        if password:
987            data |= {"pw": "1", "rpassword": password}
988        if frontier:
989            data |= {"is_frontier": "1"}
990        elif executive_delegacy:
991            data |= {"delegate_control": "1"}
992        response = self.request(url, data)
993        return "Success! You have founded " in response.text

Creates a new region.

Arguments:
  • region_name (str): Name of the region
  • wfe (str): WFE of the region
  • password (str, optional): Password to the region. Defaults to "".
  • frontier (bool, optional): Whether or not the region is a frontier. Defaults to False.
  • executive_delegacy (bool, optional): Whether or not the region has an executive WA delegacy. Defaults to False. Ignored if frontier is True.
Returns:

bool: Whether the region was successfully created or not

def upload_to_region(self, type: str, filename: str) -> str:
 995    def upload_to_region(self, type: str, filename: str) -> str:
 996        """Uploads a file to the current region.
 997
 998        Args:
 999            type (str): Type of file to upload. Must be "flag" or "banner".
1000            filename (str): Name of the file to upload. e.g. "myflag.png"
1001
1002        Raises:
1003            ValueError: If type is not "flag" or "banner"
1004
1005        Returns:
1006            str: Empty string if the upload failed, otherwise the ID of the uploaded file
1007        """
1008        self.logger.info(f"Uploading {filename} to {self.region}")
1009        if type not in {"flag", "banner"}:
1010            raise ValueError("type must be 'flag' or 'banner'")
1011        url = "https://www.nationstates.net/cgi-bin/upload.cgi"
1012        data = {
1013            "uploadtype": f"r{type}",
1014            "page": "region_control",
1015            "region": self.region,
1016            "expect": "json",
1017        }
1018        files = {
1019            f"file_upload_r{type}": (
1020                filename,
1021                open(filename, "rb"),
1022                mimetypes.guess_type(filename)[0],
1023            )
1024        }
1025        response = self.request(url, data, files=files)
1026        return "" if "id" not in response.json() else response.json()["id"]

Uploads a file to the current region.

Arguments:
  • type (str): Type of file to upload. Must be "flag" or "banner".
  • filename (str): Name of the file to upload. e.g. "myflag.png"
Raises:
  • ValueError: If type is not "flag" or "banner"
Returns:

str: Empty string if the upload failed, otherwise the ID of the uploaded file

def set_flag_and_banner( self, flag_id: str = '', banner_id: str = '', flag_mode: str = '') -> bool:
1028    def set_flag_and_banner(
1029        self, flag_id: str = "", banner_id: str = "", flag_mode: str = ""
1030    ) -> bool:
1031        """Sets the uploaded flag and/or banner for the current region.
1032
1033        Args:
1034            flag_id (str, optional): ID of the flag, uploaded with upload_to_region(). Defaults to "".
1035            banner_id (str, optional): ID of the banner, uploaded with upload_to_region(). Defaults to "".
1036            flagmode (str, optional): Must be "flag" which will have a shadow, or "logo" which will not, or "" to not change it. Defaults to "".
1037
1038        Raises:
1039            ValueError: If flagmode is not "flag", "logo", or ""
1040
1041        Returns:
1042            bool: Whether the change was successful or not
1043        """
1044        if flag_mode not in {"flag", "logo", ""}:
1045            raise ValueError("flagmode must be 'flag', 'logo', or ''")
1046        self.logger.info(f"Setting flag and banner for {self.region}")
1047        url = "https://www.nationstates.net/template-overall=none/page=region_control/"
1048        data = {
1049            "newflag": flag_id,
1050            "newbanner": banner_id,
1051            "saveflagandbannerchanges": "1",
1052            "flagmode": flag_mode,
1053        }
1054        # remove entries with empty values
1055        data = {k: v for k, v in data.items() if v}
1056
1057        response = self.request(url, data)
1058
1059        return "Regional banner/flag updated!" in response.text

Sets the uploaded flag and/or banner for the current region.

Arguments:
  • flag_id (str, optional): ID of the flag, uploaded with upload_to_region(). Defaults to "".
  • banner_id (str, optional): ID of the banner, uploaded with upload_to_region(). Defaults to "".
  • flagmode (str, optional): Must be "flag" which will have a shadow, or "logo" which will not, or "" to not change it. Defaults to "".
Raises:
  • ValueError: If flagmode is not "flag", "logo", or ""
Returns:

bool: Whether the change was successful or not

def change_wfe(self, wfe: str = '') -> bool:
1061    def change_wfe(self, wfe: str = "") -> bool:
1062        """Changes the WFE of the current region.
1063
1064        Args:
1065            wfe (str, optional): World Factbook Entry to change to. Defaults to the oldest WFE the region has, for detags.
1066
1067        Returns:
1068            bool: True if successful, False otherwise
1069        """
1070        self.logger.info(f"Changing WFE for {self.region}")
1071        if not wfe:
1072            wfe = self._get_detag_wfe()  # haku im sorry for hitting your site so much
1073        url = "https://www.nationstates.net/template-overall=none/page=region_control/"
1074        data = {
1075            "message": wfe.encode("iso-8859-1", "xmlcharrefreplace")
1076            .decode()
1077            .strip(),  # lol.
1078            "setwfebutton": "1",
1079        }
1080        response = self.request(url, data)
1081        return "World Factbook Entry updated!" in response.text

Changes the WFE of the current region.

Arguments:
  • wfe (str, optional): World Factbook Entry to change to. Defaults to the oldest WFE the region has, for detags.
Returns:

bool: True if successful, False otherwise

def request_embassy(self, target: str) -> bool:
1085    def request_embassy(self, target: str) -> bool:
1086        """Requests an embassy with a region.
1087
1088        Args:
1089            target (str): The region to request the embassy with.
1090
1091        Returns:
1092            bool: Whether the request was successfully sent or not
1093        """
1094        self.logger.info(f"Requesting embassy with {target}")
1095        url = "https://www.nationstates.net/template-overall=none/page=region_control/"
1096        data = {
1097            "requestembassyregion": target,
1098            "requestembassy": "1",  # it's silly that requesting needs this but not closing, aborting, or cancelling
1099        }
1100        response = self.request(url, data)
1101        return "Your proposal for the construction of embassies with" in response.text

Requests an embassy with a region.

Arguments:
  • target (str): The region to request the embassy with.
Returns:

bool: Whether the request was successfully sent or not

def close_embassy(self, target: str) -> bool:
1103    def close_embassy(self, target: str) -> bool:
1104        """Closes an embassy with a region.
1105
1106        Args:
1107            target (str): The region with which to close the embassy.
1108
1109        Returns:
1110            bool: Whether the embassy was successfully closed or not
1111        """
1112        self.logger.info(f"Closing embassy with {target}")
1113        url = "https://www.nationstates.net/template-overall=none/page=region_control/"
1114        data = {"cancelembassyregion": target}
1115        response = self.request(url, data)
1116        return " has been scheduled for demolition." in response.text

Closes an embassy with a region.

Arguments:
  • target (str): The region with which to close the embassy.
Returns:

bool: Whether the embassy was successfully closed or not

def abort_embassy(self, target: str) -> bool:
1118    def abort_embassy(self, target: str) -> bool:
1119        """Aborts an embassy with a region.
1120
1121        Args:
1122            target (str): The region with which to abort the embassy.
1123
1124        Returns:
1125            bool: Whether the embassy was successfully aborted or not
1126        """
1127        self.logger.info(f"Aborting embassy with {target}")
1128        url = "https://www.nationstates.net/template-overall=none/page=region_control/"
1129        data = {"abortembassyregion": target}
1130        response = self.request(url, data)
1131        return " aborted." in response.text

Aborts an embassy with a region.

Arguments:
  • target (str): The region with which to abort the embassy.
Returns:

bool: Whether the embassy was successfully aborted or not

def cancel_embassy(self, target: str) -> bool:
1133    def cancel_embassy(self, target: str) -> bool:
1134        """Cancels an embassy with a region.
1135
1136        Args:
1137            target (str): The region with which to cancel the embassy.
1138
1139        Returns:
1140            bool: Whether the embassy was successfully cancelled or not
1141        """
1142        self.logger.info(f"Cancelling embassy with {target}")
1143        url = "https://www.nationstates.net/template-overall=none/page=region_control/"
1144        data = {"cancelembassyclosureregion": target}
1145        response = self.request(url, data)
1146        return "Embassy closure order cancelled." in response.text

Cancels an embassy with a region.

Arguments:
  • target (str): The region with which to cancel the embassy.
Returns:

bool: Whether the embassy was successfully cancelled or not

def tag(self, action: str, tag: str) -> bool:
1150    def tag(self, action: str, tag: str) -> bool:
1151        """Adds or removes a tag to the current region.
1152
1153        Args:
1154            action (str): The action to take. Must be "add" or "remove".
1155            tag (str): The tag to add or remove.
1156
1157        Raises:
1158            ValueError: If action is not "add" or "remove", or if tag is not a valid tag.
1159
1160        Returns:
1161            bool: Whether the tag was successfully added or removed
1162        """
1163        if action not in {"add", "remove"}:
1164            raise ValueError("action must be 'add' or 'remove'")
1165        if canonicalize(tag) not in valid.REGION_TAGS:
1166            raise ValueError(f"{tag} is not a valid tag")
1167        self.logger.info(f"{action.capitalize()}ing tag {tag} for {self.region}")
1168        url = "https://www.nationstates.net/template-overall=none/page=region_control/"
1169        data = {
1170            f"{action}_tag": canonicalize(tag),
1171            "updatetagsbutton": "1",
1172        }
1173        response = self.request(url, data)
1174        return "Region Tags updated!" in response.text

Adds or removes a tag to the current region.

Arguments:
  • action (str): The action to take. Must be "add" or "remove".
  • tag (str): The tag to add or remove.
Raises:
  • ValueError: If action is not "add" or "remove", or if tag is not a valid tag.
Returns:

bool: Whether the tag was successfully added or removed

def eject(self, nation: str) -> bool:
1176    def eject(self, nation: str) -> bool:
1177        """Ejects a nation from the current region. Note that a 1 second delay is required before ejecting another nation.
1178
1179        Args:
1180            nation (str): The nation to eject.
1181
1182        Returns:
1183            bool: Whether the nation was successfully ejected or not
1184        """
1185        self.logger.info(f"Ejecting {nation} from {self.region}")
1186        url = "https://www.nationstates.net/template-overall=none/page=region_control/"
1187        data = {"nation_name": nation, "eject": "1"}
1188        response = self.request(url, data)
1189        return "has been ejected from " in response.text

Ejects a nation from the current region. Note that a 1 second delay is required before ejecting another nation.

Arguments:
  • nation (str): The nation to eject.
Returns:

bool: Whether the nation was successfully ejected or not

def banject(self, nation: str) -> bool:
1191    def banject(self, nation: str) -> bool:
1192        """Bans a nation from the current region. Note that a 1 second delay is required before banjecting another nation.
1193
1194        Args:
1195            nation (str): The nation to banject.
1196
1197        Returns:
1198            bool: Whether the nation was successfully banjected or not
1199        """
1200        self.logger.info(f"Banjecting {nation} from {self.region}")
1201        url = "https://www.nationstates.net/template-overall=none/page=region_control/"
1202        data = {"nation_name": nation, "ban": "1"}
1203        response = self.request(url, data)
1204        return "has been ejected and banned from " in response.text

Bans a nation from the current region. Note that a 1 second delay is required before banjecting another nation.

Arguments:
  • nation (str): The nation to banject.
Returns:

bool: Whether the nation was successfully banjected or not

def junk_card(self, id: str, season: str) -> bool:
1208    def junk_card(self, id: str, season: str) -> bool:
1209        """Junks a card from the current nation's deck.
1210        Args:
1211            id (str): ID of the card to junk
1212            season (str): Season of the card to junk
1213        Returns:
1214            bool: Whether the card was successfully junked or not
1215        """
1216        self.logger.info(f"Junking card {id} from season {season}")
1217        url = "https://www.nationstates.net/template-overall=none/page=deck"
1218
1219        data = {"page": "ajax3", "a": "junkcard", "card": id, "season": season}
1220        response = self.request(url, data)
1221
1222        return "Your Deck" in response.text

Junks a card from the current nation's deck.

Arguments:
  • id (str): ID of the card to junk
  • season (str): Season of the card to junk
Returns:

bool: Whether the card was successfully junked or not

def open_pack(self) -> bool:
1224    def open_pack(self) -> bool:
1225        """Opens a card pack.
1226
1227        Returns:
1228            bool: Whether the bid was successfully removed or not
1229        """
1230        self.logger.info("Opening trading card pack")
1231        url = "https://www.nationstates.net/template-overall=none/page=deck"
1232        data = {"open_loot_box": "1"}
1233        response = self.request(url, data)
1234        return "Tap cards to reveal..." in response.text

Opens a card pack.

Returns:

bool: Whether the bid was successfully removed or not

def ask(self, price: str, card_id: str, season: str) -> bool:
1236    def ask(self, price: str, card_id: str, season: str) -> bool:
1237        """Puts an ask at price on a card in a season
1238
1239        Args:
1240            price (str): Price to ask
1241            card_id (str): ID of the card
1242            season (str): Season of the card
1243
1244        Returns:
1245            bool: Whether the ask was successfully lodged or not
1246        """
1247        self.logger.info(f"Asking for {price} on {card_id} season {season}")
1248        url = f"https://www.nationstates.net/template-overall=none/page=deck/card={card_id}/season={season}"
1249
1250        data = {"auction_ask": price, "auction_submit": "ask"}
1251        response = self.request(url, data)
1252        return f"Your ask of {price} has been lodged." in response.text

Puts an ask at price on a card in a season

Arguments:
  • price (str): Price to ask
  • card_id (str): ID of the card
  • season (str): Season of the card
Returns:

bool: Whether the ask was successfully lodged or not

def bid(self, price: str, card_id: str, season: str) -> bool:
1254    def bid(self, price: str, card_id: str, season: str) -> bool:
1255        """Places a bid on a card in a season
1256
1257        Args:
1258            price (str): Amount of bank to bid
1259            card_id (str): ID of the card
1260            season (str): Season of the card
1261
1262        Returns:
1263            bool: Whether the bid was successfully lodged or not
1264        """
1265        self.logger.info(f"Putting a bid for {price} on {card_id} season {season}")
1266        url = f"https://www.nationstates.net/template-overall=none/page=deck/card={card_id}/season={season}"
1267
1268        data = {"auction_bid": price, "auction_submit": "bid"}
1269        response = self.request(url, data)
1270
1271        return f"Your bid of {price} has been lodged." in response.text

Places a bid on a card in a season

Arguments:
  • price (str): Amount of bank to bid
  • card_id (str): ID of the card
  • season (str): Season of the card
Returns:

bool: Whether the bid was successfully lodged or not

def remove_ask(self, price: str, card_id: str, season: str) -> bool:
1273    def remove_ask(self, price: str, card_id: str, season: str) -> bool:
1274        """Removes an ask on card_id in season at price
1275
1276        Args:
1277            price (str): Price of the ask to remove
1278            card_id (str): ID of the card
1279            season (str): Season of the card
1280
1281        Returns:
1282            bool: Whether the ask was successfully removed or not
1283        """
1284
1285        self.logger.info(f"removing an ask for {price} on {card_id} season {season}")
1286        url = f"https://www.nationstates.net/template-overall=none/page=deck/card={card_id}/season={season}"
1287
1288        data = {"new_price": price, "remove_ask_price": price}
1289        response = self.request(url, data)
1290        return f"Removed your ask for {price}" in response.text

Removes an ask on card_id in season at price

Arguments:
  • price (str): Price of the ask to remove
  • card_id (str): ID of the card
  • season (str): Season of the card
Returns:

bool: Whether the ask was successfully removed or not

def remove_bid(self, price: str, card_id: str, season: str) -> bool:
1292    def remove_bid(self, price: str, card_id: str, season: str) -> bool:
1293        """Removes a bid on a card
1294
1295        Args:
1296            price (str): Price of the bid to remove
1297            card_id (str): ID of the card
1298            season (str): Season of the card
1299
1300        Returns:
1301            bool: Whether the bid was successfully removed or not
1302        """
1303
1304        self.logger.info(f"Removing a bid for {price} on {card_id} season {season}")
1305        url = f"https://www.nationstates.net/template-overall=none/page=deck/card={card_id}/season={season}"
1306
1307        data = {"new_price": price, "remove_bid_price": price}
1308        response = self.request(url, data)
1309
1310        return f"Removed your bid for {price}" in response.text

Removes a bid on a card

Arguments:
  • price (str): Price of the bid to remove
  • card_id (str): ID of the card
  • season (str): Season of the card
Returns:

bool: Whether the bid was successfully removed or not

def expand_deck(self, price: str) -> bool:
1312    def expand_deck(self, price: str) -> bool:
1313        """Upgrades deck capacity
1314
1315        Args:
1316            price (str): Price of the Upgrade
1317
1318        Returns:
1319            bool: Whether the upgrade was successfully removed or not
1320        """
1321
1322        self.logger.info(f"Upgrading your deck at a cost of {price}")
1323        url = "https://www.nationstates.net/template-overall=none/page=deck"
1324
1325        data = {"embiggen_deck": price}
1326        response = self.request(url, data)
1327
1328        return "Increased deck capacity from" in response.text

Upgrades deck capacity

Arguments:
  • price (str): Price of the Upgrade
Returns:

bool: Whether the upgrade was successfully removed or not

def add_to_collection(self, card_id: str, card_season: str, collection_id: str):
1330    def add_to_collection(self, card_id: str, card_season: str, collection_id: str):
1331        """Adds a card to collection_id
1332
1333        Args:
1334            card_id (str): Card ID
1335            card_season (str): Cards season
1336            collection_id (str): The ID of the collection you want to add to
1337
1338        Returns:
1339            bool: Whether the adding was successfully added or not
1340        """
1341        self.logger.info(f"Adding {card_id} of season {card_season} to {collection_id}")
1342        url = f"https://www.nationstates.net/template-overall=none/page=deck/card={card_id}/season={card_season}"
1343
1344        data = {
1345            "manage_collections": "1",
1346            "modify_card_in_collection": "1",
1347            f"collection_{collection_id}": "1",
1348            "save_collection": "1",
1349        }
1350        response = self.request(url, data)
1351
1352        return "Updated collections." in response.text

Adds a card to collection_id

Arguments:
  • card_id (str): Card ID
  • card_season (str): Cards season
  • collection_id (str): The ID of the collection you want to add to
Returns:

bool: Whether the adding was successfully added or not

def remove_from_collection(self, card_id: str, card_season: str, collection_id: str):
1354    def remove_from_collection(
1355        self, card_id: str, card_season: str, collection_id: str
1356    ):
1357        """Removes a card from collection_id
1358
1359        Args:
1360            card_id (str): Card ID
1361            card_season (str): Cards season
1362            collection_id (str): The ID of the collection you want to remove from
1363
1364        Returns:
1365            bool: Whether the removal was successfully added or not
1366        """
1367        self.logger.info(
1368            f"Removing {card_id} of season {card_season} from {collection_id}"
1369        )
1370        url = f"https://www.nationstates.net/template-overall=none/page=deck/card={card_id}/season={card_season}"
1371
1372        data = {
1373            "manage_collections": "1",
1374            "modify_card_in_collection": "1",
1375            "start": "0",
1376            f"collection_{collection_id}": "0",
1377            "save_collection": "1",
1378        }
1379        response = self.request(url, data)
1380
1381        return "Updated collections." in response.text

Removes a card from collection_id

Arguments:
  • card_id (str): Card ID
  • card_season (str): Cards season
  • collection_id (str): The ID of the collection you want to remove from
Returns:

bool: Whether the removal was successfully added or not

def create_collection(self, name: str):
1383    def create_collection(self, name: str):
1384        """Creates a collection named name
1385
1386        Args:
1387            name (str): The name of the collection you want to create
1388
1389        Returns:
1390            bool: Whether the creating was successfully added or not
1391        """
1392        self.logger.info(f"Creating {name} collection")
1393        url = "https://www.nationstates.net/template-overall=none/page=deck"
1394
1395        data = {"edit": "1", "collection_name": name, "save_collection": "1"}
1396        response = self.request(url, data)
1397
1398        return "Created collection!" in response.text

Creates a collection named name

Arguments:
  • name (str): The name of the collection you want to create
Returns:

bool: Whether the creating was successfully added or not

def delete_collection(self, name: str):
1400    def delete_collection(self, name: str):
1401        """Deletes a collection named name
1402
1403        Args:
1404            name (str): The name of the collection you want to delete
1405
1406        Returns:
1407            bool: Whether the deleting was successfully added or not
1408        """
1409        self.logger.info(f"Deleting {name} collection")
1410        url = "https://www.nationstates.net/template-overall=none/page=deck"
1411
1412        data = {"edit": "1", "collection_name": name, "delete_collection": "1"}
1413        response = self.request(url, data)
1414
1415        return "Created collection!" in response.text

Deletes a collection named name

Arguments:
  • name (str): The name of the collection you want to delete
Returns:

bool: Whether the deleting was successfully added or not

def can_nation_be_founded(self, name: str):
1417    def can_nation_be_founded(self, name: str):
1418        """Checks if a nation can be founded
1419
1420        Args:
1421            name (str): The name of the nation you want to check
1422
1423        Returns:
1424            bool: Whether the nation can be founded or not
1425        """
1426        self.logger.info(f"Checking {name} in boneyard")
1427        url = "https://www.nationstates.net/template-overall=none/page=boneyard"
1428
1429        data = {"nation": name, "submit": "1"}
1430        response = self.request(url, data)
1431
1432        return (
1433            "Available! This name may be used to found a new nation." in response.text
1434        )

Checks if a nation can be founded

Arguments:
  • name (str): The name of the nation you want to check
Returns:

bool: Whether the nation can be founded or not

def join_nday_faction(self, id: str):
1436    def join_nday_faction(self, id: str):
1437        """Joins a faction in the N-Day event
1438
1439        Args:
1440            id (str): The ID of the faction you want to join
1441
1442        Returns:
1443            bool: Whether the joining was successful or not
1444        """
1445        self.logger.info(f"Joining faction {id}")
1446
1447        url = (
1448            f"https://www.nationstates.net/template-overall=none/page=faction/fid={id}"
1449        )
1450        data = {"join_faction": "1"}
1451        response = self.request(url, data)
1452
1453        return " has joined " in response.text

Joins a faction in the N-Day event

Arguments:
  • id (str): The ID of the faction you want to join
Returns:

bool: Whether the joining was successful or not

def leave_nday_faction(self, id: str):
1455    def leave_nday_faction(self, id: str):
1456        """Leaves a faction in the N-Day event
1457
1458        Args:
1459            id (str): The ID of the faction you want to leave
1460
1461        Returns:
1462            bool: Whether the leaving was successful or not
1463        """
1464        self.logger.info(f"Leaving faction {id}")
1465
1466        url = (
1467            f"https://www.nationstates.net/template-overall=none/page=faction/fid={id}"
1468        )
1469        data = {"leave_faction": "1"}
1470        response = self.request(url, data)
1471
1472        return " has left " in response.text

Leaves a faction in the N-Day event

Arguments:
  • id (str): The ID of the faction you want to leave
Returns:

bool: Whether the leaving was successful or not