├── .dockerignore ├── .github ├── dependabot.yaml └── workflows │ └── build-to-dockerhub.yml ├── .gitignore ├── CYTHON_PARSER.md ├── Chat Commands.md ├── DISCORD_BOT_SETUP.md ├── Dockerfile ├── LICENSE ├── OPTIONAL_REQUIREMENTS ├── README.md ├── REQUIREMENTS ├── VERSION ├── base_plugin.py ├── build_parser.py ├── c_parser.pyx ├── config ├── config.json.default └── permissions.json.default ├── configuration_manager.py ├── data_parser.py ├── docker-start.sh ├── obsolete_plugins ├── .gitkeep ├── __init__.py ├── obsolete.md ├── static │ ├── css │ │ └── template.css │ └── who.html ├── watchdog.py └── web_plugin.py ├── packets.py ├── plugin_manager.py ├── plugins ├── __init__.py ├── basic_auth.py ├── chat_enhancements.py ├── chat_logger.py ├── chat_manager.py ├── claims.py ├── command_dispatcher.py ├── discord_bot.py ├── emotes.py ├── emsg_blocker.py ├── general_commands.py ├── help.py ├── irc_bot.py ├── mail.py ├── motd.py ├── new_player_greeter.py ├── opensb_detector.py ├── planet_announcer.py ├── planet_backups.py.wip ├── planet_protect.py ├── player_manager.py ├── poi.py ├── privileged_chatter.py ├── spawn.py ├── species_whitelist.py └── warp_plugin.py ├── pparser.py ├── requirements.txt ├── server.py ├── tests ├── __init__.py ├── test │ ├── __init__.py │ ├── run_tests.py │ ├── test_configuration_manager.py │ ├── test_plugin_manager.py │ └── test_server.py ├── test_config │ ├── complex_config.json │ ├── complex_config.json.default │ ├── complex_merged_config.json │ ├── trivial_config.json │ ├── trivial_config.json.default │ ├── trivial_merged_config.json │ └── unicode_config.json └── test_plugins │ ├── __init__.py │ ├── bad_plugin │ ├── __init__.py │ └── bad_plugin.py │ ├── dependent_plugins │ ├── __init__.py │ ├── a.py │ ├── b.py │ └── circular.py │ ├── test_plugin_2.py │ └── test_plugin_package │ ├── __init__.py │ └── plugin.py ├── utilities.py ├── zstd_reader.py └── zstd_writer.py /.dockerignore: -------------------------------------------------------------------------------- 1 | .dockerignore 2 | Dockerfile 3 | .venv 4 | .github 5 | config/*.json 6 | config/*.log -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "docker" # See documentation for possible values 4 | directory: "/" # Location of package manifests 5 | schedule: 6 | interval: "monthly" 7 | - package-ecosystem: "pip" # See documentation for possible values 8 | directory: "/" # Location of package manifests 9 | schedule: 10 | interval: "monthly" 11 | - package-ecosystem: "github-actions" # See documentation for possible values 12 | directory: "/" # Location of package manifests 13 | schedule: 14 | interval: "monthly" -------------------------------------------------------------------------------- /.github/workflows/build-to-dockerhub.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: 6 | - "**" 7 | tags: 8 | - "v*.*.*" 9 | 10 | 11 | 12 | 13 | jobs: 14 | docker-build-and-push: 15 | runs-on: ubuntu-latest 16 | if: ${{ github.actor != 'dependabot[bot]' }} 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v4 20 | 21 | - name: Docker meta 22 | id: meta 23 | uses: docker/metadata-action@v4 24 | with: 25 | # list of Docker images to use as base name for tags 26 | images: | 27 | ${{ secrets.DOCKERHUB_IMAGE }} 28 | # generate Docker tags based on the following events/attributes 29 | tags: | 30 | type=ref,event=branch 31 | type=semver,pattern={{version}} 32 | type=semver,pattern={{major}}.{{minor}} 33 | type=semver,pattern={{major}} 34 | 35 | - name: Set up QEMU 36 | uses: docker/setup-qemu-action@v3 37 | 38 | - name: Set up Docker Buildx 39 | uses: docker/setup-buildx-action@v3 40 | 41 | - name: Login to Docker Hub 42 | if: github.event_name != 'pull_request' 43 | uses: docker/login-action@v3 44 | with: 45 | username: ${{ secrets.DOCKERHUB_USERNAME }} 46 | password: ${{ secrets.DOCKERHUB_TOKEN }} 47 | 48 | - name: Build and push 49 | uses: docker/build-push-action@v6 50 | with: 51 | context: . 52 | push: ${{ github.event_name != 'pull_request' }} 53 | tags: ${{ steps.meta.outputs.tags }} 54 | labels: ${{ steps.meta.outputs.labels }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | parts 13 | bin 14 | var 15 | sdist 16 | develop-eggs 17 | .installed.cfg 18 | lib 19 | lib64 20 | __pycache__ 21 | 22 | # Installer logs 23 | pip-log.txt 24 | 25 | # Unit test / coverage reports 26 | .coverage 27 | .tox 28 | nosetests.xml 29 | 30 | # Translations 31 | *.mo 32 | 33 | # Mr Developer 34 | .mr.developer.cfg 35 | .project 36 | .pydevproject 37 | .vscode 38 | 39 | # Project specific 40 | /config/player.dat 41 | /config/player.bak 42 | /config/player.dir 43 | /config/player 44 | /config/*.json 45 | *config.json 46 | *.db 47 | *.db-journal 48 | *.log 49 | .idea/ 50 | 51 | # Cleanup after vim 52 | *.swp 53 | *.un~ 54 | .ropeproject/ 55 | 56 | # Cleanup after Macs 57 | .DS_Store 58 | 59 | #python virtualenv 60 | virtualenv 61 | .venv 62 | 63 | # dev container 64 | .devcontainer -------------------------------------------------------------------------------- /CYTHON_PARSER.md: -------------------------------------------------------------------------------- 1 | ## Information 2 | The Cython parser is a Cython (C-Python fusion language) module for the StarryPy packer parser. 3 | Since it's compiled, it is significantly faster than the pure Python parser. It is distributed as source 4 | with StarryPy3k and includes a file to compile it quickly. Note that the Cython parser is experimental and 5 | therefore may contain bugs, does not yet do all of the parsing work, and is disabled by default. 6 | ## Using the Cython parser 7 | Prerequisites: 8 | - Cython (built against 0.25.2) 9 | - A C compiler 10 | 11 | Simply run the following command in StarryPy3k's base directory: 12 | 13 | `python build_parser.py build_ext --inplace` 14 | 15 | Once the Cython parser has been built (as a `.so` on Linux or a `.pyd` on Windows), it will be used automatically. 16 | -------------------------------------------------------------------------------- /DISCORD_BOT_SETUP.md: -------------------------------------------------------------------------------- 1 | # StarryPy Discord Bot 2 | 3 | StarryPy includes a plugin that allows you to connect a bot to a Discord 4 | server that will relay messages between the Starbound and Discord servers. 5 | If the IRC bot also included with StarryPy is enabled, the Discord and IRC 6 | plugins will also relay messages between each other. 7 | 8 | ## Using the Discord Bot 9 | The Discord bot plugin uses the [discord.py](https://github.com/Rapptz/discord.py) 10 | library, and will not work without it. 11 | 12 | To use the Discord bot, you will need to create a Discord application. Go to 13 | [this page](https://discordapp.com/developers/applications/me) and click the 14 | "New Application" button. 15 | Give your bot a descriptive name; giving it an avatar or description is optional. 16 | 17 | Once you have created your application, you must create a bot user by clicking the "Create a Bot User" option on 18 | the application's page. 19 | 20 | To add the bot to your server, go to `https://discordapp.com/oauth2/authorize?&client_id=CLIENT_ID&scope=bot&permissions=0` 21 | (replacing `CLIENT_ID` with your application's client ID) and add the 22 | bot to your server. 23 | 24 | Once the bot is added to your server, copy the token 25 | and client id from the application to the fields in config.json. Get the 26 | channel id for a given channel by enabling Developer Mode in 27 | Discord, right-clicking a channel, and clicking "Copy ID," then paste this 28 | ID into the "channel" field in config.json. Your bot should now be fully 29 | configured and ready to use. -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.13-bookworm 2 | WORKDIR /app 3 | 4 | COPY requirements.txt . 5 | RUN pip install -r requirements.txt 6 | 7 | COPY . . 8 | RUN mkdir /app/defaults 9 | COPY config/*.default /app/defaults/ 10 | 11 | COPY config/permissions.json.default config/permissions.json 12 | 13 | VOLUME /app/config 14 | 15 | CMD [ "./docker-start.sh" ] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | /* This program is free software. It comes without any warranty, to 2 | * the extent permitted by applicable law. You can redistribute it 3 | * and/or modify it under the terms of the Do What The Fuck You Want 4 | * To Public License, Version 2, as published by Sam Hocevar. See 5 | * http://www.wtfpl.net/ for more details. */ 6 | 7 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE 8 | Version 2, December 2004 9 | 10 | Copyright (C) 2004 Pat Shanahan 11 | 12 | Everyone is permitted to copy and distribute verbatim or modified 13 | copies of this license document, and changing it is allowed as long 14 | as the name is changed. 15 | 16 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE 17 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 18 | 19 | 0. You just DO WHAT THE FUCK YOU WANT TO. -------------------------------------------------------------------------------- /OPTIONAL_REQUIREMENTS: -------------------------------------------------------------------------------- 1 | irc3 2 | discord.py -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # StarryPy3k 2 | 3 | ## About 4 | StarryPy3k is the successor to StarryPy. StarryPy is a plugin-driven Starbound 5 | server wrapper that adds a great deal of functionality to Starbound. StarryPy3k 6 | is written using asyncio in Python 3.11. 7 | 8 | ***Please note this is still in very active development and is not ready to be 9 | used on general purpose servers. It should mostly work, but you have been 10 | forewarned.*** 11 | 12 | ## Requirements 13 | Due to an upgrade of the Discord API, Python **3.8** or greater is required. 14 | Tests are only conducted on Python version 3.11. 15 | 16 | While StarryPy3k **may** work with earlier or later versions of Python, it is not 17 | recommended and will not be readily supported. 18 | 19 | ## Installation 20 | If you are installing during the development phase, please clone the repository 21 | with git. While it is not strictly necessary, it is highly encouraged to 22 | run your StarryPy3k instance from a virtual environment, as future plugins 23 | may require more Python modules than are currently listed (eg - IRC3), and 24 | using a virtual environment helps to keep a clean namespace and reduce the 25 | chance of bugs. 26 | 27 | ### Starbound Server Configuration 28 | StarryPy works as a benevolent "man in the middle" between the Starbound game 29 | client and the Starbound dedicated server, in effect acting as a proxy server. 30 | As such, for easiest transition from a "Vanilla" server to one enhanced by 31 | StarryPy, you need to set your Starbound server to accept incoming connections 32 | on a new TCP port. By default, this will be the port one lower than standard, 33 | to wit: 21024. To accomplish this, edit your `starbound_server.config`. Look 34 | for the lines below, and change them to specify port 21024: 35 | 36 | ``` 37 | "gameServerPort" : 21025, 38 | [...] 39 | "queryServerPort" : 21025, 40 | ``` 41 | 42 | While editing your `starbound_server.config`, you should also add some accounts 43 | for your server's staff, if you have not done so already. StarryPy3k's 44 | basic_auth plugin uses Starbound's account system to authenticate privileged 45 | users (moderator and up), so you will need at least one account before your 46 | staff can join the server. Look for the lines below: 47 | 48 | ``` 49 | "serverUsers" : { 50 | }, 51 | ``` 52 | 53 | And add some accounts (preferably one per staff member) using the format below. 54 | Note: you do **not** need to set "admin" to "false". Set it to "true" if you 55 | would like this account to have administrator privileges on the underlying 56 | Starbound dedicated server. 57 | 58 | ``` 59 | "serverUsers" : { 60 | "repalceWithAccountName" : { 61 | "admin" : false, 62 | "password" : "replaceWithAccountPassword" 63 | }, 64 | "repalceWithAnotherAccountName" : { 65 | "admin" : false, 66 | "password" : "replaceWithAnotherAccountPassword" 67 | } 68 | }, 69 | ``` 70 | 71 | ### StarryPy Proxy Configuration 72 | An example configuration file, `config.json.default`, is provided in the 73 | `config` directory. Copy that file to a new one named `config.json` in the 74 | same location. Open it in your text editor of choice. The following are the 75 | most likely changes you will have to make: 76 | 77 | ``` 78 | "basic_auth": { 79 | "enabled": true, 80 | "owner_sb_account": "-- REPLACE WITH OWNER ACCOUNT --", 81 | "staff_sb_accounts": [ 82 | "-- REPLACE WITH STARBOUND ACCOUNT NAME --", 83 | "-- REPLACE WITH ANOTHER --", 84 | "-- SO ON AND SO FORTH --" 85 | ] 86 | }, 87 | ``` 88 | 89 | The section above is used by StarryPy3k's basic_auth plugin to define 90 | Starbound accounts that staff members can use to authenticate. Edit the example 91 | above to use **only** the account **names** (no passwords) that you just set up 92 | in your `starbound_server.config` file. 93 | 94 | ``` 95 | "irc_bot": { 96 | "channel": "#YourChannel", 97 | "log_irc": false, 98 | "server": "irc.example.com", 99 | "strip_colors": true, 100 | "username": "Replace With Valid IRC Nick" 101 | }, 102 | ``` 103 | 104 | This section controls the built-in IRC-to-Starbound bridge. It will be active 105 | if you have the `irc3` Python module installed on your system. Edit the sample 106 | values here to match your preferred IRC server, bot nick, et cetera. Chat in 107 | the Starbound server will be relayed to the specified IRC channel, and vice 108 | versa. You can also see who is on the server from IRC by saying `.who` in the 109 | IRC channel (we cannot use `/` as the command leader in IRC for obvious reasons. 110 | 111 | ``` 112 | "motd": { 113 | "message": "Insert your MOTD message here. ^red;Note^reset; color codes work." 114 | }, 115 | "new_player_greeters": { 116 | "gifts": {}, 117 | "greeting": "This message will be displayed to players the first time StarryPy sees them connect." 118 | }, 119 | ``` 120 | 121 | The MOTD, or Message Of The Day, will be displayed to all players when they 122 | connect to the Starbound server. You can update this in-game by using the 123 | `/set_motd` command. The next section allows you to specify a message to be 124 | displayed to any players the first time they connect to the server. You can 125 | also have StarryPy give items to new players by enumarating them in the `gifts` 126 | property. Use Starbound's names for items as specified in its `.json` files. 127 | 128 | ``` 129 | "player_manager": { 130 | "owner_uuid": "!--REPLACE WITH YOUR UUID--!", 131 | "player_db": "config/player" 132 | }, 133 | ``` 134 | 135 | Replace the obvious value here with your UUID. This is how StarryPy will 136 | recognize you as the owner of the server and accord you the relevant rights 137 | and privileges. You can find your UUID by watching the Starbound server log 138 | as you connect, by using the `list` RCON command, or by observing the names 139 | of your save files on the computer you use to play Starbound. 140 | 141 | Once you have finished editing `config.json`, copy the `permissions.json.default` 142 | file to `permissions.json` and edit it to your liking. Example of 143 | permissions format is provided below: 144 | ``` 145 | "Role Name": { 146 | "priority": 100000, // This determines the role heirarchy for administrative commands such as /kick, /ban, and /user 147 | "prefix": "^#F7434C;", // This role's chat prefix, typically a color 148 | "inherits": [ // Roles to inherit permissions from 149 | "Another Role" 150 | ], 151 | "permissions": [ // An array of permissions; see permissions.json.default for all the permissions included 152 | "special.allperms" 153 | ] 154 | } 155 | ``` 156 | 157 | ### Starting the proxy 158 | Starting StarryPy is as simple as issueing the command `python3 ./server.py` 159 | once you have finised editing `config/config.json` and `config/permissions 160 | .json`. To terminate the proxy, either press `^C` in an interactive 161 | terminal session, or send it an `INT` signal. 162 | 163 | ### Running under Docker 164 | StarryPy now includes a basic Docker configuration. To run this image, 165 | all you need to do is run: 166 | 167 | ```bash 168 | docker run -p 21025:21025 starrypy/starrypy:1.0.0 169 | ``` 170 | 171 | StarryPy exports a volume at /app/config which stores your configuration file and 172 | the various databases. You can create your own data container for this volume to persist 173 | your configuration and data even if you rebuild or upgrade StarryPy, or use volume 174 | mount points to share a directory from your host server into the container. 175 | 176 | To use a storage volume, create a volume with: 177 | 178 | ```bash 179 | docker volume create --name sb-data 180 | ``` 181 | 182 | Then provide it as part of your startup command: 183 | 184 | ```bash 185 | docker run -d --name starry -p 20125:21025 -v sb-data:/app/config starrypy/starrypy:1.0.0 186 | ``` 187 | 188 | You can edit the config by mounting the volume into another container with your favorite text editor: 189 | ```bash 190 | docker run --rm -v sb-data:/app/config -ti thinca/vim /app/config/config.json 191 | ``` 192 | 193 | If you'd rather map a directory on your host, just provide that as an argument to -v instead: 194 | 195 | ```bash 196 | docker run -d --name starry -p 20125:21025 -v /opt/wherever/you/want/it:/app/config starrypy/starrypy:1.0.0 197 | ``` 198 | 199 | You can also run as a low-privileges user, with starry only having access to write to its config volume: 200 | ```bash 201 | docker run -d --name starry -p 20125:21025 -v /opt/wherever/you/want/it:/app/config --user 1002 starrypy/starrypy:1.0.0 202 | ``` 203 | 204 | 205 | Please note that this is a Linux-based docker container, so it won't work properly on Docker 206 | for Windows in Windows Container mode. 207 | 208 | ## Contributing 209 | Contributions are highly encouraged and always welcome. Please feel free to 210 | open an issue on GitHub if you are having an error, or wish to make a 211 | suggestion. If you're feeling really motivated, fork the repo and contribute 212 | some code. 213 | 214 | In addition, plugin development is encouraged. There are still a few features 215 | missing from the core (particularly in the role system). A more comprehensive 216 | guide on plugin development is in the works. Please note that plugins will not 217 | work without modification from the original version of StarryPy. 218 | 219 | If you would like to talk with other contributors/ask questions about 220 | development, please join #StarryPy on irc.freenode.net, or chat with us on 221 | [gitter](https://gitter.im/StarryPy). 222 | 223 | ## History 224 | StarryPy3k was originally developed by [CarrotsAreMediocre](https://github.com/CarrotsAreMediocre), who set all the groundwork for AsyncIO and packet 225 | interpreting. Due to personal circumstances, Carrots stepped away from the 226 | project. 227 | 228 | After roughly 2 years of laying dormant, Kharidiron, having spent some time 229 | learning the ropes of StarryPy, decided to take the reigns on StarryPy3k. 230 | After many months of staring at the code (and many emails to 231 | CarrotsAreMediocre requesting assistance in understanding just what it is 232 | doing), is feeling a modicum more confident in handling this project and 233 | keeping it running. -------------------------------------------------------------------------------- /REQUIREMENTS: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarryPy/StarryPy3k/08b1ef5a1dec95ce9b9002f3319a8619b2f6b9ae/REQUIREMENTS -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | v2.0.1 -------------------------------------------------------------------------------- /base_plugin.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import collections 3 | from collections import abc 4 | 5 | from utilities import DotDict, recursive_dictionary_update, background 6 | 7 | 8 | class BaseMeta(type): 9 | def __new__(mcs, name, bases, clsdict): 10 | for key, value in clsdict.items(): 11 | if callable(value) and (value.__name__.startswith("on_") or 12 | hasattr(value, "_command")): 13 | clsdict[key] = value 14 | c = type.__new__(mcs, name, bases, clsdict) 15 | return c 16 | 17 | 18 | class BasePlugin(metaclass=BaseMeta): 19 | """ 20 | Defines an interface for all plugins to inherit from. Note that the init 21 | method should generally not be overrode; all setup work should be done in 22 | activate() if possible. If you do override __init__, remember to super()! 23 | 24 | Note that only one instance of each plugin will be instantiated for *all* 25 | connected clients. self.connection will be changed by the plugin 26 | manager to the current connection. 27 | 28 | You may access the factory if necessary via self.factory.connections 29 | to access other clients, but this "Is Not A Very Good Idea" (tm) 30 | 31 | `name` *must* be defined in child classes or else the plugin manager will 32 | complain quite thoroughly. 33 | """ 34 | 35 | name = "Base Plugin" 36 | description = "The common class for all plugins to inherit from." 37 | version = ".1" 38 | depends = () 39 | default_config = None 40 | plugins = DotDict({}) 41 | auto_activate = True 42 | background_tasks = set() 43 | 44 | def __init__(self): 45 | self.loop = asyncio.get_event_loop() 46 | self.plugin_config = self.config.get_plugin_config(self.name) 47 | if isinstance(self.default_config, abc.Mapping): 48 | temp = recursive_dictionary_update(self.default_config, 49 | self.plugin_config) 50 | self.plugin_config.update(temp) 51 | 52 | else: 53 | self.plugin_config = self.default_config 54 | 55 | async def activate(self): 56 | pass 57 | 58 | # not async so server can shut down outside of async loop 59 | async def deactivate(self): 60 | pass 61 | 62 | # helper to ensure background tasks get properly referenced until awaited 63 | def background(self, coro): 64 | return background(coro) 65 | 66 | async def on_protocol_request(self, data, connection): 67 | """Packet type: 0 """ 68 | return True 69 | 70 | async def on_protocol_response(self, data, connection): 71 | """Packet type: 1 """ 72 | return True 73 | 74 | async def on_server_disconnect(self, data, connection): 75 | """Packet type: 2 """ 76 | return True 77 | 78 | async def on_connect_success(self, data, connection): 79 | """Packet type: 3 """ 80 | return True 81 | 82 | async def on_connect_failure(self, data, connection): 83 | """Packet type: 4 """ 84 | return True 85 | 86 | async def on_handshake_challenge(self, data, connection): 87 | """Packet type: 5 """ 88 | return True 89 | 90 | async def on_chat_received(self, data, connection): 91 | """Packet type: 6 """ 92 | return True 93 | 94 | async def on_universe_time_update(self, data, connection): 95 | """Packet type: 7 """ 96 | return True 97 | 98 | async def on_celestial_response(self, data, connection): 99 | """Packet type: 8 """ 100 | return True 101 | 102 | async def on_player_warp_result(self, data, connection): 103 | """Packet type: 9 """ 104 | return True 105 | 106 | async def on_planet_type_update(self, data, connection): 107 | """Packet type: 10 """ 108 | return True 109 | 110 | async def on_pause(self, data, connection): 111 | """Packet type: 11 """ 112 | return True 113 | 114 | async def on_client_connect(self, data, connection): 115 | """Packet type: 12 """ 116 | return True 117 | 118 | async def on_client_disconnect_request(self, data, connection): 119 | """Packet type: 13 """ 120 | return True 121 | 122 | async def on_handshake_response(self, data, connection): 123 | """Packet type: 14 """ 124 | return True 125 | 126 | async def on_player_warp(self, data, connection): 127 | """Packet type: 15 """ 128 | return True 129 | 130 | async def on_fly_ship(self, data, connection): 131 | """Packet type: 16 """ 132 | return True 133 | 134 | async def on_chat_sent(self, data, connection): 135 | """Packet type: 17 """ 136 | return True 137 | 138 | async def on_celestial_request(self, data, connection): 139 | """Packet type: 18 """ 140 | return True 141 | 142 | async def on_client_context_update(self, data, connection): 143 | """Packet type: 19 """ 144 | return True 145 | 146 | async def on_world_start(self, data, connection): 147 | """Packet type: 20 """ 148 | return True 149 | 150 | async def on_world_stop(self, data, connection): 151 | """Packet type: 21 """ 152 | return True 153 | 154 | async def on_world_layout_update(self, data, connection): 155 | """Packet type: 22 """ 156 | return True 157 | 158 | async def on_world_parameters_update(self, data, connection): 159 | """Packet type: 23 """ 160 | return True 161 | 162 | async def on_central_structure_update(self, data, connection): 163 | """Packet type: 24 """ 164 | return True 165 | 166 | async def on_tile_array_update(self, data, connection): 167 | """Packet type: 25 """ 168 | return True 169 | 170 | async def on_tile_update(self, data, connection): 171 | """Packet type: 26 """ 172 | return True 173 | 174 | async def on_tile_liquid_update(self, data, connection): 175 | """Packet type: 27 """ 176 | return True 177 | 178 | async def on_tile_damage_update(self, data, connection): 179 | """Packet type: 28 """ 180 | return True 181 | 182 | async def on_tile_modification_failure(self, data, connection): 183 | """Packet type: 29 """ 184 | return True 185 | 186 | async def on_give_item(self, data, connection): 187 | """Packet type: 30 """ 188 | return True 189 | 190 | async def on_environment_update(self, data, connection): 191 | """Packet type: 31 """ 192 | return True 193 | 194 | async def on_update_tile_protection(self, data, connection): 195 | """Packet type: 32 """ 196 | return True 197 | 198 | async def on_set_dungeon_gravity(self, data, connection): 199 | """Packet type: 33 """ 200 | return True 201 | 202 | async def on_set_dungeon_breathable(self, data, connection): 203 | """Packet type: 34 """ 204 | 205 | async def on_set_player_start(self, data, connection): 206 | """Packet type: 35 """ 207 | return True 208 | 209 | async def on_find_unique_entity_response(self, data, connection): 210 | """Packet type: 36""" 211 | return True 212 | 213 | async def on_modify_tile_list(self, data, connection): 214 | """Packet type: 37 """ 215 | return True 216 | 217 | async def on_damage_tile_group(self, data, connection): 218 | """Packet type: 38 """ 219 | return True 220 | 221 | async def on_collect_liquid(self, data, connection): 222 | """Packet type: 39 """ 223 | return True 224 | 225 | async def on_request_drop(self, data, connection): 226 | """Packet type: 40 """ 227 | return True 228 | 229 | async def on_spawn_entity(self, data, connection): 230 | """Packet type: 41 """ 231 | return True 232 | 233 | async def on_connect_wire(self, data, connection): 234 | """Packet type: 42 """ 235 | return True 236 | 237 | async def on_disconnect_all_wires(self, data, connection): 238 | """Packet type: 43 """ 239 | return True 240 | 241 | async def on_world_client_state_update(self, data, connection): 242 | """Packet type: 44 """ 243 | return True 244 | 245 | async def on_find_unique_entity(self, data, connection): 246 | """Packet type: 45 """ 247 | return True 248 | 249 | async def on_unk(self, data, connection): 250 | """Packet type: 46 """ 251 | return True 252 | 253 | async def on_entity_create(self, data, connection): 254 | """Packet type: 47 """ 255 | return True 256 | 257 | async def on_entity_update(self, data, connection): 258 | """Packet type: 48 """ 259 | return True 260 | 261 | async def on_entity_destroy(self, data, connection): 262 | """Packet type: 49 """ 263 | return True 264 | 265 | async def on_entity_interact(self, data, connection): 266 | """Packet type: 50 """ 267 | return True 268 | 269 | async def on_entity_interact_result(self, data, connection): 270 | """Packet type: 51 """ 271 | return True 272 | 273 | async def on_hit_request(self, data, connection): 274 | """Packet type: 52 """ 275 | return True 276 | 277 | async def on_damage_request(self, data, connection): 278 | """Packet type: 53 """ 279 | return True 280 | 281 | async def on_damage_notification(self, data, connection): 282 | """Packet type: 54 """ 283 | return True 284 | 285 | async def on_entity_message(self, data, connection): 286 | """Packet type: 55 """ 287 | return True 288 | 289 | async def on_entity_message_response(self, data, connection): 290 | """Packet type: 56 """ 291 | return True 292 | 293 | async def on_update_world_properties(self, data, connection): 294 | """Packet type: 57 """ 295 | return True 296 | 297 | async def on_step_update(self, data, connection): 298 | """Packet type: 58 """ 299 | return True 300 | 301 | async def on_system_world_start(self, data, connection): 302 | """Packet type: 59 """ 303 | return True 304 | 305 | async def on_system_world_update(self, data, connection): 306 | """Packet type: 60 """ 307 | return True 308 | 309 | 310 | async def on_system_ship_create(self, data, connection): 311 | """Packet type: 63 """ 312 | return True 313 | 314 | async def on_system_ship_destroy(self, data, connection): 315 | """Packet type: 64 """ 316 | return True 317 | 318 | async def on_system_object_spawn(self, data, connection): 319 | """Packet type: 65 """ 320 | return True 321 | 322 | def __repr__(self): 323 | return "" % (self.name, self.version) 324 | 325 | 326 | class CommandNameError(Exception): 327 | """ 328 | Raised when a command name can't be found from the `commands` list in a 329 | `SimpleCommandPlugin` instance. 330 | """ 331 | 332 | 333 | class SimpleCommandPlugin(BasePlugin): 334 | name = "simple_command_plugin" 335 | description = "Provides a simple parent class to define chat commands." 336 | version = "0.1" 337 | depends = ["command_dispatcher"] 338 | auto_activate = True 339 | 340 | async def activate(self): 341 | await super().activate() 342 | for name, attr in [(x, getattr(self, x)) for x in self.__dir__()]: 343 | if hasattr(attr, "_command"): 344 | for alias in attr._aliases: 345 | self.plugins['command_dispatcher'].register(attr, alias) 346 | 347 | 348 | class StoragePlugin(BasePlugin): 349 | name = "storage_plugin" 350 | depends = ['player_manager'] 351 | 352 | async def activate(self): 353 | await super().activate() 354 | self.storage = self.plugins.player_manager.get_storage(self.name) 355 | 356 | 357 | class StorageCommandPlugin(SimpleCommandPlugin): 358 | name = "storage_command_plugin" 359 | depends = ['command_dispatcher', 'player_manager'] 360 | 361 | async def activate(self): 362 | await super().activate() 363 | self.storage = self.plugins.player_manager.get_storage(self) 364 | -------------------------------------------------------------------------------- /build_parser.py: -------------------------------------------------------------------------------- 1 | from distutils.core import setup 2 | from Cython.Build import cythonize 3 | 4 | setup( 5 | name = "StarryPy Packet Parser", 6 | ext_modules = cythonize('c_parser.pyx') 7 | ) -------------------------------------------------------------------------------- /c_parser.pyx: -------------------------------------------------------------------------------- 1 | import struct 2 | from libc.string cimport memcpy 3 | 4 | def parse_variant(object stream): 5 | return c_parse_variant(stream) 6 | 7 | def parse_vlq(object stream): 8 | return c_parse_vlq(stream) 9 | 10 | def parse_svlq(object stream): 11 | return c_parse_svlq(stream) 12 | 13 | def parse_dict_variant(object stream): 14 | return c_parse_dict_variant(stream) 15 | 16 | def parse_variant_variant(object stream): 17 | return c_parse_variant_variant(stream) 18 | 19 | def parse_starbytearray(object stream): 20 | return c_parse_starbytearray(stream) 21 | 22 | def parse_starstring(object stream): 23 | return c_parse_starstring(stream) 24 | 25 | cdef c_parse_variant(object stream): 26 | cdef char x = ord(stream.read(1)) 27 | 28 | if x == 1: 29 | return None 30 | elif x == 2: 31 | y = struct.unpack(">d", stream.read(8))[0] 32 | return y 33 | elif x == 3: 34 | c = stream.read(1) 35 | if c == 1: 36 | return True 37 | else: 38 | return False 39 | elif x == 4: 40 | return c_parse_svlq(stream) 41 | elif x == 5: 42 | return c_parse_starstring(stream) 43 | elif x == 6: 44 | return c_parse_variant_variant(stream) 45 | elif x == 7: 46 | return c_parse_dict_variant(stream) 47 | 48 | cdef int c_parse_vlq(object stream): 49 | cdef long long value = 0 50 | cdef char tmp 51 | while True: 52 | try: 53 | tmp = ord(stream.read(1)) 54 | value = (value << 7) | (tmp & 0x7f) 55 | if tmp & 0x80 == 0: 56 | break 57 | except TypeError: 58 | break 59 | return value 60 | 61 | cdef int c_parse_svlq(object stream): 62 | cdef long long v = c_parse_vlq(stream) 63 | if (v & 1) == 0x00: 64 | return v >> 1 65 | else: 66 | return -((v >> 1) + 1) 67 | 68 | 69 | cdef c_parse_dict_variant(object stream): 70 | cdef int i = c_parse_vlq(stream) 71 | c = {} 72 | for _ in range(i): 73 | key = c_parse_starstring(stream) 74 | value = c_parse_variant(stream) 75 | c[key] = value 76 | return c 77 | 78 | cdef c_parse_variant_variant(object stream): 79 | cdef int i = c_parse_vlq(stream) 80 | return [c_parse_variant(stream) for _ in range(i)] 81 | 82 | cdef c_parse_starbytearray(object stream): 83 | cdef int i = c_parse_vlq(stream) 84 | s = stream.read(i) 85 | return s 86 | 87 | cdef c_parse_starstring(object stream): 88 | s = c_parse_starbytearray(stream) 89 | try: 90 | return str(s, encoding="utf-8") 91 | except UnicodeDecodeError: 92 | return s 93 | -------------------------------------------------------------------------------- /config/config.json.default: -------------------------------------------------------------------------------- 1 | { 2 | "listen_port": 21025, 3 | "min_cache_size": 16, 4 | "packet_reap_time": 600, 5 | "plugin_path": "./plugins", 6 | "plugins": { 7 | "basic_auth": { 8 | "enabled": true, 9 | "owner_sb_account": "-- REPLACE WITH OWNER ACCOUNT --", 10 | "staff_sb_accounts": [ 11 | "-- REPLACE WITH STARBOUND ACCOUNT NAME --", 12 | "-- REPLACE WITH ANOTHER --", 13 | "-- SO ON AND SO FORTH --" 14 | ] 15 | }, 16 | "chat_enhancements": { 17 | "chat_timestamps": true, 18 | "timestamp_color": "^gray;" 19 | }, 20 | "chat_logger": {}, 21 | "chat_manager": {}, 22 | "claims": { 23 | "auto_claim_ship": true, 24 | "max_claims_per_person": 6 25 | }, 26 | "command_dispatcher": { 27 | "command_prefix": "/" 28 | }, 29 | "discord_bot": { 30 | "enabled": false, 31 | "channel": "-- channel id --", 32 | "staff_channel": "-- channel id --", 33 | "client_id": "-- client_id --", 34 | "command_prefix": "!", 35 | "log_discord": false, 36 | "rank_roles": { 37 | "A Discord Rank": "A StarryPy Rank" 38 | }, 39 | "strip_colors": true, 40 | "token": "-- token --" 41 | }, 42 | "general_commands": { 43 | "maintenance_message": "This server is currently in maintenance mode and is not accepting new connections." 44 | }, 45 | "help_plugin": {}, 46 | "irc_bot": { 47 | "enabled": false, 48 | "announce_join_leave": true, 49 | "channel": "#YourChannel", 50 | "command_prefix": "!", 51 | "log_irc": false, 52 | "server": "irc.example.com", 53 | "strip_colors": true, 54 | "username": "Replace With Valid IRC Nick" 55 | }, 56 | "mail": { 57 | "max_mail_storage": 25 58 | }, 59 | "motd": { 60 | "message": "Insert your MOTD message here. ^red;Note^reset; color codes work." 61 | }, 62 | "new_player_greeters": { 63 | "gifts": {}, 64 | "greeting": "This message will be displayed to players the first time StarryPy sees them connect." 65 | }, 66 | "planet_backups": {}, 67 | "planet_protect": {}, 68 | "player_manager": { 69 | "new_user_ranks": [ 70 | "Guest" 71 | ], 72 | "owner_ranks": [ 73 | "Owner" 74 | ], 75 | "owner_uuid": "!--REPLACE WITH YOUR UUID--!", 76 | "player_db": "config/player" 77 | }, 78 | "poi": {}, 79 | "privileged_chatter": { 80 | "broadcast_prefix": "^red;(ADMIN): ", 81 | "modchat_color": "^violet;", 82 | "report_prefix": "^magenta;(REPORT): " 83 | }, 84 | "simple_command_plugin": {}, 85 | "spawn": {}, 86 | "species_whitelist": { 87 | "enabled": false, 88 | "allowed_species": [ 89 | "apex", 90 | "avian", 91 | "glitch", 92 | "floran", 93 | "human", 94 | "hylotl", 95 | "penguin", 96 | "novakid" 97 | ] 98 | }, 99 | "storage_command_plugin": {}, 100 | "warp_plugin": {} 101 | }, 102 | "upstream_host": "localhost", 103 | "upstream_port": 21024 104 | } 105 | -------------------------------------------------------------------------------- /config/permissions.json.default: -------------------------------------------------------------------------------- 1 | { 2 | "Owner": { 3 | "priority": 100000, 4 | "prefix": "^#F7434C;", 5 | "inherits": [ 6 | "SuperAdmin" 7 | ], 8 | "permissions": [ 9 | "special.allperms" 10 | ] 11 | }, 12 | "SuperAdmin": { 13 | "priority": 10000, 14 | "prefix": "^#F444A4;", 15 | "inherits": [ 16 | "Admin" 17 | ], 18 | "permissions": [ 19 | "player_manager.delete_player", 20 | "general_commands.shutdown", 21 | "general_commands.maintenance_mode", 22 | "general_commands.maintenance_bypass", 23 | "motd.set_motd", 24 | "spawn.set_spawn" 25 | ] 26 | }, 27 | "Admin": { 28 | "priority": 1000, 29 | "prefix": "^#C443F7;", 30 | "inherits": [ 31 | "Moderator" 32 | ], 33 | "permissions": [ 34 | "player_manager.list_players", 35 | "planet_protect.bypass", 36 | "general_commands.nick_others", 37 | "general_commands.who_clientids", 38 | "general_commands.whois", 39 | "general_commands.give_item", 40 | "planet_announcer.set_greeting", 41 | "planet_protect.protect", 42 | "planet_protect.manage_protection", 43 | "poi.set_poi", 44 | "privileged_chatter.broadcast", 45 | "warp.tp_player", 46 | "warp.tp_ship", 47 | "emsg_blocker.bypass", 48 | "chat_enhancements.socialspy" 49 | ] 50 | }, 51 | "Moderator": { 52 | "priority": 100, 53 | "prefix": "^#4385F7;", 54 | "inherits": [ 55 | "Registered" 56 | ], 57 | "permissions": [ 58 | "player_manager.kick", 59 | "player_manager.ban", 60 | "player_manager.user", 61 | "player_manager.save", 62 | "privileged_chatter.modchat" 63 | ] 64 | }, 65 | "Registered": { 66 | "priority": 10, 67 | "prefix": "^#A0F743;", 68 | "inherits": [ 69 | "Guest" 70 | ], 71 | "permissions": [ 72 | "claims.claim", 73 | "general_commands.nick" 74 | ] 75 | }, 76 | "Guest": { 77 | "priority": 1, 78 | "prefix": "^yellow;", 79 | "inherits": [], 80 | "permissions": [ 81 | "claims.manage_claims", 82 | "claims.planet_access", 83 | "chat_enhancements.whisper", 84 | "chat_enhancements.ignore", 85 | "emotes.emote", 86 | "general_commands.who", 87 | "general_commands.here", 88 | "general_commands.whoami", 89 | "general_commands.uptime", 90 | "help.help", 91 | "motd.motd", 92 | "poi.poi", 93 | "privileged_chatter.report", 94 | "spawn.spawn", 95 | "spawn.show_spawn", 96 | "mail.sendmail", 97 | "mail.readmail" 98 | ] 99 | } 100 | } -------------------------------------------------------------------------------- /configuration_manager.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import sys 4 | from pathlib import Path 5 | 6 | from utilities import recursive_dictionary_update, DotDict 7 | 8 | logger = logging.getLogger("starrypy.configuration_manager") 9 | 10 | 11 | class ConfigurationManager: 12 | def __init__(self): 13 | self._raw_config = None 14 | self._raw_default_config = None 15 | self._config = {} 16 | self._dot_dict = None 17 | self._path = None 18 | 19 | def __repr__(self): 20 | return "".format(json.dumps(self.config)) 21 | 22 | @property 23 | def config(self): 24 | if self._dot_dict is None: 25 | self._dot_dict = DotDict(self._config) 26 | return self._dot_dict 27 | 28 | def load_config(self, path, default=False): 29 | if not isinstance(path, Path): 30 | path = Path(path) 31 | if default: 32 | self.load_defaults(path) 33 | try: 34 | with path.open() as f: 35 | self._raw_config = f.read() 36 | except FileNotFoundError: 37 | path.touch() 38 | with path.open("w") as f: 39 | f.write("{}") 40 | self._raw_config = "{}" 41 | self._path = path 42 | try: 43 | recursive_dictionary_update(self._config, 44 | json.loads(self._raw_config)) 45 | except ValueError as e: 46 | logger.error("Error while loading config.json file:\n\t" 47 | "{}".format(e)) 48 | sys.exit(1) 49 | if "plugins" not in self._config: 50 | self._config['plugins'] = DotDict({}) 51 | 52 | def load_defaults(self, path): 53 | path = Path(str(path) + ".default") 54 | with path.open() as f: 55 | self._raw_default_config = f.read() 56 | recursive_dictionary_update(self._config, 57 | json.loads(self._raw_default_config)) 58 | 59 | def save_config(self, path=None): 60 | if path is None: 61 | path = self._path 62 | temp_path = Path(str(path) + "_") 63 | with temp_path.open("w") as f: 64 | json.dump(self.config, f, sort_keys=True, indent=4, 65 | separators=(',', ': '), ensure_ascii=False) 66 | path.unlink() 67 | temp_path.rename(path) 68 | logger.debug("Config file saved.") 69 | 70 | def get_plugin_config(self, plugin_name): 71 | if plugin_name not in self.config.plugins: 72 | storage = DotDict({}) 73 | self.config.plugins[plugin_name] = storage 74 | else: 75 | storage = self.config.plugins[plugin_name] 76 | return storage 77 | 78 | def update_config(self, plugin_name, new_value): 79 | if plugin_name not in self.config.plugins: 80 | raise ValueError("Plugin name provided is not valid.") 81 | if isinstance(new_value, dict): 82 | self.config.plugins[plugin_name] = new_value 83 | self.save_config() 84 | -------------------------------------------------------------------------------- /docker-start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # If we run in docker, and the user chose a file share volume, these files won't exist. Starry will complain. 4 | # Rather than change the way starry does its default files, let's just ensure they are present with this shell script. 5 | if [ ! -f config/config.json.default ]; then 6 | cp defaults/config.json.default config 7 | fi 8 | 9 | if [ ! -f config/permissions.json.default ]; then 10 | cp defaults/permissions.json.default config 11 | fi 12 | 13 | exec python3 ./server.py -------------------------------------------------------------------------------- /obsolete_plugins/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarryPy/StarryPy3k/08b1ef5a1dec95ce9b9002f3319a8619b2f6b9ae/obsolete_plugins/.gitkeep -------------------------------------------------------------------------------- /obsolete_plugins/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarryPy/StarryPy3k/08b1ef5a1dec95ce9b9002f3319a8619b2f6b9ae/obsolete_plugins/__init__.py -------------------------------------------------------------------------------- /obsolete_plugins/obsolete.md: -------------------------------------------------------------------------------- 1 | These are plugins that I've marked as obsolete, as the starbound 2 | clients/servers already implement much of the same features, making these 3 | redundant. 4 | 5 | I might come back and fix the warp plugin at some point, since there are 6 | still some warps that we can do that starbound doesn't allow... but this is 7 | less of a priority for me at the moment. 8 | 9 | - Kharidron 10 | July 14, 2016 11 | 12 | 13 | 14 | I'm adding watchdog and web_plugin in here as well, since they both need 15 | code attention, but are not a high priority for me at the moment. Also, as a 16 | way of alerting users down the line that they aren't ready for use yet. 17 | 18 | - Kharidiron 19 | July 21, 2016 20 | -------------------------------------------------------------------------------- /obsolete_plugins/static/css/template.css: -------------------------------------------------------------------------------- 1 | body { 2 | padding-top: 50px; 3 | } 4 | .starter-template { 5 | padding: 40px 15px; 6 | text-align: center; 7 | } 8 | -------------------------------------------------------------------------------- /obsolete_plugins/static/who.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | {{title}} 10 | 11 | 13 | 14 | 16 | 20 | 21 | 22 | 43 |
44 |
45 |

{{ len(players) }} players currently online.

46 | 47 |
48 |
49 | {% for x, player in enumerate(players) %} 50 | 51 | 52 | 60 |
61 |
62 | {% if len(player.roles) %} 63 | Roles: 64 | {% for role in player.roles %} 65 | {{role}} 66 | {% end %} 67 |
68 | {% end %} 69 | 70 | Logged in: {{player.last_seen}} 71 |
72 | Current location: {{player.location}} 73 |
74 |
75 | 76 | {% end %} 77 |
78 |
79 | 80 | 81 | 82 | -------------------------------------------------------------------------------- /obsolete_plugins/watchdog.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import subprocess 3 | import sys 4 | import pathlib 5 | 6 | from base_plugin import SimpleCommandPlugin 7 | 8 | 9 | class StarboundWatchdog(SimpleCommandPlugin): 10 | name = "starbound_watchdog" 11 | """ Provides a watchdog to automatically start a server, restart it should 12 | it die, and (in the future) detect hangs.""" 13 | 14 | async def activate(self): 15 | self.is_64bits = sys.maxsize > 2 ** 32 # Check if it's 64 bits 16 | self.platform = sys.platform.lower() # Linux, Windows, Darwin. 17 | self.starbound_path = pathlib.Path(self.config.config.starbound_folder) 18 | self.executable = self.find_executable() 19 | self.watchdog = asyncio.Task(self.start_watchdog()) 20 | 21 | def find_executable(self): 22 | if self.platform == "win32": 23 | p = self.starbound_path / "win32/starbound_server.exe" 24 | self.logger.info("Detected windows. Trying path %s", str(p)) 25 | elif self.platform == "linux": 26 | if self.is_64bits: 27 | p = self.starbound_path / "linux64/starbound_server" 28 | else: 29 | p = self.starbound_path / "linux32/starbound_server" 30 | else: 31 | raise ValueError("Unknown server operating system.") 32 | if not p.exists(): 33 | raise FileNotFoundError("Couldn't find starbound executable.") 34 | return str(p) 35 | 36 | async def start_watchdog(self): 37 | subproc = subprocess.Popen(self.executable, shell=True) 38 | self.logger.info("Started Starbound server with PID: %d", subproc.pid) 39 | while True: 40 | if subproc.poll(): 41 | self.logger.warning("Starbound has exited. " 42 | "Restarting in 5 seconds...") 43 | for connection in self.factory.connections: 44 | connection.die() 45 | await asyncio.sleep(5) 46 | self.watchdog = asyncio.Task( 47 | self.start_watchdog()) 48 | break 49 | await asyncio.sleep(1) 50 | try: 51 | subproc.terminate() 52 | except ProcessLookupError: 53 | self.logger.debug("Process already dead.") 54 | -------------------------------------------------------------------------------- /obsolete_plugins/web_plugin.py: -------------------------------------------------------------------------------- 1 | import tornado.ioloop 2 | import tornado.web 3 | from tornado.platform.asyncio import AsyncIOMainLoop 4 | 5 | from base_plugin import BasePlugin 6 | from utilities import path 7 | 8 | 9 | class WebHandler(tornado.web.RequestHandler): 10 | def get(self, *args, **kwargs): 11 | players = [player for player in 12 | self.player_manager.players.values()] 13 | self.render("static/who.html", title="Who's online", 14 | players=players) 15 | 16 | 17 | class WebManager(BasePlugin): 18 | name = "web_manager" 19 | depends = ['player_manager'] 20 | 21 | async def activate(self): 22 | WebHandler.web_manager = self 23 | WebHandler.factory = self.factory 24 | WebHandler.player_manager = self.plugins.player_manager 25 | 26 | AsyncIOMainLoop().install() 27 | application.listen(8888) 28 | 29 | 30 | application = tornado.web.Application([ 31 | (r"/", WebHandler), 32 | (r'/css/(.*)', tornado.web.StaticFileHandler, {'path': str(path 33 | / "plugins" 34 | / "static" 35 | / "css")})]) 36 | -------------------------------------------------------------------------------- /packets.py: -------------------------------------------------------------------------------- 1 | from utilities import BiDict 2 | 3 | packets = BiDict({ 4 | 'protocol_request': 0, 5 | 'protocol_response': 1, 6 | 'server_disconnect': 2, 7 | 'connect_success': 3, 8 | 'connect_failure': 4, 9 | 'handshake_challenge': 5, 10 | 'chat_received': 6, 11 | 'universe_time_update': 7, 12 | 'celestial_response': 8, 13 | 'player_warp_result': 9, 14 | 'planet_type_update': 10, 15 | 'pause': 11, 16 | 'server_info': 12, 17 | 'client_connect': 13, 18 | 'client_disconnect_request': 14, 19 | 'handshake_response': 15, 20 | 'player_warp': 16, 21 | 'fly_ship': 17, 22 | 'chat_sent': 18, 23 | 'celestial_request': 19, 24 | 'client_context_update': 20, 25 | 'world_start': 21, 26 | 'world_stop': 22, 27 | 'world_layout_update': 23, 28 | 'world_parameters_update': 24, 29 | 'central_structure_update': 25, 30 | 'tile_array_update': 26, 31 | 'tile_update': 27, 32 | 'tile_liquid_update': 28, 33 | 'tile_damage_update': 29, 34 | 'tile_modification_failure': 30, 35 | 'give_item': 31, 36 | 'environment_update': 32, 37 | 'update_tile_protection': 33, 38 | 'set_dungeon_gravity': 34, 39 | 'set_dungeon_breathable': 35, 40 | 'set_player_start': 36, 41 | 'find_unique_entity_response': 37, 42 | 'pong': 38, 43 | 'modify_tile_list': 39, 44 | 'damage_tile_group': 40, 45 | 'collect_liquid': 41, 46 | 'request_drop': 42, 47 | 'spawn_entity': 43, 48 | 'connect_wire': 44, 49 | 'disconnect_all_wires': 45, 50 | 'world_client_state_update': 46, 51 | 'find_unique_entity': 47, 52 | 'world_start_acknowledge': 48, 53 | 'ping': 49, 54 | 'entity_create': 50, 55 | 'entity_update': 51, 56 | 'entity_destroy': 52, 57 | 'entity_interact': 53, 58 | 'entity_interact_result': 54, 59 | 'hit_request': 55, 60 | 'damage_request': 56, 61 | 'damage_notification': 57, 62 | 'entity_message': 58, 63 | 'entity_message_response': 59, 64 | 'update_world_properties': 60, 65 | 'step_update': 61, 66 | 'system_world_start': 62, 67 | 'system_world_update': 63, 68 | 'system_object_create': 64, 69 | 'system_object_destroy': 65, 70 | 'system_ship_create': 66, 71 | 'system_ship_destroy': 67, 72 | 'system_object_spawn': 68}) 73 | 74 | -------------------------------------------------------------------------------- /plugin_manager.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import importlib.machinery 3 | import inspect 4 | import logging 5 | import pathlib 6 | from types import ModuleType 7 | 8 | from base_plugin import BasePlugin 9 | from configuration_manager import ConfigurationManager 10 | from pparser import PacketParser 11 | from utilities import detect_overrides 12 | 13 | 14 | class PluginManager: 15 | def __init__(self, config: ConfigurationManager, *, base=BasePlugin, 16 | factory=None): 17 | self.base = base 18 | self.config = config 19 | self.failed = {} 20 | self._seen_classes = set() 21 | self._plugins = {} 22 | self._activated_plugins = set() 23 | self._deactivated_plugins = set() 24 | self._resolved = False 25 | self._overrides = set() 26 | self._override_cache = set() 27 | self._packet_parser = PacketParser(self.config) 28 | self._factory = factory 29 | self.logger = logging.getLogger("starrypy.plugin_manager") 30 | 31 | def list_plugins(self): 32 | return self._plugins 33 | 34 | async def do(self, connection, action: str, packet: dict): 35 | """ 36 | Calls an action on all loaded plugins. 37 | """ 38 | try: 39 | if ("on_%s" % action) in self._overrides: 40 | packet = await self._packet_parser.parse(packet) 41 | send_flag = True 42 | for plugin in self._plugins.values(): 43 | p = getattr(plugin, "on_%s" % action) 44 | if not (await p(packet, connection)): 45 | send_flag = False 46 | return send_flag 47 | else: 48 | return True 49 | except Exception: 50 | self.logger.exception("Exception encountered in plugin on action: " 51 | "%s", action, exc_info=True) 52 | return True 53 | 54 | def load_from_path(self, plugin_path: pathlib.Path): 55 | blacklist = ["__init__", "__pycache__"] 56 | loaded = set() 57 | for file in plugin_path.iterdir(): 58 | if file.stem in blacklist: 59 | continue 60 | if (file.suffix == ".py" or file.is_dir()) and str( 61 | file) not in loaded: 62 | try: 63 | loaded.add(str(file)) 64 | self.load_plugin(file) 65 | except (SyntaxError, ImportError) as e: 66 | self.failed[file.stem] = str(e) 67 | print(e) 68 | except FileNotFoundError: 69 | self.logger.warning("File not found in plugin loader.") 70 | 71 | @staticmethod 72 | def _load_module(file_path: pathlib.Path): 73 | """ 74 | Attempts to load a module, either from a straight python file or from 75 | a python package, by appending __init__.py to the end of the path if it 76 | is a directory. 77 | """ 78 | if file_path.is_dir(): 79 | file_path /= '__init__.py' 80 | if not file_path.exists(): 81 | raise FileNotFoundError("{0} doesn't exist.".format(file_path)) 82 | name = "plugins.%s" % file_path.stem 83 | loader = importlib.machinery.SourceFileLoader(name, str(file_path)) 84 | module = loader.load_module(name) 85 | return module 86 | 87 | def load_plugin(self, plugin_path: pathlib.Path): 88 | module = self._load_module(plugin_path) 89 | classes = self.get_classes(module) 90 | for candidate in classes: 91 | candidate.factory = self._factory 92 | self._seen_classes.add(candidate) 93 | self.config.save_config() 94 | 95 | def get_classes(self, module: ModuleType): 96 | """ 97 | Uses the inspect module to find all classes in a given module that 98 | are subclassed from `self.base`, but are not actually `self.base`. 99 | """ 100 | class_list = [] 101 | for _, obj in inspect.getmembers(module): 102 | if inspect.isclass(obj): 103 | if issubclass(obj, self.base) and obj is not self.base: 104 | obj.config = self.config 105 | obj.logger = logging.getLogger("starrypy.plugin.%s" % 106 | obj.name) 107 | class_list.append(obj) 108 | 109 | return class_list 110 | 111 | def load_plugins(self, plugins: list): 112 | for plugin in plugins: 113 | self.load_plugin(plugin) 114 | 115 | def resolve_dependencies(self): 116 | """ 117 | Resolves dependencies from self._seen_classes through a very simple 118 | topological sort. Raises ImportError if there is an unresolvable 119 | dependency, otherwise it instantiates the class and puts it in 120 | self._plugins. 121 | """ 122 | deps = {x.name: set(x.depends) for x in self._seen_classes} 123 | classes = {x.name: x for x in self._seen_classes} 124 | while len(deps) > 0: 125 | ready = [x for x, d in deps.items() if len(d) == 0] 126 | for name in ready: 127 | p = classes[name]() 128 | self._plugins[name] = p 129 | del deps[name] 130 | for name, depends in deps.items(): 131 | to_load = depends & set(self._plugins.keys()) 132 | deps[name] = deps[name].difference(set(self._plugins.keys())) 133 | for plugin in to_load: 134 | classes[name].plugins[plugin] = self._plugins[plugin] 135 | if len(ready) == 0: 136 | raise ImportError("Unresolved dependencies found in: " 137 | "{}".format(deps)) 138 | self._resolved = True 139 | 140 | async def get_overrides(self): 141 | if self._override_cache is self._activated_plugins: 142 | return self._overrides 143 | else: 144 | overrides = set() 145 | for plugin in self._activated_plugins: 146 | override = await detect_overrides(BasePlugin, plugin) 147 | overrides.update({x for x in override}) 148 | self._overrides = overrides 149 | self._override_cache = self._activated_plugins 150 | return overrides 151 | 152 | async def activate_all(self): 153 | self.logger.info("Activating plugins:") 154 | for plugin in self._plugins.values(): 155 | self.logger.info(plugin.name) 156 | await plugin.activate() 157 | self._activated_plugins.add(plugin) 158 | await self.get_overrides() 159 | 160 | async def deactivate_all(self): 161 | for plugin in self._plugins.values(): 162 | self.logger.info("Deactivating %s", plugin.name) 163 | await plugin.deactivate() 164 | -------------------------------------------------------------------------------- /plugins/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarryPy/StarryPy3k/08b1ef5a1dec95ce9b9002f3319a8619b2f6b9ae/plugins/__init__.py -------------------------------------------------------------------------------- /plugins/basic_auth.py: -------------------------------------------------------------------------------- 1 | """ 2 | StarryPy Basic Authentication Plugin 3 | 4 | Blocks UUID spoofing of staff members by forcing players with Moderator roles 5 | to log in with a whitelisted Starbound account. 6 | Permitted accounts are defined in StarryPy3k's configuration file. 7 | 8 | Original Authors: GermaniumSystem 9 | """ 10 | 11 | 12 | from base_plugin import SimpleCommandPlugin 13 | from data_parser import ConnectFailure 14 | from pparser import build_packet 15 | from packets import packets 16 | 17 | 18 | class BasicAuth(SimpleCommandPlugin): 19 | name = "basic_auth" 20 | depends = ["player_manager"] 21 | default_config = {"enabled": False, 22 | "staff_priority": 100, 23 | "staff_sb_accounts": [ 24 | "-- REPLACE WITH STARBOUND ACCOUNT NAME --", 25 | "-- REPLACE WITH ANOTHER --", 26 | "-- SO ON AND SO FORTH ---"], 27 | "owner_priority": 100000, 28 | "owner_sb_account": "-- REPLACE WITH OWNER ACCOUNT --"} 29 | 30 | def __init__(self): 31 | super().__init__() 32 | self.enabled = False 33 | 34 | async def activate(self): 35 | await super().activate() 36 | if self.config.get_plugin_config(self.name)["enabled"]: 37 | self.logger.debug("Enabled.") 38 | self.enabled = True 39 | else: 40 | self.enabled = False 41 | self.logger.warning("+---------------< WARNING >---------------+") 42 | self.logger.warning("| basic_auth plugin is disabled! You are |") 43 | self.logger.warning("| vulnerable to UUID spoofing attacks! |") 44 | self.logger.warning("| Consult README for enablement info. |") 45 | self.logger.warning("+-----------------------------------------+") 46 | 47 | async def on_client_connect(self, data, connection): 48 | """ 49 | Catch when a the client updates the server with its connection 50 | details. 51 | 52 | :param data: 53 | :param connection: 54 | :return: Boolean: True on successful connection, False on a 55 | failed connection. 56 | """ 57 | 58 | if not self.enabled: 59 | return True 60 | uuid = data["parsed"]["uuid"].decode("ascii") 61 | account = data["parsed"]["account"] 62 | player = self.plugins["player_manager"].get_player_by_uuid(uuid) 63 | # We're only interested in players who already exist. 64 | if player: 65 | # The Owner account is quite dangerous, so it has a separate 66 | # password to prevent a malicious staff member from taking over. 67 | # Moderator thru SuperAdmin can still execute spoofing attacks on 68 | # eachother, but this is being allowed for the sake of usability. 69 | if ( 70 | ( 71 | self.plugin_config.owner_priority <= player.priority 72 | and account == self.plugin_config.owner_sb_account 73 | ) or ( 74 | self.plugin_config.owner_priority > player.priority 75 | >= self.plugin_config.staff_priority 76 | and account in self.plugin_config.staff_sb_accounts 77 | ) 78 | ): 79 | # Everything checks out. 80 | self.logger.info("Player with privileged UUID '{}' " 81 | "successfully authenticated as " 82 | "'{}'".format(uuid, account)) 83 | # We don't need to worry about anything after this. 84 | # Starbound will take care of an incorrect password. 85 | elif self.plugin_config.staff_priority <= player.priority: 86 | # They're privileged but failed to authenticate. Kill it. 87 | await connection.raw_write( 88 | self.build_rejection("^red;UNAUTHORIZED^reset;\n" 89 | "Privileged players must log in with " 90 | "an account defined in StarryPy3k's " 91 | "config.")) 92 | connection.die() 93 | self.logger.warning("Player with privileged UUID '{}' FAILED " 94 | "to authenticate as '{}'" 95 | "!".format(uuid, account)) 96 | return False 97 | return True 98 | 99 | # Helper functions - Used by hooks and commands 100 | 101 | def build_rejection(self, reason): 102 | """ 103 | Function to build packet to reject connection for client. 104 | 105 | :param reason: String. Reason for rejection. 106 | :return: Rejection packet. 107 | """ 108 | return build_packet(packets["connect_failure"], 109 | ConnectFailure.build( 110 | dict(reason=reason))) 111 | 112 | -------------------------------------------------------------------------------- /plugins/chat_logger.py: -------------------------------------------------------------------------------- 1 | """ 2 | StarryPy Chat Logger Plugin 3 | 4 | Log all in-game chat messages to the logger. 5 | 6 | Original authors: kharidiron 7 | """ 8 | 9 | from base_plugin import BasePlugin 10 | 11 | 12 | class ChatLogger(BasePlugin): 13 | name = "chat_logger" 14 | 15 | def __init__(self): 16 | super().__init__() 17 | 18 | async def activate(self): 19 | await super().activate() 20 | 21 | async def on_chat_sent(self, data, connection): 22 | """ 23 | Catch when someone sends any form of message or command and log it. 24 | 25 | :param data: The packet containing the message. 26 | :param connection: The connection from which the packet came. 27 | :return: Boolean; Always true. 28 | """ 29 | message = data["parsed"]["message"] 30 | self.logger.info("{}: {}".format(connection.player.name, message)) 31 | return True 32 | -------------------------------------------------------------------------------- /plugins/chat_manager.py: -------------------------------------------------------------------------------- 1 | """ 2 | StarryPy Chat Manager Plugin 3 | 4 | Provides core chat management features, such as mute...and that's it right now. 5 | Future features could be added... 6 | 7 | Original authors: AMorporkian 8 | Updated for release: kharidiron 9 | """ 10 | 11 | from base_plugin import SimpleCommandPlugin 12 | from utilities import Command, send_message 13 | 14 | 15 | ### 16 | 17 | class ChatManager(SimpleCommandPlugin): 18 | name = "chat_manager" 19 | depends = ["player_manager", "command_dispatcher"] 20 | 21 | def __init__(self): 22 | super().__init__() 23 | self.storage = None 24 | 25 | async def activate(self): 26 | await super().activate() 27 | self.storage = self.plugins.player_manager.get_storage(self) 28 | if "mutes" not in self.storage: 29 | self.storage["mutes"] = set() 30 | 31 | # Packet hooks - look for these packets and act on them 32 | 33 | async def on_chat_sent(self, data, connection): 34 | """ 35 | Catch when someone sends a message. 36 | 37 | :param data: The packet containing the message. 38 | :param connection: The connection from which the packet came. 39 | :return: Boolean. True if we're done with the packet here, False if the 40 | player is muted (preventing packet from being passed along. 41 | Commands are treated as truthy values. 42 | """ 43 | message = data["parsed"]["message"] 44 | if message.startswith( 45 | self.plugins.command_dispatcher.plugin_config.command_prefix): 46 | return True 47 | 48 | if self.mute_check(connection.player): 49 | send_message(connection, "You are muted and cannot chat.") 50 | return False 51 | 52 | return True 53 | 54 | # Helper functions - Used by commands 55 | 56 | def mute_check(self, player): 57 | """ 58 | Utility function to verifying if target player is muted. 59 | 60 | :param player: Target player to check. 61 | :return: Boolean. True if player is muted, False if they are not. 62 | """ 63 | return player in self.storage.mutes 64 | 65 | # Commands - In-game actions that can be performed 66 | 67 | @Command("mute", 68 | perm="chat_manager.mute", 69 | doc="Mutes a user", 70 | syntax="(username)") 71 | async def _mute(self, data, connection): 72 | """ 73 | Mute command. Pulls target's name from data stream. Check if valid 74 | player. Also check if player can be muted, or is already muted. 75 | Mute target when possible. 76 | 77 | :param data: The packet containing the command. 78 | :param connection: The connection from which the packet came. 79 | :return: Null 80 | """ 81 | alias = " ".join(data) 82 | player = self.plugins.player_manager.find_player(alias) 83 | if player is None: 84 | raise NameError 85 | elif self.mute_check(player): 86 | send_message(connection, 87 | "{} is already muted.".format(player.alias)) 88 | return 89 | elif player.priority >= connection.player.priority: 90 | send_message(connection, 91 | "Can't mute {}, they are equal or higher " 92 | "than your rank!".format(player.alias)) 93 | return 94 | else: 95 | self.storage.mutes.add(player) 96 | send_message(connection, 97 | "{} has been muted.".format(player.alias)) 98 | if player.logged_in: 99 | send_message(player.connection, 100 | "{} has muted you.".format(connection.player.alias)) 101 | 102 | @Command("unmute", 103 | perm="chat_manager.mute", 104 | doc="Unmutes a player", 105 | syntax="(username)") 106 | async def _unmute(self, data, connection): 107 | """ 108 | Unmute command. Pulls target's name from data stream. Check if valid 109 | player. Check that player is actually muted. If possible, unmute 110 | the target. 111 | 112 | :param data: The packet containing the command. 113 | :param connection: The connection from which the packet came. 114 | :return: Null 115 | """ 116 | alias = " ".join(data) 117 | player = self.plugins.player_manager.find_player(alias) 118 | if player is None: 119 | raise NameError 120 | elif not self.mute_check(player): 121 | send_message(connection, 122 | "{} isn't muted.".format(player.alias)) 123 | return 124 | else: 125 | self.storage.mutes.remove(player) 126 | send_message(connection, 127 | "{} has been unmuted.".format(player.alias)) 128 | if player.logged_in: 129 | send_message(player.connection, 130 | "{} has unmuted you.".format(connection.player.alias)) 131 | -------------------------------------------------------------------------------- /plugins/command_dispatcher.py: -------------------------------------------------------------------------------- 1 | """ 2 | StarryPy Command Dispatcher Plugin 3 | 4 | A plugin which handles user commands. All plugins wishing to provide commands 5 | should register themselves through CommandDispatcher. 6 | 7 | This should be done by using the @Command decorator in a SimpleCommandPlugin 8 | subclass, though it could be done manually in tricky use-cases. 9 | 10 | Note that your @Command subroutines must be async functions. 11 | 12 | Original authors: AMorporkian 13 | Updated for release: kharidiron 14 | """ 15 | 16 | import asyncio 17 | import packets 18 | 19 | from base_plugin import BasePlugin 20 | from utilities import extractor, get_syntax, send_message 21 | from data_parser import ChatSent 22 | from pparser import build_packet 23 | 24 | 25 | class CommandDispatcher(BasePlugin): 26 | name = "command_dispatcher" 27 | default_config = {"command_prefix": "/"} 28 | 29 | def __init__(self): 30 | super().__init__() 31 | self.commands = {} 32 | 33 | # Packet hooks - look for these packets and act on them 34 | 35 | async def on_chat_sent(self, data, connection): 36 | """ 37 | Catch a chat packet as it goes by. If the first character in its 38 | string is the command_prefix, it is a command. Grab it and start 39 | interpreting its contents. 40 | 41 | :param data: Packet which is being transmitted. 42 | :param connection: Connection which sent the packet. 43 | :return: Boolean: True if the message is not a command (or not one we 44 | know about), so that the packets keeps going. False if it is 45 | a command we know, so that it stops here after it is 46 | processed. 47 | """ 48 | if data['parsed']['message'].startswith( 49 | self.plugin_config.command_prefix): 50 | if data['parsed']['message'].startswith("{}sb:".format( 51 | self.plugin_config.command_prefix)): 52 | # Bypass StarryPy command processing and send to the 53 | # starbound server 54 | cmd = data['parsed']['message'].replace("sb:", "") 55 | pkt = ChatSent.build({"message": cmd, "send_mode": data[ 56 | 'parsed']['send_mode']}) 57 | full = build_packet(packets.packets['chat_sent'], pkt) 58 | await connection.client_raw_write(full) 59 | return False 60 | to_parse = data['parsed']['message'][len( 61 | self.plugin_config.command_prefix):].split() 62 | 63 | try: 64 | command = to_parse[0] 65 | except IndexError: 66 | return True # It's just a slash. 67 | 68 | if command not in self.commands: 69 | return True # There's no command here that we know of. 70 | else: 71 | self.background(self.run_command(command, 72 | connection, 73 | to_parse[1:])) 74 | return False # We're handling the command in the event loop. 75 | else: 76 | # Not a command, just text, so pass it along. 77 | return True 78 | 79 | # Helper functions - Used by commands 80 | 81 | def register(self, fn, name, aliases=None): 82 | """ 83 | Registers a function with a given name. Recursively applies itself 84 | for any aliases provided. 85 | 86 | :param fn: The function to be called. 87 | :param name: The primary command name. 88 | :param aliases: Additional names a command can have. 89 | :return: Null. 90 | :raise: NameError on duplicate command name. 91 | """ 92 | self.logger.debug("Adding command with name {}".format(name)) 93 | if aliases is not None: 94 | for alias in aliases: 95 | self.register(fn, alias) 96 | 97 | if name in self.commands: 98 | oldfn = self.commands[name] 99 | if fn.priority >= oldfn.priority: 100 | self.commands[name] = fn 101 | self.logger.debug("Command {} from {} overrides command {} " 102 | "from {}.".format(name, fn.__self__, name, 103 | oldfn.__self__)) 104 | else: 105 | self.logger.debug("Command {} from {} overrides command {} " 106 | "from {}.".format(name, oldfn.__self__, name, 107 | fn.__self__)) 108 | 109 | else: 110 | self.commands[name] = fn 111 | 112 | def _send_syntax_error(self, command, error, connection): 113 | """ 114 | Sends a syntax error to the user regarding a command. 115 | 116 | :param command: The command name 117 | :param error: The error (a string or an exception) 118 | :param connection: The player connection. 119 | :return: None. 120 | """ 121 | send_message(connection, 122 | "Syntax error: {}".format(error), 123 | get_syntax(command, 124 | self.commands[command], 125 | self.plugin_config.command_prefix)) 126 | return None 127 | 128 | def _send_name_error(self, player_name, connection): 129 | """ 130 | Sends an error about an incorrect player name. 131 | 132 | :param player_name: The non-existent player's name 133 | :param connection: The active player connection. 134 | :return: None 135 | """ 136 | send_message(connection, "Unknown player {}".format(player_name)) 137 | return None 138 | 139 | async def run_command(self, command, connection, to_parse): 140 | """ 141 | Evaluate the command passed in, passing along the arguments. Raise 142 | various errors depending on what might have gone wrong. 143 | 144 | :param command: Command to be executed. Looked up in commands dict. 145 | :param connection: Connection which is calling the command. 146 | :param to_parse: Arguments to provide to the command. 147 | :return: Null. 148 | :raise: SyntaxWarning on improper syntax usage. NameError when object 149 | could not be found. ValueError when improper input is provided. 150 | General Exception error as a last-resort catch-all. 151 | """ 152 | try: 153 | handler = self.commands[command] 154 | #self.logger.debug("Processing command {} with handler {}.".format(command, handler)) 155 | await handler(extractor(to_parse), connection) 156 | except SyntaxWarning as e: 157 | self._send_syntax_error(command, e, connection) 158 | except NameError as e: 159 | self._send_name_error(e, connection) 160 | except ValueError as e: 161 | send_message(connection, str(e)) 162 | except SystemExit as e: 163 | raise SystemExit 164 | except Exception: 165 | self.logger.exception("Unknown exception encountered. Ignoring.", 166 | exc_info=True) 167 | -------------------------------------------------------------------------------- /plugins/discord_bot.py: -------------------------------------------------------------------------------- 1 | """ 2 | StarryPy Discord Plugin 3 | 4 | Provides a Discord bot that echos conversations between the game server and 5 | a Discord guild channel. 6 | 7 | Original authors: kharidiron 8 | """ 9 | 10 | import re 11 | import logging 12 | import asyncio 13 | 14 | import discord 15 | 16 | from base_plugin import BasePlugin 17 | from utilities import ChatSendMode, ChatReceiveMode, link_plugin_if_available 18 | 19 | 20 | # Mock Objects 21 | 22 | class MockPlayer: 23 | """ 24 | A mock player object for command passing. 25 | 26 | We have to make it 'Mock' because there are all sorts of things in the 27 | real Player object that don't map correctly, and would cause all sorts 28 | of headaches. 29 | """ 30 | name = "DiscordBot" 31 | logged_in = True 32 | 33 | def __init__(self): 34 | self.granted_perms = set() 35 | self.revoked_perms = set() 36 | self.permissions = set() 37 | self.priority = 0 38 | self.name = "MockPlayer" 39 | self.alias = "MockPlayer" 40 | 41 | def perm_check(self, perm): 42 | if not perm: 43 | return True 44 | elif "special.allperms" in self.permissions: 45 | return True 46 | elif perm.lower() in self.revoked_perms: 47 | return False 48 | elif perm.lower() in self.permissions: 49 | return True 50 | else: 51 | return False 52 | 53 | 54 | class MockConnection: 55 | """ 56 | A mock connection object for command passing. 57 | """ 58 | def __init__(self, owner): 59 | self.owner = owner 60 | self.player = MockPlayer() 61 | 62 | async def send_message(self, *messages): 63 | for message in messages: 64 | message = self.owner.color_strip.sub("", message) 65 | await self.owner.bot_write(message, 66 | target=self.owner.command_target) 67 | return None 68 | 69 | class DiscordClient(discord.Client): 70 | 71 | def __init__(self, plugin): 72 | intents = discord.Intents.default() 73 | intents.message_content = True 74 | discord.Client.__init__(self, intents = intents) 75 | self.starry_plugin = plugin 76 | self.channel = None 77 | self.staff_channel = None 78 | 79 | async def on_ready(self): 80 | self.channel = self.get_channel(self.starry_plugin.channel_id) 81 | self.staff_channel = self.get_channel(self.starry_plugin.staff_channel_id) 82 | if not self.channel: 83 | self.starry_plugin.logger.error("Couldn't get channel! Messages can't be " 84 | "sent! Ensure the channel ID is correct.") 85 | if not self.staff_channel: 86 | self.starry_plugin.logger.warning("Couldn't get staff channel! Reports " 87 | "will be sent to the main channel.") 88 | 89 | async def on_message(self, message): 90 | await self.starry_plugin.send_to_game(message) 91 | 92 | 93 | class DiscordPlugin(BasePlugin): 94 | name = "discord_bot" 95 | depends = ['command_dispatcher'] 96 | default_config = { 97 | "enabled": True, 98 | "token": "-- token --", 99 | "client_id": "-- client_id --", 100 | "channel": "-- channel id --", 101 | "staff_channel": "-- channel id --", 102 | "strip_colors": True, 103 | "log_discord": False, 104 | "command_prefix": "!", 105 | "rank_roles": { 106 | "A Discord Rank": "A StarryPy Rank" 107 | } 108 | } 109 | 110 | def __init__(self): 111 | BasePlugin.__init__(self) 112 | self.enabled = True 113 | self.token = None 114 | self.channel_id = None 115 | self.staff_channel_id = None 116 | self.token = None 117 | self.client_id = None 118 | self.mock_connection = None 119 | self.prefix = None 120 | self.command_prefix = None 121 | self.dispatcher = None 122 | self.color_strip = re.compile("\^(.*?);") 123 | self.command_target = None 124 | self.sc = None 125 | self.irc_bot_exists = False 126 | self.irc = None 127 | self.chat_manager = None 128 | self.rank_roles = None 129 | self.discord_logger = None 130 | self.discord_client = None 131 | self.allowed_commands = ('who', 'help', 'uptime', 'motd', 'show_spawn', 132 | 'ban', 'unban', 'kick', 'list_bans', 'mute', 133 | 'unmute', 'set_motd', 'whois', 'broadcast', 134 | 'user', 'del_player', 'maintenance_mode', 135 | 'shutdown', 'save') 136 | 137 | async def activate(self): 138 | self.enabled = self.config.get_plugin_config(self.name)["enabled"] 139 | if not self.enabled: 140 | return; 141 | await super().activate() 142 | 143 | self.dispatcher = self.plugins.command_dispatcher 144 | self.irc_bot_exists = link_plugin_if_available(self, 'irc_bot') 145 | if self.irc_bot_exists: 146 | self.irc = self.plugins['irc_bot'] 147 | self.prefix = self.config.get_plugin_config("command_dispatcher")[ 148 | "command_prefix"] 149 | self.command_prefix = self.config.get_plugin_config(self.name)[ 150 | "command_prefix"] 151 | self.token = self.config.get_plugin_config(self.name)["token"] 152 | self.client_id = int(self.config.get_plugin_config(self.name)["client_id"]) 153 | self.channel_id = int(self.config.get_plugin_config(self.name)["channel"]) 154 | self.staff_channel_id = int(self.config.get_plugin_config(self.name)[ 155 | "staff_channel"]) 156 | self.sc = self.config.get_plugin_config(self.name)["strip_colors"] 157 | 158 | self.background(self.start_bot()).add_done_callback(self.error_handler) 159 | 160 | self.mock_connection = MockConnection(self) 161 | self.rank_roles = self.config.get_plugin_config(self.name)[ 162 | "rank_roles"] 163 | if link_plugin_if_available(self, "chat_manager"): 164 | self.chat_manager = self.plugins['chat_manager'] 165 | self.discord_logger = logging.getLogger("discord") 166 | self.discord_logger.setLevel(logging.INFO) 167 | ch = logging.StreamHandler() 168 | ch.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - ' 169 | '%(name)s # %(message)s', 170 | datefmt='%Y-%m-%d %H:%M:%S')) 171 | self.discord_logger.addHandler(ch) 172 | 173 | async def deactivate(self): 174 | if not self.enabled: 175 | return 176 | if self.discord_client: 177 | await self.discord_client.close() 178 | await super().deactivate() 179 | 180 | # Packet hooks - look for these packets and act on them 181 | 182 | async def on_connect_success(self, data, connection): 183 | """ 184 | Hook on bot successfully connecting to server. 185 | 186 | :param data: 187 | :param connection: 188 | :return: Boolean: True. Must be true, so packet moves on. 189 | """ 190 | if not self.enabled: 191 | return True; 192 | self.background(self.make_announce(connection, "joined")).add_done_callback(self.error_handler) 193 | return True 194 | 195 | async def on_client_disconnect_request(self, data, connection): 196 | """ 197 | Hook on bot disconnecting from the server. 198 | 199 | :param data: 200 | :param connection: 201 | :return: Boolean: True. Must be true, so packet moves on. 202 | """ 203 | if not self.enabled: 204 | return True; 205 | self.background(self.make_announce(connection, "left")).add_done_callback(self.error_handler) 206 | return True 207 | 208 | async def on_chat_sent(self, data, connection): 209 | """ 210 | Hook on message being broadcast on server. Display it in Discord. 211 | 212 | If 'sc' is True, colors are stripped from game text. e.g. - 213 | 214 | ^red;Red^reset; Text -> Red Text. 215 | 216 | :param data: 217 | :param connection: 218 | :return: Boolean: True. Must be true, so packet moves on. 219 | """ 220 | if not self.enabled: 221 | return True; 222 | if not data["parsed"]["message"].startswith(self.prefix): 223 | msg = data["parsed"]["message"] 224 | if self.sc: 225 | msg = self.color_strip.sub("", msg) 226 | if data["parsed"]["send_mode"] == ChatSendMode.UNIVERSE: 227 | if self.chat_manager: 228 | if not self.chat_manager.mute_check(connection.player): 229 | alias = connection.player.alias 230 | self.background(self.bot_write("**<{}>** {}" 231 | .format(alias, 232 | msg))) 233 | return True 234 | 235 | # Helper functions - Used by commands 236 | 237 | async def start_bot(self): 238 | """ 239 | :param : 240 | :param : 241 | :return: Null 242 | """ 243 | self.logger.info("Starting Discord Bot") 244 | try: 245 | if(self.discord_client != None): 246 | self.background(self.discord_client.close()) 247 | self.discord_client = DiscordClient(self); 248 | await self.discord_client.login(self.token) 249 | await self.discord_client.connect() # sleeps forever until a problem occurs 250 | except asyncio.CancelledError as e: 251 | self.logger.info("Caught interrupt, shutting down.") 252 | except Exception as e: 253 | self.logger.exception("Caught exception in Discord run; shutting down: {}", e) 254 | raise e 255 | 256 | async def send_to_game(self, message): 257 | """ 258 | Broadcast a message on the server. Make sure it isn't coming from the 259 | bot (or else we get duplicate messages). 260 | 261 | :param message: The message packet. 262 | :return: Null 263 | """ 264 | nick = message.author.display_name 265 | text = message.clean_content 266 | guild = message.guild 267 | if message.author.id != self.client_id: 268 | if message.content[0] == self.command_prefix and (message.channel == self.discord_client.channel or message.channel == self.discord_client.staff_channel): 269 | self.command_target = message.channel 270 | self.background(self.handle_command(message.content[1:], 271 | message.author)) 272 | elif message.channel == self.discord_client.channel: 273 | for emote in guild.emojis: 274 | text = text.replace("<:{}:{}>".format(emote.name, 275 | emote.id), 276 | ":{}:".format(emote.name)) 277 | await self.factory.broadcast("[^orange;DC^reset;] <{}>" 278 | " {}".format(nick, text), 279 | mode=ChatReceiveMode.BROADCAST) 280 | if self.config.get_plugin_config(self.name)["log_discord"]: 281 | self.logger.info("<{}> {}".format(nick, text)) 282 | if self.irc_bot_exists and self.irc.enabled: 283 | self.background(self.irc.bot_write( 284 | "[DC] <{}> {}".format(nick, text))) 285 | 286 | async def make_announce(self, connection, circumstance): 287 | """ 288 | Send a message to Discord when someone joins/leaves the server. 289 | 290 | :param connection: Connection of connecting player on server. 291 | :param circumstance: 292 | :return: Null. 293 | """ 294 | await asyncio.sleep(1) 295 | if hasattr(connection, "player"): 296 | await self.bot_write("**{}** has {} the server.".format( 297 | connection.player.alias, circumstance)) 298 | 299 | async def handle_command(self, data, user): 300 | split = data.split() 301 | command = split[0] 302 | to_parse = split[1:] 303 | roles = sorted(user.roles, reverse=True) 304 | role = "Guest" 305 | for x in roles: 306 | if x.name in self.rank_roles: 307 | role = self.rank_roles[x.name] 308 | break 309 | self.mock_connection.player.permissions = \ 310 | self.plugins.player_manager.ranks[role.lower()]["permissions"] 311 | self.mock_connection.player.priority = \ 312 | self.plugins.player_manager.ranks[role.lower()]["priority"] 313 | self.mock_connection.player.alias = user.display_name 314 | self.mock_connection.player.name = user.display_name 315 | if command in self.dispatcher.commands: 316 | # Only handle commands that work from Discord 317 | if command in self.allowed_commands: 318 | await self.dispatcher.run_command(command, 319 | self.mock_connection, 320 | to_parse) 321 | else: 322 | await self.bot_write("Command not handled by Discord.", 323 | target=self.command_target) 324 | else: 325 | await self.bot_write("Command not found.", 326 | target=self.command_target) 327 | 328 | async def bot_write(self, msg, target=None): 329 | if self.discord_client == None or not self.discord_client.is_ready(): 330 | await self.start_bot() 331 | if target is None: 332 | target = self.discord_client.channel 333 | if target is None: 334 | return 335 | self.background(target.send(msg)).add_done_callback(self.error_handler) 336 | 337 | def error_handler(self, future): 338 | try: 339 | future.result() 340 | except KeyboardInterrupt: 341 | # exiting, can leave this alone 342 | return 343 | except Exception as e: 344 | self.logger.error("Caught an unhandled exception in Discord bot. Will restart.") 345 | self.logger.exception(e) 346 | self.background(self.start_bot()).add_done_callback(self.error_handler) 347 | -------------------------------------------------------------------------------- /plugins/emotes.py: -------------------------------------------------------------------------------- 1 | """ 2 | StarryPy Emotes Plugin 3 | 4 | Simple plugin to provide in-game text emotes, with both generic /me 5 | functionality (a la IRC) and predefined actions. 6 | 7 | Original authors: kharidiron 8 | """ 9 | 10 | import asyncio 11 | 12 | import data_parser 13 | import packets 14 | import pparser 15 | from base_plugin import SimpleCommandPlugin 16 | from utilities import Command, send_message, StorageMixin, broadcast, \ 17 | link_plugin_if_available, ChatSendMode 18 | 19 | 20 | class Emotes(StorageMixin, SimpleCommandPlugin): 21 | name = "emotes" 22 | depends = ["command_dispatcher", "player_manager", "chat_manager"] 23 | set_emotes = {"beckon": "beckons you to come over", 24 | "bow": "bows before you", 25 | "cheer": "cheers at you! Yay!", 26 | "cower": "cowers at the sight of your weapons!", 27 | "cry": "bursts out in tears... sob sob", 28 | "dance": "is busting out some moves, some sweet dance moves", 29 | "hug": "needs a hug!", 30 | "hugs": "needs a hug! Many MANY hugs!", 31 | "kiss": "blows you a kiss <3", 32 | "kneel": "kneels down before you", 33 | "laugh": "suddenly laughs and just as suddenly stops", 34 | "lol": "laughs out loud -LOL-", 35 | "no": "disagrees", 36 | "point": "points somewhere in the distance", 37 | "ponder": "ponders if this is worth it", 38 | "rofl": "rolls on the floor laughing", 39 | "salute": "salutes you", 40 | "shrug": "shrugs at you", 41 | "sit": "sits down. Oh, boy...", 42 | "sleep": "falls asleep. Zzz", 43 | "surprised": "is surprised beyond belief", 44 | "threaten": "is threatening you with a butter knife!", 45 | "wave": "waves... Helloooo there!", 46 | "yes": "agrees"} 47 | 48 | def __init__(self): 49 | super().__init__() 50 | self.irc_active = False 51 | self.discord_active = False 52 | self.chat_enhancements = False 53 | 54 | async def activate(self): 55 | await super().activate() 56 | self.irc_active = link_plugin_if_available(self, "irc_bot") 57 | self.discord_active = link_plugin_if_available(self, "discord_bot") 58 | self.chat_enhancements = link_plugin_if_available(self, 59 | "chat_enhancements") 60 | 61 | # Helper functions - Used by commands 62 | 63 | async def _send_to_server(self, message, mode, connection): 64 | msg_base = data_parser.ChatSent.build(dict(message="".join(message), 65 | send_mode=mode)) 66 | msg_packet = pparser.build_packet(packets.packets['chat_sent'], 67 | msg_base) 68 | await connection.client_raw_write(msg_packet) 69 | 70 | # Commands - In-game actions that can be performed 71 | 72 | @Command("me", 73 | perm="emotes.emote", 74 | doc="Perform emote actions.") 75 | async def _emote(self, data, connection): 76 | """ 77 | Command to provide in-game text emotes. 78 | 79 | :param data: The packet containing the command. 80 | :param connection: The connection which sent the command. 81 | :return: Null. 82 | """ 83 | if not data: 84 | emotes = ", ".join(sorted(self.set_emotes)) 85 | send_message(connection, 86 | "Available emotes are:\n {}".format(emotes)) 87 | send_message(connection, 88 | "...or, just type your own: `/me can do anything`") 89 | return False 90 | else: 91 | if self.plugins['chat_manager'].mute_check(connection.player): 92 | send_message(connection, "You are muted and cannot emote.") 93 | return False 94 | 95 | emote = " ".join(data) 96 | try: 97 | emote = self.set_emotes[emote] 98 | except KeyError: 99 | pass 100 | finally: 101 | if self.irc_active: 102 | self.background( 103 | self.plugins["irc_bot"].bot_write(" -*- {} {}".format( 104 | connection.player.alias, emote))) 105 | if self.discord_active: 106 | self.background(self.plugins["discord_bot"] 107 | .bot_write(" -*- {} {}".format( 108 | connection.player.alias, emote))) 109 | message = "^orange;{} {}".format(connection.player.alias, 110 | emote) 111 | try: 112 | await ( 113 | self._send_to_server(message, 114 | ChatSendMode.UNIVERSE, 115 | connection)) 116 | except (KeyError, AttributeError): 117 | self.logger.debug("using fallback broadcast") 118 | broadcast(connection, message) 119 | 120 | @Command("mel", 121 | perm="emotes.emote", 122 | doc="Perform emote actions in local chat.") 123 | async def _emote_local(self, data, connection): 124 | """ 125 | Command to provide in-game text emotes for local chat. 126 | 127 | :param data: The packet containing the command. 128 | :param connection: The connection which sent the command. 129 | :return: Null. 130 | """ 131 | if not data: 132 | emotes = ", ".join(sorted(self.set_emotes)) 133 | send_message(connection, 134 | "Available emotes are:\n {}".format(emotes)) 135 | send_message(connection, 136 | "...or, just type your own: `/me can do anything`") 137 | return False 138 | else: 139 | if self.plugins['chat_manager'].mute_check(connection.player): 140 | send_message(connection, "You are muted and cannot emote.") 141 | return False 142 | 143 | emote = " ".join(data) 144 | try: 145 | emote = self.set_emotes[emote] 146 | except KeyError: 147 | pass 148 | finally: 149 | message = "^orange;{} {}".format(connection.player.alias, 150 | emote) 151 | try: 152 | await ( 153 | self._send_to_server(message, 154 | ChatSendMode.LOCAL, 155 | connection)) 156 | except (KeyError, AttributeError): 157 | self.logger.debug("using fallback broadcast") 158 | broadcast(connection, message) 159 | -------------------------------------------------------------------------------- /plugins/emsg_blocker.py: -------------------------------------------------------------------------------- 1 | """ 2 | StarryPy Entity Message Blocker Plugin 3 | 4 | Filter out harmful or malicious entity messages from players. 5 | 6 | Original authors: medeor413 7 | """ 8 | 9 | from base_plugin import BasePlugin 10 | from utilities import Direction 11 | 12 | 13 | class ChatLogger(BasePlugin): 14 | name = "emsg_blocker" 15 | 16 | def __init__(self): 17 | super().__init__() 18 | self.blocked_messages = [] 19 | self.in_transit_players = set() 20 | 21 | async def activate(self): 22 | await super().activate() 23 | self.in_transit_players = set() 24 | self.blocked_messages = [ 25 | "applyStatusEffect", 26 | "warp", 27 | "playAltMusic", 28 | "stopAltMusic", 29 | "playCinematic" 30 | ] 31 | self.blocked_world_properties = [ 32 | "nonCombat", 33 | "invinciblePlayers" 34 | ] 35 | 36 | async def on_world_stop(self, data, connection): 37 | self.in_transit_players.add(connection) 38 | return True 39 | 40 | async def on_world_start(self, data, connection): 41 | if connection in self.in_transit_players: 42 | self.in_transit_players.remove(connection) 43 | return True 44 | 45 | async def on_entity_message(self, data, connection): 46 | """ 47 | Catch when an entity message is sent and block it, depending on its 48 | contents. 49 | 50 | :param data: The packet containing the message. 51 | :param connection: The connection from which the packet came. 52 | :return: Boolean; True if the message is allowed, false if it's 53 | blocked. 54 | """ 55 | if data['direction'] == Direction.TO_CLIENT: 56 | # The server probably isn't sending malicious messages 57 | return True 58 | else: 59 | if data['parsed']['message_name'] in self.blocked_messages: 60 | if not connection.player.perm_check("emsg_blocker.bypass"): 61 | self.logger.debug("Blocked message {} from player {}." 62 | .format(data['parsed']['message_name'], 63 | connection.player.alias)) 64 | return False 65 | return True 66 | 67 | async def on_entity_message_response(self, data, connection): 68 | if connection in self.in_transit_players and data['direction'] == \ 69 | Direction.TO_CLIENT: 70 | return False 71 | else: 72 | return True 73 | 74 | async def on_update_world_properties(self, data, connection): 75 | """ 76 | Catch when world properties are modified and block it, depending on 77 | its contents. 78 | :param data: 79 | :param connection: 80 | :return: Boolean: True if the change is allowed, false otherwise 81 | """ 82 | if data['direction'] == Direction.TO_CLIENT: 83 | # The server is just informing the clients of changes. 84 | return True 85 | else: 86 | for key in data['parsed'].keys(): 87 | if key in self.blocked_world_properties: 88 | if not connection.player.perm_check("emsg_blocker.bypass"): 89 | self.logger.debug("Blocked change of world property " 90 | "{} from player {}.".format( 91 | key, connection.player.alias)) 92 | return False 93 | return True -------------------------------------------------------------------------------- /plugins/general_commands.py: -------------------------------------------------------------------------------- 1 | """ 2 | StarryPy General Commands Plugin 3 | 4 | Plugin for handling most of the most basic (and most useful) commands. 5 | 6 | Original authors: AMorporkian 7 | Updated for release: kharidiron 8 | """ 9 | import asyncio 10 | 11 | import sys 12 | 13 | import datetime 14 | 15 | import packets 16 | import pparser 17 | import data_parser 18 | from base_plugin import SimpleCommandPlugin 19 | from utilities import send_message, Command, broadcast, link_plugin_if_available, State 20 | 21 | 22 | ### 23 | 24 | class GeneralCommands(SimpleCommandPlugin): 25 | name = "general_commands" 26 | depends = ["command_dispatcher", "player_manager"] 27 | default_config = {"maintenance_message": "This server is currently in " 28 | "maintenance mode and is not " 29 | "accepting new connections."} 30 | 31 | def __init__(self): 32 | super().__init__() 33 | self.maintenance = False 34 | self.rejection_message = "" 35 | self.start_time = None 36 | self.chat_manager = None 37 | # Helper functions - Used by commands 38 | 39 | async def activate(self): 40 | await super().activate() 41 | self.maintenance = False 42 | self.rejection_message = self.config.get_plugin_config(self.name)[ 43 | "maintenance_message"] 44 | self.start_time = datetime.datetime.now() 45 | if link_plugin_if_available(self, "chat_manager"): 46 | self.chat_manager = self.plugins["chat_manager"] 47 | 48 | def generate_whois(self, target): 49 | """ 50 | Generate the whois data for a player, and return it as a formatted 51 | string. 52 | 53 | :param target: Player object to be looked up. 54 | :return: String: The data about the player. 55 | """ 56 | logged_in = "(^green;Online^reset;)" 57 | last_seen = "Now" 58 | ban_status = "^green;Not banned" 59 | mute_line = "" 60 | if not target.logged_in: 61 | logged_in = "(^red;Offline^reset;)" 62 | last_seen = target.last_seen 63 | if target.ip in self.plugins["player_manager"].shelf["bans"]: 64 | ban = self.plugins["player_manager"].shelf["bans"][target.ip] 65 | ban_status = "^red;Banned by {} on {}\nBan Reason: ^red;{}".format( 66 | ban.banned_by, ban.banned_at, ban.reason) 67 | if self.chat_manager: 68 | if self.chat_manager.mute_check(target): 69 | mute_line = "Mute Status: ^red;Muted^green;" 70 | else: 71 | mute_line = "Mute Status: ^green;Unmuted" 72 | return ("Name: {} {}\n" 73 | "Raw Name: {}\n" 74 | "Ranks: ^yellow;{}^green;\n" 75 | "UUID: ^yellow;{}^green;\n" 76 | "IP address: ^cyan;{}^green;\n" 77 | "Team ID: ^cyan;{}^green;\n" 78 | "Current location: ^yellow;{}^green;\n" 79 | "Last seen: ^yellow;{}^green;\n" 80 | "Ban status: {}^green;\n" 81 | "{}".format( 82 | target.alias, logged_in, 83 | target.name, 84 | ", ".join(target.ranks), 85 | target.uuid, 86 | target.ip, 87 | target.team_id, 88 | target.location, 89 | last_seen, 90 | ban_status, 91 | mute_line)) 92 | 93 | async def on_connect_success(self, data, connection): 94 | if self.maintenance and not connection.player.perm_check( 95 | "general_commands.maintenance_bypass"): 96 | fail = data_parser.ConnectFailure.build(dict( 97 | reason=self.rejection_message)) 98 | pkt = pparser.build_packet(packets.packets['connect_failure'], 99 | fail) 100 | await connection.raw_write(pkt) 101 | return False 102 | else: 103 | return True 104 | 105 | # Commands - In-game actions that can be performed 106 | 107 | @Command("who", 108 | perm="general_commands.who", 109 | doc="Lists players who are currently logged in.") 110 | async def _who(self, data, connection): 111 | """ 112 | Return a list of players currently logged in. 113 | 114 | :param data: The packet containing the command. 115 | :param connection: The connection from which the packet came. 116 | :return: Null. 117 | """ 118 | ret_list = [] 119 | for player in self.plugins['player_manager'].players_online: 120 | target = self.plugins['player_manager'].get_player_by_uuid(player) 121 | if connection.player.perm_check("general_commands.who_clientids"): 122 | ret_list.append( 123 | "[^red;{}^reset;] {}{}^reset;".format(target.client_id, 124 | target.chat_prefix, 125 | target.alias)) 126 | else: 127 | ret_list.append("{}{}^reset;".format(target.chat_prefix, 128 | target.alias)) 129 | send_message(connection, 130 | "{} players online:\n{}".format(len(ret_list), 131 | ", ".join(ret_list))) 132 | 133 | @Command("whois", 134 | perm="general_commands.whois", 135 | doc="Returns client data about the specified user.", 136 | syntax="(username)") 137 | async def _whois(self, data, connection): 138 | """ 139 | Display information about a player. 140 | 141 | :param data: The packet containing the command. 142 | :param connection: The connection from which the packet came. 143 | :return: Null. 144 | :raise: SyntaxWarning if no name provided. 145 | """ 146 | if len(data) == 0: 147 | raise SyntaxWarning("No target provided.") 148 | name = " ".join(data) 149 | info = self.plugins['player_manager'].find_player(name) 150 | if info is not None: 151 | send_message(connection, self.generate_whois(info)) 152 | else: 153 | send_message(connection, "Player not found!") 154 | 155 | @Command("give", "item", "give_item", 156 | perm="general_commands.give_item", 157 | doc="Gives an item to a player. " 158 | "If player name is omitted, give item(s) to self.", 159 | syntax=("[player=self]", "(item name)", "[count=1]")) 160 | async def _give_item(self, data, connection): 161 | """ 162 | Give item(s) to a player. 163 | 164 | :param data: The packet containing the command. 165 | :param connection: The connection from which the packet came. 166 | :return: Null. 167 | :raise: SyntaxWarning if too many arguments provided or item count 168 | cannot be properly converted. NameError if a target player 169 | cannot be resolved. 170 | """ 171 | arg_count = len(data) 172 | target = self.plugins.player_manager.find_player(data[0]) 173 | if arg_count == 1: 174 | target = connection.player 175 | item = data[0] 176 | count = 1 177 | elif arg_count == 2: 178 | if data[1].isdigit(): 179 | target = connection.player 180 | item = data[0] 181 | count = int(data[1]) 182 | else: 183 | item = data[1] 184 | count = 1 185 | elif arg_count == 3: 186 | item = data[1] 187 | if not data[2].isdigit(): 188 | raise SyntaxWarning("Couldn't convert %s to an item count." % 189 | data[2]) 190 | count = int(data[2]) 191 | else: 192 | raise SyntaxWarning("Too many arguments") 193 | if target is None: 194 | raise NameError(target) 195 | target = target.connection 196 | if count > 10000 and item != "money": 197 | count = 10000 198 | item_base = data_parser.GiveItem.build(dict(name=item, 199 | count=count, 200 | variant_type=7, 201 | description="")) 202 | item_packet = pparser.build_packet(packets.packets['give_item'], 203 | item_base) 204 | await target.raw_write(item_packet) 205 | send_message(connection, 206 | "Gave {} (count: {}) to {}".format( 207 | item, 208 | count, 209 | target.player.alias)) 210 | send_message(target, "{} gave you {} (count: {})".format( 211 | connection.player.alias, item, count)) 212 | 213 | @Command("nick", 214 | perm="general_commands.nick", 215 | doc="Changes your nickname to another one.", 216 | syntax="(username)") 217 | async def _nick(self, data, connection): 218 | """ 219 | Change your name as it is displayed in the chat window. 220 | 221 | :param data: The packet containing the command. 222 | :param connection: The connection from which the packet came. 223 | :return: Null. 224 | """ 225 | if len(data) > 1 and connection.player.perm_check( 226 | "general_commands.nick_others"): 227 | target = self.plugins.player_manager.find_player(data[0]) 228 | alias = " ".join(data[1:]) 229 | else: 230 | alias = " ".join(data) 231 | target = connection.player 232 | if len(data) == 0: 233 | alias = connection.player.name 234 | conflict = self.plugins.player_manager.get_player_by_alias(alias) 235 | if conflict and target != conflict: 236 | raise ValueError("There's already a user by that name.") 237 | else: 238 | clean_alias = self.plugins['player_manager'].clean_name(alias) 239 | if clean_alias is None: 240 | send_message(connection, 241 | "Nickname contains no valid characters.") 242 | return 243 | old_alias = target.alias 244 | target.alias = clean_alias 245 | broadcast(connection, "{}'s name has been changed to {}".format( 246 | old_alias, clean_alias)) 247 | 248 | @Command("serverwhoami", 249 | perm="general_commands.whoami", 250 | doc="Displays your current nickname for chat.") 251 | async def _whoami(self, data, connection): 252 | """ 253 | Displays your current nickname and connection information. 254 | 255 | :param data: The packet containing the command. 256 | :param connection: The connection from which the packet came. 257 | :return: Null. 258 | """ 259 | send_message(connection, 260 | self.generate_whois(connection.player)) 261 | 262 | @Command("here", 263 | perm="general_commands.here", 264 | doc="Displays all players on the same planet as you.") 265 | async def _here(self, data, connection): 266 | """ 267 | Displays all players on the same planet as the user. 268 | 269 | :param data: The packet containing the command. 270 | :param connection: The connection from which the packet came. 271 | :return: Null. 272 | """ 273 | ret_list = [] 274 | location = str(connection.player.location) 275 | for uid in self.plugins.player_manager.players_online: 276 | p = self.plugins.player_manager.get_player_by_uuid(uid) 277 | if str(p.location) == location: 278 | if connection.player.perm_check( 279 | "general_commands.who_clientids"): 280 | ret_list.append( 281 | "[^red;{}^reset;] {}{}^reset;" 282 | .format(p.client_id, 283 | p.chat_prefix, 284 | p.alias)) 285 | else: 286 | ret_list.append("{}{}^reset;".format( 287 | p.chat_prefix, p.alias)) 288 | send_message(connection, 289 | "{} players on planet:\n{}".format(len(ret_list), 290 | ", ".join(ret_list))) 291 | 292 | @Command("uptime", 293 | perm="general_commands.uptime", 294 | doc="Displays the time since the server started.") 295 | async def _uptime(self, data, connection): 296 | """ 297 | Displays the time since the server started. 298 | :param data: The packet containing the command. 299 | :param connection: The connection from which the packet came. 300 | :return: Null. 301 | """ 302 | current_time = datetime.datetime.now() - self.start_time 303 | send_message(connection, "Uptime: {}".format(current_time)) 304 | 305 | @Command("shutdown", 306 | perm="general_commands.shutdown", 307 | doc="Shutdown the server after N seconds (default 5).", 308 | syntax="[time]") 309 | async def _shutdown(self, data, connection): 310 | """ 311 | Shutdown the StarryPy server, disconnecting everyone. 312 | 313 | :param data: The packet containing the command. 314 | :param connection: The connection from which the packet came. 315 | :return: Null. 316 | """ 317 | self.logger.warning("{} has called for a shutdown.".format( 318 | connection.player.alias)) 319 | shutdown_time = 5 320 | if data: 321 | if data[0].isdigit(): 322 | shutdown_time = int(data[0]) 323 | 324 | broadcast(self, "^red;(ADMIN) The server is shutting down in {} " 325 | "seconds.^reset;".format(shutdown_time)) 326 | await asyncio.sleep(shutdown_time) 327 | # this is a noisy shutdown (makes a bit of errors in the logs). Not 328 | # sure how to make it better... 329 | self.logger.warning("Shutting down server now.") 330 | self.plugins.player_manager.sync() 331 | sys.exit() 332 | 333 | @Command("maintenance_mode", 334 | perm="general_commands.maintenance_mode", 335 | doc="Toggle maintenance mode on the server. While in " 336 | "maintenance mode, the server will reject all new " 337 | "connection.") 338 | async def _maintenance(self, data, connection): 339 | if self.maintenance: 340 | self.maintenance = False 341 | broadcast(self, "^red;NOTICE: Maintence mode disabled. " 342 | "^reset;New connections are allowed.") 343 | else: 344 | self.maintenance = True 345 | broadcast(self, "^red;NOTICE: The server is now in maintenance " 346 | "mode. ^reset;No additional clients can connect.") 347 | -------------------------------------------------------------------------------- /plugins/help.py: -------------------------------------------------------------------------------- 1 | """ 2 | StarryPy Help Plugin 3 | 4 | Provides the 'help' command in game, for displaying help/usage information on 5 | in-game commands. 6 | 7 | Original authors: AMorporkian 8 | Updated for release: kharidiron 9 | """ 10 | 11 | from base_plugin import SimpleCommandPlugin 12 | from utilities import get_syntax, Command, send_message 13 | 14 | 15 | class HelpPlugin(SimpleCommandPlugin): 16 | name = "help_plugin" 17 | depends = ["command_dispatcher"] 18 | 19 | def __init__(self): 20 | super().__init__() 21 | self.command_prefix = None 22 | self.commands = None 23 | 24 | async def activate(self): 25 | await super().activate() 26 | cd = self.plugins.command_dispatcher 27 | self.command_prefix = cd.plugin_config.command_prefix 28 | self.commands = cd.commands 29 | 30 | # Commands - In-game actions that can be performed 31 | 32 | @Command("help", 33 | perm="help.help", 34 | doc="Help command.") 35 | async def _help(self, data, connection): 36 | """ 37 | Command to provide in-game help with commands. 38 | 39 | If invoked with no arguments, lists all commands available to a player. 40 | When invoked with a trailing command, provides usage details for the 41 | command. 42 | 43 | :param data: The packet containing the command. 44 | :param connection: The connection which sent the command. 45 | :return: Null. 46 | """ 47 | if not data: 48 | commands = [] 49 | for c, f in self.commands.items(): 50 | if connection.player.perm_check(f.perm): 51 | commands.append(c) 52 | send_message(connection, 53 | "Available commands: {}".format(" ".join( 54 | [command for command in sorted(commands)]))) 55 | else: 56 | try: 57 | docstring = self.commands[data[0]].__doc__ 58 | send_message(connection, 59 | "Help for {}: {}".format(data[0], docstring), 60 | get_syntax(data[0], 61 | self.commands[data[0]], 62 | self.command_prefix)) 63 | except KeyError: 64 | self.logger.error("Help failed on command {}.".format(data[0])) 65 | send_message(connection, 66 | "Unknown command {}.".format(data[0])) 67 | -------------------------------------------------------------------------------- /plugins/mail.py: -------------------------------------------------------------------------------- 1 | """ 2 | StarryPy Mail Plugin 3 | 4 | Provides a mail system that allows users to send messages to players that 5 | are not logged in. When the recipient next logs in, they will be notified 6 | that they have new messages. 7 | 8 | Author: medeor413 9 | """ 10 | import asyncio 11 | import datetime 12 | 13 | from base_plugin import StorageCommandPlugin 14 | from utilities import Command, send_message 15 | 16 | 17 | class Mail: 18 | def __init__(self, message, author): 19 | self.message = message 20 | self.time = datetime.datetime.now() 21 | self.author = author 22 | self.unread = True 23 | 24 | 25 | class MailPlugin(StorageCommandPlugin): 26 | name = "mail" 27 | depends = ["player_manager", "command_dispatcher"] 28 | default_config = {"max_mail_storage": 25} 29 | 30 | def __init__(self): 31 | super().__init__() 32 | self.max_mail = 0 33 | self.find_player = None 34 | 35 | async def activate(self): 36 | await super().activate() 37 | self.max_mail = self.plugin_config.max_mail_storage 38 | self.find_player = self.plugins.player_manager.find_player 39 | if 'mail' not in self.storage: 40 | self.storage['mail'] = {} 41 | 42 | async def on_connect_success(self, data, connection): 43 | """ 44 | Catch when a player successfully connects to the server, and send them 45 | a new mail message. 46 | :param data: 47 | :param connection: 48 | :return: True. Must always be true so the packet continues. 49 | """ 50 | self.background(self._display_unread(connection)) 51 | return True 52 | 53 | async def _display_unread(self, connection): 54 | await asyncio.sleep(3) 55 | if connection.player.uuid not in self.storage['mail']: 56 | self.storage['mail'][connection.player.uuid] = [] 57 | mailbox = self.storage['mail'][connection.player.uuid] 58 | unread_count = len([x for x in mailbox if x.unread]) 59 | mail_count = len(mailbox) 60 | if unread_count > 0: 61 | send_message(connection, "You have {} unread messages." 62 | .format(unread_count)) 63 | if mail_count >= self.max_mail * 0.8: 64 | send_message(connection, "Your mailbox is almost full!") 65 | 66 | def send_mail(self, target, author, message): 67 | """ 68 | A convenience method for sending mail so other plugins can use the 69 | mail system easily. 70 | 71 | :param target: Player: The recipient of the message. 72 | :param author: Player: The author of the message. 73 | :param message: String: The message to be sent. 74 | :return: None. 75 | """ 76 | mail = Mail(message, author) 77 | self.storage['mail'][target.uuid].insert(0, mail) 78 | 79 | @Command("sendmail", 80 | perm="mail.sendmail", 81 | doc="Send mail to a player, to be read later.", 82 | syntax="(user) (message)") 83 | async def _sendmail(self, data, connection): 84 | if data: 85 | target = self.find_player(data[0]) 86 | if not target: 87 | raise SyntaxWarning("Couldn't find target.") 88 | if not data[1]: 89 | raise SyntaxWarning("No message provided.") 90 | uid = target.uuid 91 | if uid not in self.storage['mail']: 92 | self.storage['mail'][uid] = [] 93 | mailbox = self.storage['mail'][uid] 94 | if len(mailbox) >= self.max_mail: 95 | send_message(connection, "{}'s mailbox is full!" 96 | .format(target.alias)) 97 | else: 98 | mail = Mail(" ".join(data[1:]), connection.player) 99 | mailbox.insert(0, mail) 100 | send_message(connection, "Mail delivered to {}." 101 | .format(target.alias)) 102 | if target.logged_in: 103 | send_message(target.connection, "New mail from " 104 | "{}!" 105 | .format(connection.player.alias)) 106 | else: 107 | raise SyntaxWarning("No target provided.") 108 | 109 | @Command("readmail", 110 | perm="mail.readmail", 111 | doc="Read mail recieved from players. Give a number for a " 112 | "specific mail, or no number for all unread mails.", 113 | syntax="[index]") 114 | async def _readmail(self, data, connection): 115 | if connection.player.uuid not in self.storage['mail']: 116 | self.storage['mail'][connection.player.uuid] = [] 117 | mailbox = self.storage['mail'][connection.player.uuid] 118 | if data: 119 | try: 120 | index = int(data[0]) - 1 121 | mail = mailbox[index] 122 | mail.unread = False 123 | send_message(connection, "From {} on {}: \n{}" 124 | .format(mail.author.alias, 125 | mail.time.strftime("%d %b " 126 | "%H:%M"), 127 | mail.message)) 128 | except ValueError: 129 | send_message(connection, "Specify a valid number.") 130 | except IndexError: 131 | send_message(connection, "No mail with that " 132 | "number.") 133 | else: 134 | unread_mail = False 135 | for mail in mailbox: 136 | if mail.unread: 137 | unread_mail = True 138 | mail.unread = False 139 | send_message(connection, "From {} on {}: \n{}" 140 | .format(mail.author.alias, 141 | mail.time 142 | .strftime("%d %b %H:%M"), 143 | mail.message)) 144 | if not unread_mail: 145 | send_message(connection, "No unread mail to " 146 | "display.") 147 | 148 | @Command("listmail", 149 | perm="mail.readmail", 150 | doc="List all mail, optionally in a specified category.", 151 | syntax="[category]") 152 | async def _listmail(self, data, connection): 153 | if connection.player.uuid not in self.storage['mail']: 154 | self.storage['mail'][connection.player.uuid] = [] 155 | mailbox = self.storage['mail'][connection.player.uuid] 156 | if data: 157 | if data[0] == "unread": 158 | count = 1 159 | for mail in mailbox: 160 | if mail.unread: 161 | send_message(connection, "* {}: From " 162 | "{} on {}" 163 | .format(count, 164 | mail.author.alias, 165 | mail.time.strftime( 166 | "%d %b ""%H:%M"))) 167 | count += 1 168 | if count == 1: 169 | send_message(connection, "No unread mail in " 170 | "mailbox.") 171 | elif data[0] == "read": 172 | count = 1 173 | for mail in mailbox: 174 | if not mail.unread: 175 | send_message(connection, "{}: From {} on {}" 176 | .format(count, 177 | mail.author.alias, 178 | mail.time.strftime( 179 | "%d %b %H:%M"))) 180 | count += 1 181 | if count == 1: 182 | send_message(connection, "No read mail in " 183 | "mailbox.") 184 | else: 185 | raise SyntaxWarning("Invalid category. Valid categories are " 186 | "\"read\" and \"unread\".") 187 | else: 188 | count = 1 189 | for mail in mailbox: 190 | msg = "{}: From {} on {}".format(count, mail.author.alias, 191 | mail.time.strftime( 192 | "%d %b %H:%M")) 193 | if mail.unread: 194 | msg = "* {}".format(msg) 195 | send_message(connection, msg) 196 | count += 1 197 | if count == 1: 198 | send_message(connection, "No mail in mailbox.") 199 | 200 | @Command("delmail", 201 | perm="mail.readmail", 202 | doc="Delete unwanted mail, by index or category.", 203 | syntax="(index or category)") 204 | async def _delmail(self, data, connection): 205 | uid = connection.player.uuid 206 | if uid not in self.storage['mail']: 207 | self.storage['mail'][uid] = [] 208 | mailbox = self.storage['mail'][uid] 209 | if data: 210 | if data[0] == "all": 211 | self.storage['mail'][uid] = [] 212 | send_message(connection, "Deleted all mail.") 213 | elif data[0] == "unread": 214 | for mail in mailbox: 215 | if mail.unread: 216 | self.storage['mail'][uid].remove(mail) 217 | send_message(connection, "Deleted all unread mail.") 218 | elif data[0] == "read": 219 | for mail in mailbox: 220 | if not mail.unread: 221 | self.storage['mail'][uid].remove(mail) 222 | send_message(connection, "Deleted all read mail.") 223 | else: 224 | try: 225 | index = int(data[0]) - 1 226 | self.storage['mail'][uid].pop(index) 227 | send_message(connection, "Deleted mail {}." 228 | .format(data[0])) 229 | except ValueError: 230 | raise SyntaxWarning("Argument must be a category or " 231 | "number. Valid categories: \"read\"," 232 | " \"unread\", \"all\"") 233 | except IndexError: 234 | send_message(connection, "No message at " 235 | "that index.") 236 | else: 237 | raise SyntaxWarning("No argument provided.") -------------------------------------------------------------------------------- /plugins/motd.py: -------------------------------------------------------------------------------- 1 | """ 2 | StarryPy Message of the Day (MOTD) Plugin 3 | 4 | Provides a 'Message of the Day' text to players when the connect to the server. 5 | 6 | Ported by: kharidiron 7 | """ 8 | 9 | import asyncio 10 | 11 | from base_plugin import SimpleCommandPlugin 12 | from utilities import Command, send_message 13 | 14 | 15 | ### 16 | 17 | class MOTD(SimpleCommandPlugin): 18 | name = "motd" 19 | depends = ["command_dispatcher"] 20 | default_config = {"message": "Insert your MOTD message here. " 21 | "^red;Note^reset; color codes work."} 22 | 23 | def __init__(self): 24 | super().__init__() 25 | self.motd = None 26 | 27 | async def activate(self): 28 | await super().activate() 29 | self.motd = self.config.get_plugin_config(self.name)["message"] 30 | 31 | # Packet hooks - look for these packets and act on them 32 | 33 | async def on_connect_success(self, data, connection): 34 | """ 35 | Client successfully connected hook. If a client connects, show them the 36 | Message of the day. We have to wrap the display of the MOTD in a future 37 | so that we can delay its display by one second. Otherwise, the packet 38 | gets sent to the client before it has a chance to render it. 39 | 40 | :param data: The packet saying the client connected. 41 | :param connection: The connection from which the packet came. 42 | :return: Boolean: True. Anything else stops the client from being able 43 | to connect. 44 | """ 45 | self.background(self._display_motd(connection)) 46 | return True 47 | 48 | # Helper functions - Used by commands 49 | 50 | async def _display_motd(self, connection): 51 | """ 52 | Helper routine for displaying the MOTD on client connect. Sleeps for 53 | one second before displaying the MOTD. Do this in a non-blocking 54 | fashion. 55 | 56 | :param connection: The connection we're showing the message to. 57 | :return: Null. 58 | """ 59 | send_message(connection, "{}".format(self.motd)) 60 | 61 | # Commands - In-game actions that can be performed 62 | 63 | @Command("set_motd", 64 | perm="motd.set_motd", 65 | doc="Sets the 'Message of the Day' text.", 66 | syntax="(message text)") 67 | async def _set_motd(self, data, connection): 68 | """ 69 | Sets the 'Message of the Day' text. 70 | 71 | :param data: The packet containing the message. 72 | :param connection: The connection from which the packet came. 73 | :return: Boolean. True if successful, False if failed. 74 | """ 75 | if data: 76 | new_message = " ".join(data) 77 | self.motd = new_message 78 | self.config.update_config(self.name, {"message": new_message}) 79 | send_message(connection, "MOTD set.") 80 | return True 81 | 82 | @Command("motd", 83 | perm="motd.motd", 84 | doc="Displays the 'Message of the Day' text.") 85 | async def _motd(self, data, connection): 86 | """ 87 | Displays the 'Message of the Day' text to the requesting user. 88 | 89 | :param data: The packet containing the command. 90 | :param connection: The connection from which the packet came. 91 | :return: Null. 92 | """ 93 | send_message(connection, "{}".format(self.motd)) 94 | -------------------------------------------------------------------------------- /plugins/new_player_greeter.py: -------------------------------------------------------------------------------- 1 | """ 2 | StarryPy New Player Greeter Plugin 3 | 4 | Plugin for greeting players who are new to the server. Greets can include: 5 | - private greet message 6 | - starter items disbursement 7 | - public announcement 8 | 9 | Original authors: kharidiron 10 | """ 11 | 12 | import asyncio 13 | 14 | import packets 15 | import pparser 16 | from base_plugin import SimpleCommandPlugin 17 | from data_parser import GiveItem 18 | from utilities import send_message, ChatReceiveMode, DotDict 19 | 20 | 21 | ### 22 | 23 | class NewPlayerGreeter(SimpleCommandPlugin): 24 | name = "new_player_greeters" 25 | depends = ["player_manager"] 26 | default_config = {"greeting": "Why hello there. You look like you're " 27 | "new here. Here, take this. It should " 28 | "help you on your way.", 29 | "gifts": DotDict({ 30 | })} 31 | 32 | def __init__(self): 33 | super().__init__() 34 | self.greeting = None 35 | self.gifts = DotDict({}) 36 | 37 | async def activate(self): 38 | await super().activate() 39 | self.greeting = self.config.get_plugin_config(self.name)["greeting"] 40 | self.gifts = self.config.get_plugin_config(self.name)["gifts"] 41 | 42 | async def on_world_start(self, data, connection): 43 | """ 44 | Client on world hook. After a client connects, when their world 45 | first loads, check if they are new to the server (never been seen 46 | before). If they're new, send them a nice message and give them some 47 | starter items. 48 | 49 | :param data: The packet saying the client connected. 50 | :param connection: The connection from which the packet came. 51 | :return: Boolean: True. Anything else stops the client from being able 52 | to connect. 53 | """ 54 | player = self.plugins['player_manager'].get_player_by_name( 55 | connection.player.name) 56 | if hasattr(player, 'seen_before'): 57 | return True 58 | else: 59 | self.background(self._new_player_greeter(connection)) 60 | self.background(self._new_player_gifter(connection)) 61 | player.seen_before = True 62 | return True 63 | 64 | # Helper functions - Used by commands 65 | 66 | async def _new_player_greeter(self, connection): 67 | """ 68 | Helper routine for greeting new players. 69 | 70 | :param connection: The connection we're showing the message to. 71 | :return: Null. 72 | """ 73 | await asyncio.sleep(1.3) 74 | send_message(connection, 75 | "{}".format(self.greeting), 76 | mode=ChatReceiveMode.RADIO_MESSAGE) 77 | return 78 | 79 | async def _new_player_gifter(self, connection): 80 | """ 81 | Helper routine for giving items to new players. 82 | 83 | :param connection: The connection we're showing the message to. 84 | :return: Null. 85 | """ 86 | await asyncio.sleep(2) 87 | for item, count in self.gifts.items(): 88 | count = int(count) 89 | if count > 10000 and item != "money": 90 | count = 10000 91 | item_base = GiveItem.build(dict(name=item, 92 | count=count, 93 | variant_type=7, 94 | description="")) 95 | item_packet = pparser.build_packet(packets.packets['give_item'], 96 | item_base) 97 | await asyncio.sleep(.1) 98 | await connection.raw_write(item_packet) 99 | send_message(connection, 100 | "You have been given {} {}".format(str(count), item), 101 | mode=ChatReceiveMode.COMMAND_RESULT) 102 | return 103 | -------------------------------------------------------------------------------- /plugins/opensb_detector.py: -------------------------------------------------------------------------------- 1 | """ 2 | StarryPy OpenSB Detector Plugin 3 | 4 | Detects zstd compression for the stream and sets server configuration accordingly 5 | """ 6 | 7 | import asyncio 8 | 9 | from base_plugin import SimpleCommandPlugin 10 | from utilities import send_message, Command 11 | 12 | 13 | class OpenSBDetector(SimpleCommandPlugin): 14 | name = "opensb_detector" 15 | 16 | def __init__(self): 17 | super().__init__() 18 | 19 | async def activate(self): 20 | await super().activate() 21 | 22 | async def on_protocol_response(self, data, connection): 23 | # self.logger.debug("Received protocol response: {} from connection {}".format(data, connection)) 24 | info = data["parsed"].get("info") 25 | if info != None and info["compression"] == "Zstd": 26 | self.logger.info("Detected Zstd compression. Setting server configuration.") 27 | connection.start_zstd() 28 | return True 29 | -------------------------------------------------------------------------------- /plugins/planet_announcer.py: -------------------------------------------------------------------------------- 1 | """ 2 | StarryPy Planet Announcer Plugin 3 | 4 | Announces to all players on a world when another player enters the world. 5 | Allows Admins to set a custom greeting message on a world. 6 | 7 | Reimplemented for StarryPy3k by medeor413. 8 | """ 9 | 10 | import asyncio 11 | 12 | from base_plugin import StorageCommandPlugin 13 | from utilities import send_message, Command 14 | 15 | 16 | class PlanetAnnouncer(StorageCommandPlugin): 17 | name = "planet_announcer" 18 | depends = ["player_manager", "command_dispatcher"] 19 | 20 | def __init__(self): 21 | super().__init__() 22 | 23 | async def activate(self): 24 | await super().activate() 25 | if "greetings" not in self.storage: 26 | self.storage["greetings"] = {} 27 | 28 | async def on_world_start(self, data, connection): 29 | self.background(self._announce(connection)) 30 | return True 31 | 32 | async def _announce(self, connection): 33 | """ 34 | Announce to all players in the world when a new player beams in, 35 | and display the greeting message to the new player, if set. 36 | 37 | :param connection: The connection of the player beaming in. 38 | :return: Null. 39 | """ 40 | await asyncio.sleep(.5) 41 | location = str(connection.player.location) 42 | for uuid in self.plugins["player_manager"].players_online: 43 | p = self.plugins["player_manager"].get_player_by_uuid(uuid) 44 | if str(p.location) == location and p.connection != connection: 45 | send_message(p.connection, "{} has beamed down to the planet!" 46 | .format(connection.player.alias)) 47 | if location in self.storage["greetings"]: 48 | send_message(connection, self.storage["greetings"][location]) 49 | 50 | @Command("set_greeting", 51 | perm="planet_announcer.set_greeting", 52 | doc="Sets the greeting message to be displayed when a player " 53 | "enters this planet, or clears it if unspecified.") 54 | async def _set_greeting(self, data, connection): 55 | location = str(connection.player.location) 56 | msg = " ".join(data) 57 | if not msg: 58 | if location in self.storage["greetings"]: 59 | self.storage["greetings"].pop(location) 60 | send_message(connection, "Greeting message " 61 | "cleared.") 62 | else: 63 | self.storage["greetings"][location] = msg 64 | send_message(connection, "Greeting message set to \"{}" 65 | "\".".format(msg)) 66 | -------------------------------------------------------------------------------- /plugins/planet_backups.py.wip: -------------------------------------------------------------------------------- 1 | """ 2 | StarryPy Planet Backups Plugin 3 | 4 | Plugin to allow the backup and restoration of worlds. 5 | 6 | Original authors: kharidiron 7 | """ 8 | 9 | # TODO: This whole plugin 10 | 11 | from base_plugin import SimpleCommandPlugin 12 | from utilities import get_syntax, Command, send_message 13 | 14 | 15 | class PlanetBackups(SimpleCommandPlugin): 16 | name = "planet_backups" 17 | depends = ["command_dispatcher"] 18 | 19 | def __init__(self): 20 | super().__init__() 21 | self.command_prefix = None 22 | self.commands = None 23 | 24 | async def activate(self): 25 | await super().activate() 26 | cd = self.plugins.command_dispatcher 27 | self.command_prefix = cd.plugin_config.command_prefix 28 | self.commands = cd.commands 29 | 30 | # Commands - In-game actions that can be performed 31 | 32 | # @Command("em", 33 | # doc="Perform emote actions.") 34 | # def _emote(self, data, connection): 35 | # """ 36 | # Command to provide in-game text emotes. 37 | # 38 | # :param data: The packet containing the command. 39 | # :param connection: The connection which sent the command. 40 | # :return: Null. 41 | # """ 42 | # if not data: 43 | # # list emotes available to player 44 | # pass 45 | # else: 46 | # # perform emote 47 | # pass 48 | -------------------------------------------------------------------------------- /plugins/poi.py: -------------------------------------------------------------------------------- 1 | """ 2 | StarryPy POI Plugin 3 | 4 | Plugin to move players' ships to points of interest designated by admins. 5 | 6 | Original code by: kharidiron 7 | Reimplemented by: medeor413 8 | """ 9 | 10 | import asyncio 11 | 12 | import data_parser 13 | import pparser 14 | import packets 15 | from base_plugin import StorageCommandPlugin 16 | from utilities import Command, send_message, SystemLocationType 17 | 18 | 19 | ### 20 | 21 | class POI(StorageCommandPlugin): 22 | name = "poi" 23 | depends = ["command_dispatcher"] 24 | 25 | def __init__(self): 26 | super().__init__() 27 | 28 | async def activate(self): 29 | await super().activate() 30 | if "pois" not in self.storage: 31 | self.storage["pois"] = {} 32 | 33 | # Helper functions - Used by commands 34 | 35 | async def _move_ship(self, connection, location): 36 | """ 37 | Generate packet that moves ship. 38 | 39 | :param connection: Player being moved. 40 | :param location: The intended destination of the player. 41 | :return: Null. 42 | :raise: NotImplementedError when POI does not exist. 43 | """ 44 | if location not in self.storage["pois"]: 45 | send_message(connection, "That POI does not exist!") 46 | raise NotImplementedError 47 | else: 48 | location = self.storage["pois"][location] 49 | destination = data_parser.FlyShip.build(dict( 50 | world_x=location.x, 51 | world_y=location.y, 52 | world_z=location.z, 53 | location=dict( 54 | type=SystemLocationType.COORDINATE, 55 | world_x=location.x, 56 | world_y=location.y, 57 | world_z=location.z, 58 | world_planet=location.planet, 59 | world_satellite=location.satellite 60 | ) 61 | )) 62 | flyship_packet = pparser.build_packet(packets.packets["fly_ship"], 63 | destination) 64 | await connection.client_raw_write(flyship_packet) 65 | 66 | # Commands - In-game actions that can be performed 67 | 68 | @Command("poi", 69 | perm="poi.poi", 70 | doc="Moves a player's ship to the specified Point of Interest, " 71 | "or prints the POIs if no argument given.", 72 | syntax="[\"][POI name][\"]") 73 | async def _poi(self, data, connection): 74 | """ 75 | Move a players ship to the specified POI, free of fuel charge, 76 | no matter where they are in the universe. 77 | 78 | :param data: The packet containing the command. 79 | :param connection: The connection from which the packet came. 80 | :return: Null. 81 | """ 82 | # TODO - Or maybe not - when player already above spawn planet, 83 | # nothing happens. It would be nice to generate an alert on this case. 84 | if len(data) == 0: 85 | poi_list = (self.storage["pois"].keys()) 86 | pois = ", ".join(poi_list) 87 | send_message(connection, 88 | "Points of Interest: {}".format(pois)) 89 | return 90 | planet = connection.player.location 91 | poi = " ".join(data).lower() 92 | if planet.locationtype() != "ShipWorld" or planet.uuid \ 93 | != connection.player.uuid: 94 | send_message(connection, 95 | "You must be on your ship for this to work.") 96 | return 97 | try: 98 | await self._move_ship(connection, poi) 99 | send_message(connection, 100 | "Now en route to {}. Please stand by...".format(poi)) 101 | except NotImplementedError: 102 | pass 103 | 104 | @Command("set_poi", 105 | perm="poi.set_poi", 106 | doc="Set the planet you're on as a POI.", 107 | syntax="[\"](POI name)[\"]") 108 | async def _set_poi(self, data, connection): 109 | """ 110 | Set the current planet as a Point of Interest. Note, you must be 111 | standing on a planet for this to work. 112 | 113 | :param data: The packet containing the command. 114 | :param connection: The connection from which the packet came. 115 | :return: Null. 116 | """ 117 | planet = connection.player.location 118 | if len(data) == 0: 119 | send_message(connection, 120 | "No name for POI specified.") 121 | return 122 | poi_name = " ".join(data).lower() 123 | if poi_name in self.storage["pois"]: 124 | send_message(connection, 125 | "A POI with this name already exists!") 126 | return 127 | if not str(planet).startswith("CelestialWorld"): 128 | send_message(connection, 129 | "You must be standing on a planet for this to work.") 130 | return 131 | self.storage["pois"][poi_name] = planet 132 | send_message(connection, 133 | "POI {} added to list!".format(poi_name)) 134 | 135 | @Command("del_poi", 136 | perm="poi.set_poi", 137 | doc="Remove the specified POI from the POI list.", 138 | syntax="[\"](POI name)[\"]") 139 | async def _del_poi(self, data, connection): 140 | """ 141 | Remove the specified Point of Interest from the POI list. 142 | 143 | :param data: The packet containing the command. 144 | :param connection: The connection from which the packet came. 145 | :return: Null. 146 | """ 147 | if len(data) == 0: 148 | send_message(connection, 149 | "No POI specified.") 150 | return 151 | poi_name = " ".join(data).lower() 152 | if poi_name in self.storage["pois"]: 153 | self.storage["pois"].pop(poi_name) 154 | send_message(connection, 155 | "Deleted POI {}.".format(poi_name)) 156 | else: 157 | send_message(connection, 158 | "That POI does not exist.") 159 | return 160 | -------------------------------------------------------------------------------- /plugins/privileged_chatter.py: -------------------------------------------------------------------------------- 1 | """ 2 | StarryPy Privileged Chatter Plugin 3 | 4 | Add a number of chat commands that leverage the roles system: 5 | - Mod Chatter: lets Mods,Admins and SuperAdmins talk privately in-game 6 | - Admin Announce: command for admins to make alert-style announcements to all 7 | players 8 | - Notify Mod : Lets players send a message to Mods+, in case something needs 9 | their attention. 10 | 11 | Original authors: medeor413 12 | """ 13 | 14 | from base_plugin import SimpleCommandPlugin 15 | from utilities import Command, send_message, ChatReceiveMode, broadcast,\ 16 | link_plugin_if_available 17 | 18 | 19 | class PrivilegedChatter(SimpleCommandPlugin): 20 | name = "privileged_chatter" 21 | depends = ["command_dispatcher", "player_manager"] 22 | default_config = {"modchat_color": "^violet;", 23 | "report_prefix": "^magenta;(REPORT): ", 24 | "broadcast_prefix": "^red;(ADMIN): "} 25 | 26 | def __init__(self): 27 | super().__init__() 28 | self.modchat_color = None 29 | self.report_prefix = None 30 | self.broadcast_prefix = None 31 | self.mail = None 32 | self.discord = None 33 | self.chat_enhancements = None 34 | 35 | async def activate(self): 36 | await super().activate() 37 | self.modchat_color = self.config.get_plugin_config(self.name)[ 38 | "modchat_color"] 39 | self.report_prefix = self.config.get_plugin_config(self.name)[ 40 | "report_prefix"] 41 | self.broadcast_prefix = self.config.get_plugin_config(self.name)[ 42 | "broadcast_prefix"] 43 | if link_plugin_if_available(self, 'mail'): 44 | self.mail = self.plugins.mail 45 | if link_plugin_if_available(self, 'discord_bot'): 46 | self.discord = self.plugins.discord_bot 47 | if link_plugin_if_available(self, 'chat_enhancements'): 48 | self.chat_enhancements = self.plugins.chat_enhancements 49 | 50 | # Commands - In-game actions that can be performed 51 | 52 | @Command("modchat", "m", 53 | perm="privileged_chatter.modchat", 54 | doc="Send a message that can only be seen by other moderators.", 55 | syntax="(message)") 56 | async def _moderatorchat(self, data, connection): 57 | """ 58 | Command to send private messages between moderators. 59 | 60 | :param data: The packet containing the command. 61 | :param connection: The connection which sent the command. 62 | :return: Null. 63 | """ 64 | if data: 65 | message = " ".join(data) 66 | if self.chat_enhancements: 67 | sender = self.chat_enhancements.decorate_line(connection) 68 | else: 69 | sender = connection.player.name 70 | send_mode = ChatReceiveMode.BROADCAST 71 | channel = "" 72 | for uuid in self.plugins["player_manager"].players_online: 73 | p = self.plugins["player_manager"].get_player_by_uuid(uuid) 74 | if p.perm_check("privileged_chatter.modchat"): 75 | send_message(p.connection, 76 | "{}{}^reset;".format( 77 | self.modchat_color, message), 78 | client_id=p.client_id, 79 | name=sender, 80 | mode=send_mode, 81 | channel=channel) 82 | 83 | @Command("report", 84 | perm="privileged_chatter.report", 85 | doc="Privately make a report to all online moderators.", 86 | syntax="(message)") 87 | async def _report(self, data, connection): 88 | """ 89 | Command to send reports to moderators. 90 | 91 | :param data: The packet containing the command. 92 | :param connection: The connection which sent the command. 93 | :return: Null. 94 | """ 95 | if data: 96 | message = " ".join(data) 97 | if self.chat_enhancements: 98 | sender = self.chat_enhancements.decorate_line(connection) 99 | else: 100 | sender = connection.player.name 101 | send_mode = ChatReceiveMode.BROADCAST 102 | channel = "" 103 | mods_online = False 104 | send_message(connection, 105 | "{}{}^reset;".format( 106 | self.report_prefix, message), 107 | client_id=connection.player.client_id, 108 | name=sender, 109 | mode=send_mode, 110 | channel=channel) 111 | for uuid in self.plugins["player_manager"].players_online: 112 | p = self.plugins["player_manager"].get_player_by_uuid(uuid) 113 | if p.perm_check("privileged_chatter.modchat"): 114 | mods_online = True 115 | send_message(p.connection, 116 | "{}{}^reset;".format( 117 | self.report_prefix, message), 118 | client_id=p.client_id, 119 | name=sender, 120 | mode=send_mode, 121 | channel=channel) 122 | if self.discord: 123 | await self.discord.bot_write("**Report by {}:** {}".format( 124 | connection.player.alias, message), 125 | target=self.discord.staff_channel) 126 | # if not mods_online and self.report_mail: 127 | # self.mail.send_mail() 128 | 129 | @Command("broadcast", 130 | perm="privileged_chatter.broadcast", 131 | doc="Sends a message to everyone on the server.", 132 | syntax="(message)") 133 | async def _broadcast(self, data, connection): 134 | """ 135 | Broadcast a message to everyone on the server. Currently, this is 136 | actually redundant, as sending a message regularly is already a 137 | broadcast. 138 | 139 | :param data: The packet containing the command. 140 | :param connection: The connection from which the packet came. 141 | :return: Null. 142 | """ 143 | if data: 144 | message = self.broadcast_prefix + " ".join(data) 145 | broadcast(self, message) 146 | -------------------------------------------------------------------------------- /plugins/spawn.py: -------------------------------------------------------------------------------- 1 | """ 2 | StarryPy Spawn Plugin 3 | 4 | Plugin to move players ships to a designated 'spawn' planet. 5 | 6 | Original authors: kharidiron 7 | """ 8 | 9 | import asyncio 10 | 11 | import data_parser 12 | import pparser 13 | import packets 14 | from base_plugin import StorageCommandPlugin 15 | from utilities import Command, send_message, SystemLocationType 16 | 17 | 18 | # Roles 19 | 20 | ### 21 | 22 | class Spawn(StorageCommandPlugin): 23 | name = "spawn" 24 | depends = ["command_dispatcher"] 25 | 26 | def __init__(self): 27 | super().__init__() 28 | 29 | async def activate(self): 30 | await super().activate() 31 | if "spawn" not in self.storage: 32 | self.storage["spawn"] = {} 33 | 34 | # Helper functions - Used by commands 35 | 36 | async def _move_ship(self, connection): 37 | """ 38 | Generate packet that moves ship. 39 | 40 | :param connection: Player being moved to spawn. 41 | :return: Null. 42 | :raise: NotImplementedError when spawn planet not yet set. 43 | """ 44 | if "spawn_location" not in self.storage["spawn"]: 45 | send_message(connection, "Spawn planet not currently set.") 46 | raise NotImplementedError 47 | else: 48 | spawn_location = self.storage["spawn"]["spawn_location"] 49 | destination = data_parser.FlyShip.build(dict( 50 | world_x=spawn_location.x, 51 | world_y=spawn_location.y, 52 | world_z=spawn_location.z, 53 | location=dict( 54 | type=SystemLocationType.COORDINATE, 55 | world_x=spawn_location.x, 56 | world_y=spawn_location.y, 57 | world_z=spawn_location.z, 58 | world_planet=spawn_location.planet, 59 | world_satellite=spawn_location.satellite 60 | ) 61 | )) 62 | flyship_packet = pparser.build_packet(packets.packets["fly_ship"], 63 | destination) 64 | await connection.client_raw_write(flyship_packet) 65 | 66 | # Commands - In-game actions that can be performed 67 | 68 | @Command("spawn", 69 | perm="spawn.spawn", 70 | doc="Moves a player's ship to the spawn planet.") 71 | async def _spawn(self, data, connection): 72 | """ 73 | Move a players ship to the spawn planet, free of fuel charge, 74 | no matter where they are in the universe. 75 | 76 | :param data: The packet containing the command. 77 | :param connection: The connection from which the packet came. 78 | :return: Null. 79 | """ 80 | # TODO - Or maybe not - when player already above spawn planet, 81 | # nothing happens. It would be nice to generate an alert on this case. 82 | planet = connection.player.location 83 | if planet.locationtype() != "ShipWorld" or planet.uuid \ 84 | != connection.player.uuid: 85 | send_message(connection, 86 | "You must be on your ship for this to work.") 87 | return 88 | try: 89 | await self._move_ship(connection) 90 | send_message(connection, 91 | "Now en route to spawn. Please stand by...") 92 | except NotImplementedError: 93 | pass 94 | 95 | @Command("set_spawn", 96 | perm="spawn.set_spawn", 97 | doc="Set the spawn planet.") 98 | async def _set_spawn(self, data, connection): 99 | """ 100 | Set the current planet as the spawn plant. Note, you must be standing 101 | on a planet for this to work. 102 | 103 | :param data: The packet containing the command. 104 | :param connection: The connection from which the packet came. 105 | :return: Null. 106 | """ 107 | planet = connection.player.location 108 | if not str(planet).startswith("CelestialWorld"): 109 | send_message(connection, 110 | "You must be standing on a planet for this to work.") 111 | return 112 | self.storage["spawn"]["spawn_location"] = planet 113 | send_message(connection, "Spawn planet set to {}.".format(str(planet))) 114 | 115 | @Command("show_spawn", 116 | perm="spawn.show_spawn", 117 | doc="Print the current spawn location.") 118 | async def _show_spawn(self, data, connection): 119 | """ 120 | Display the coordinates of the current spawn location. 121 | 122 | :param data: The packet containing the command. 123 | :param connection: The connection from which the packet came. 124 | :return: Null. 125 | """ 126 | if "spawn_location" not in self.storage["spawn"]: 127 | send_message(connection, "Spawn planet not currently set.") 128 | else: 129 | spawn_location = self.storage["spawn"]["spawn_location"] 130 | send_message(connection, "{}".format(spawn_location)) 131 | -------------------------------------------------------------------------------- /plugins/species_whitelist.py: -------------------------------------------------------------------------------- 1 | """ 2 | StarryPy species whitelist plugin 3 | 4 | Prevents players with unknown species from joining the server. 5 | This is necessary due to a year+ old "bug" detailed here: 6 | https://community.playstarbound.com/threads/119569/ 7 | 8 | Original Authors: GermaniumSystem 9 | """ 10 | 11 | from base_plugin import BasePlugin 12 | from data_parser import ConnectFailure 13 | from packets import packets 14 | from pparser import build_packet 15 | 16 | 17 | 18 | class SpeciesWhitelist(BasePlugin): 19 | name = "species_whitelist" 20 | depends = ["player_manager"] 21 | default_config = {"enabled": False, 22 | "allowed_species": [ 23 | "apex", 24 | "avian", 25 | "glitch", 26 | "floran", 27 | "human", 28 | "hylotl", 29 | "penguin", 30 | "novakid" 31 | ]} 32 | 33 | 34 | async def activate(self): 35 | await super().activate() 36 | self.enabled = self.config.get_plugin_config(self.name)["enabled"] 37 | self.allowed_species = self.config.get_plugin_config(self.name)["allowed_species"] 38 | 39 | 40 | async def on_client_connect(self, data, connection): 41 | if not self.enabled: 42 | return True 43 | species = data['parsed']['species'] 44 | if species not in self.allowed_species: 45 | self.logger.warn("Aborting connection - player's species ({}) " 46 | "is not in whitelist.".format(species)) 47 | rejection_packet = build_packet(packets['connect_failure'], 48 | ConnectFailure.build(dict(reason="^red;Connection " 49 | "aborted!\n\n^orange;Your species ({}) is not " 50 | "allowed on this server.\n^green;Please use a " 51 | "different character.".format(species)))) 52 | await connection.raw_write(rejection_packet) 53 | connection.die() 54 | return False 55 | return True 56 | 57 | -------------------------------------------------------------------------------- /plugins/warp_plugin.py: -------------------------------------------------------------------------------- 1 | """ 2 | StarryPy Warp Plugin 3 | 4 | Allows Moderators to teleport to other players and their ships. 5 | 6 | Original author: ? 7 | Updated for 1.0 by medeor413 8 | """ 9 | 10 | import asyncio 11 | 12 | import packets 13 | from base_plugin import SimpleCommandPlugin 14 | from data_parser import PlayerWarp 15 | from pparser import build_packet 16 | from utilities import Command, send_message 17 | 18 | 19 | class WarpPlugin(SimpleCommandPlugin): 20 | """Plugin which provides commands related to warping.""" 21 | name = "warp_plugin" 22 | depends = ["player_manager", "command_dispatcher"] 23 | 24 | def __init__(self): 25 | super().__init__() 26 | self.find_player = None 27 | 28 | async def activate(self): 29 | await super().activate() 30 | self.find_player = self.plugins.player_manager.find_player 31 | 32 | async def warp_player_to_player(self, from_player, to_player): 33 | """ 34 | Warps a player to another player. 35 | :param from_player: Player: The player being warped. 36 | :param to_player: Player: The player being warped to. 37 | :return: None 38 | """ 39 | wp = PlayerWarp.build(dict(warp_action=dict(warp_type=2, 40 | player_id=to_player.uuid))) 41 | full = build_packet(packets.packets['player_warp'], wp) 42 | await from_player.connection.client_raw_write(full) 43 | 44 | async def warp_player_to_ship(self, from_player, to_player): 45 | """ 46 | Warps a player to another player's ship. 47 | :param from_player: Player: The player being warped. 48 | :param to_player: Player: The player whose ship is being warped to. 49 | :return: None 50 | """ 51 | wp = PlayerWarp.build(dict(warp_action=dict(warp_type=1, world_id=2, 52 | ship_id=to_player.uuid, 53 | flag=0))) 54 | full = build_packet(packets.packets['player_warp'], wp) 55 | await from_player.connection.client_raw_write(full) 56 | 57 | @Command("tp", 58 | perm="warp.tp_player", 59 | doc="Warps a player to another player.", 60 | syntax=("[from player=self]", "(to player)")) 61 | async def warp(self, data, connection): 62 | if len(data) == 1: 63 | from_player = connection.player 64 | to_player = self.find_player(data[0], check_logged_in=True) 65 | elif len(data) == 2: 66 | from_player = self.find_player(data[0], check_logged_in=True) 67 | to_player = self.find_player(data[1], check_logged_in=True) 68 | else: 69 | raise SyntaxWarning 70 | if from_player is None or to_player is None: 71 | send_message(connection, "Target is not logged in or does not " 72 | "exist.") 73 | return 74 | await self.warp_player_to_player(from_player, to_player) 75 | if from_player.alias != connection.player.alias: 76 | send_message(from_player.connection, "You've been warped to {}." 77 | .format(to_player.alias)) 78 | send_message(connection, "Warped {} to {}.".format( 79 | from_player.alias, to_player.alias)) 80 | else: 81 | send_message(connection, "Warped to {}.".format(to_player.alias)) 82 | 83 | @Command("tps", 84 | perm="warp.tp_ship", 85 | doc="Warps a player to another player's ship.", 86 | syntax=("[from player=self]", "(to player")) 87 | async def ship_warp(self, data, connection): 88 | if len(data) == 1: 89 | from_player = connection.player 90 | to_player = self.find_player(data[0], check_logged_in=True) 91 | elif len(data) == 2: 92 | from_player = self.find_player(data[0], check_logged_in=True) 93 | to_player = self.find_player(data[1], check_logged_in=True) 94 | else: 95 | raise SyntaxWarning 96 | if from_player is None or to_player is None: 97 | send_message(connection, "Target is not logged in or does not " 98 | "exist.") 99 | return 100 | await self.warp_player_to_ship(from_player, to_player) 101 | if from_player.alias != connection.player.alias: 102 | send_message(from_player.connection, "You've been warped to {}'s" 103 | " ship." 104 | .format(to_player.alias)) 105 | send_message(connection, "Warped {} to {}'s ship.".format( 106 | from_player.alias, to_player.alias)) 107 | else: 108 | send_message(connection, "Warped to {}'s ship..".format( 109 | to_player.alias)) 110 | -------------------------------------------------------------------------------- /pparser.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import traceback 3 | 4 | from configuration_manager import ConfigurationManager 5 | from data_parser import * 6 | 7 | parse_map = { 8 | 0: ProtocolRequest, 9 | 1: ProtocolResponse, 10 | 2: ServerDisconnect, 11 | 3: ConnectSuccess, 12 | 4: ConnectFailure, 13 | 5: HandshakeChallenge, 14 | 6: ChatReceived, 15 | 7: None, 16 | 8: None, 17 | 9: PlayerWarpResult, 18 | 10: None, 19 | 11: None, 20 | 12: None, 21 | 13: ClientConnect, 22 | 14: ClientDisconnectRequest, 23 | 15: None, 24 | 16: PlayerWarp, 25 | 17: FlyShip, 26 | 18: ChatSent, 27 | 19: None, 28 | 20: ClientContextUpdate, 29 | 21: WorldStart, 30 | 22: WorldStop, 31 | 23: None, 32 | 24: None, 33 | 25: None, 34 | 26: None, 35 | 27: None, 36 | 28: None, 37 | 29: None, 38 | 30: None, 39 | 31: GiveItem, 40 | 32: None, 41 | 33: None, 42 | 34: None, 43 | 35: None, 44 | 36: None, 45 | 37: None, 46 | 38: None, 47 | 39: ModifyTileList, 48 | 40: None, 49 | 41: None, 50 | 42: None, 51 | 43: SpawnEntity, 52 | 44: None, 53 | 45: None, 54 | 46: None, 55 | 47: None, 56 | 48: None, 57 | 49: None, 58 | 50: EntityCreate, 59 | 51: None, 60 | 52: None, 61 | 53: EntityInteract, 62 | 54: EntityInteractResult, 63 | 55: None, 64 | 56: DamageRequest, 65 | 57: DamageNotification, 66 | 58: EntityMessage, 67 | 59: EntityMessageResponse, 68 | 60: DictVariant, 69 | 61: StepUpdate, 70 | 62: None, 71 | 63: None, 72 | 64: None, 73 | 65: None, 74 | 66: None, 75 | 67: None, 76 | 68: None 77 | } 78 | 79 | 80 | class PacketParser: 81 | """ 82 | Object for handling the parsing and caching of packets. 83 | """ 84 | def __init__(self, config: ConfigurationManager): 85 | self._cache = {} 86 | self.config = config 87 | self.loop = asyncio.get_event_loop() 88 | self._reaper = self.loop.create_task(self._reap()) 89 | 90 | async def parse(self, packet): 91 | """ 92 | Given a packet preped packet from the stream, parse it down to its 93 | parts. First check if the packet is one we've seen before; if it is, 94 | pull its parsed form from the cache, and run with that. Otherwise, 95 | pass it to the appropriate parser for parsing. 96 | 97 | :param packet: Packet with header information parsed. 98 | :return: Fully parsed packet. 99 | """ 100 | try: 101 | if packet["size"] >= self.config.config["min_cache_size"]: 102 | packet["hash"] = hash(packet["original_data"]) 103 | if packet["hash"] in self._cache: 104 | self._cache[packet["hash"]].count += 1 105 | packet["parsed"] = self._cache[packet["hash"]].packet[ 106 | "parsed"] 107 | else: 108 | packet = await self._parse_and_cache_packet(packet) 109 | else: 110 | packet = await self._parse_packet(packet) 111 | except Exception as e: 112 | print("Error during parsing.") 113 | print(traceback.print_exc()) 114 | finally: 115 | return packet 116 | 117 | async def _reap(self): 118 | """ 119 | Prune packets from the cache that are not being used, and that are 120 | older than the "packet_reap_time". 121 | 122 | :return: None. 123 | """ 124 | while True: 125 | await asyncio.sleep(self.config.config["packet_reap_time"]) 126 | for h, cached_packet in self._cache.copy().items(): 127 | cached_packet.count -= 1 128 | if cached_packet.count <= 0: 129 | del (self._cache[h]) 130 | 131 | async def _parse_and_cache_packet(self, packet): 132 | """ 133 | Take a new packet and pass it to the parser. Once we get it back, 134 | make a copy of it to the cache. 135 | 136 | :param packet: Packet with header information parsed. 137 | :return: Fully parsed packet. 138 | """ 139 | packet = await self._parse_packet(packet) 140 | self._cache[packet["hash"]] = CachedPacket(packet=packet) 141 | return packet 142 | 143 | async def _parse_packet(self, packet): 144 | """ 145 | Parse the packet by giving it to the appropriate parser. 146 | 147 | :param packet: Packet with header information parsed. 148 | :return: Fully parsed packet. 149 | """ 150 | res = parse_map[packet["type"]] 151 | if res is None: 152 | packet["parsed"] = {} 153 | else: 154 | #packet["parsed"] = await self.loop.run_in_executor( 155 | # self.loop.executor, res.parse, packet["data"]) 156 | # Removed due to issues with testers. Need to evaluate what's going 157 | # on. 158 | packet["parsed"] = res.parse(packet["data"]) 159 | return packet 160 | 161 | # def __del__(self): 162 | # self._reaper.cancel() 163 | 164 | 165 | class CachedPacket: 166 | """ 167 | Prototype for cached packets. Keep track of how often it is used, 168 | as well as the full packet's contents. 169 | """ 170 | def __init__(self, packet): 171 | self.count = 1 172 | self.packet = packet 173 | 174 | 175 | def build_packet(packet_id, data, compressed=False): 176 | """ 177 | Convenience method for building a packet. 178 | 179 | :param packet_id: ID value of packet. 180 | :param data: Contents of packet. 181 | :param compressed: Whether or not to compress the packet. 182 | :return: Built packet object. 183 | """ 184 | return BasePacket.build({"id": packet_id, 185 | "data": data, 186 | "compressed": compressed}) 187 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohappyeyeballs==2.4.3 2 | aiohttp==3.10.10 3 | aiosignal==1.3.1 4 | async-timeout==4.0.3 5 | attrs==24.2.0 6 | charset-normalizer==3.4.0 7 | discord.py==2.4.0 8 | docopt==0.6.2 9 | frozenlist==1.5.0 10 | idna==3.10 11 | irc3==1.1.10 12 | multidict==6.1.0 13 | propcache==0.2.0 14 | venusian==3.1.0 15 | yarl==1.16.0 16 | zstandard==0.23.0 17 | -------------------------------------------------------------------------------- /server.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import sys 4 | import signal 5 | import traceback 6 | 7 | from configuration_manager import ConfigurationManager 8 | from data_parser import ChatReceived 9 | from packets import packets 10 | from pparser import build_packet 11 | from plugin_manager import PluginManager 12 | from utilities import path, read_packet, State, Direction, ChatReceiveMode 13 | from zstd_reader import ZstdFrameReader 14 | from zstd_writer import ZstdFrameWriter 15 | 16 | 17 | DEBUG = True 18 | 19 | if DEBUG: 20 | loglevel = logging.DEBUG 21 | else: 22 | loglevel = logging.INFO 23 | 24 | logger = logging.getLogger('starrypy') 25 | logger.setLevel(loglevel) 26 | 27 | class SwitchToZstdException(Exception): 28 | pass 29 | 30 | class StarryPyServer: 31 | """ 32 | Primary server class. Handles all the things. 33 | """ 34 | def __init__(self, reader, writer, config, factory): 35 | logger.debug("Initializing connection.") 36 | self._reader = ZstdFrameReader(reader, Direction.TO_SERVER) # read packets from client 37 | self._writer = ZstdFrameWriter(writer) # writes packets to client 38 | self._client_reader = None # read packets from server (acting as client) 39 | self._client_writer = None # write packets to server 40 | self.factory = factory 41 | self._client_loop_future = asyncio.create_task(self.client_loop()) 42 | self._server_loop_future = asyncio.create_task(self.server_loop()) 43 | self.state = None 44 | self._alive = True 45 | self.config = config.config 46 | self.client_ip = reader._transport.get_extra_info('peername')[0] 47 | self._server_read_future = None 48 | self._client_read_future = None 49 | self._server_write_future = None 50 | self._client_write_future = None 51 | logger.info("Received connection from {}".format(self.client_ip)) 52 | 53 | def start_zstd(self): 54 | self._reader.enable_zstd() 55 | self._client_reader.enable_zstd() 56 | self._writer.enable_zstd(skip_packets=1) # skip this packet 57 | self._client_writer.enable_zstd() 58 | logger.info("Switched to zstd") 59 | 60 | 61 | async def server_loop(self): 62 | """ 63 | Main server loop. As clients connect to the proxy, pass the 64 | connection on to the upstream server and bind it to a 'connection'. 65 | Start sniffing all packets as they fly by. 66 | 67 | :return: 68 | """ 69 | 70 | # wait until client is available 71 | while self._client_writer is None: 72 | await asyncio.sleep(0.1) 73 | 74 | try: 75 | while True: 76 | packet = await read_packet(self._reader, 77 | Direction.TO_SERVER) 78 | # Break in case of emergencies: 79 | # if packet['type'] not in [17, 40, 41, 43, 48, 51]: 80 | # logger.debug('c->s {}'.format(packet['type'])) 81 | 82 | if (await self.check_plugins(packet)): 83 | await self.write_client(packet) 84 | except asyncio.IncompleteReadError: 85 | # Pass on these errors. These occur when a player disconnects badly 86 | pass 87 | except asyncio.CancelledError: 88 | logger.warning("Connection ended abruptly.") 89 | except Exception as err: 90 | logger.error("Server loop exception occurred:" 91 | "{}: {}".format(err.__class__.__name__, err)) 92 | logger.error("Error details and traceback: {}".format(traceback.format_exc())) 93 | finally: 94 | logger.info("Server loop ended.") 95 | self.die() 96 | 97 | async def client_loop(self): 98 | """ 99 | Main client loop. Sniff packets originating from the server and bound 100 | for the clients. 101 | 102 | :return: 103 | """ 104 | (reader, writer) = await asyncio.open_connection(self.config['upstream_host'], 105 | self.config['upstream_port']) 106 | 107 | self._client_reader = ZstdFrameReader(reader, Direction.TO_CLIENT) 108 | self._client_writer = ZstdFrameWriter(writer) 109 | 110 | try: 111 | while True: 112 | packet = await read_packet(self._client_reader, 113 | Direction.TO_CLIENT) 114 | # Break in case of emergencies: 115 | # if packet['type'] not in [7, 17, 23, 27, 31, 43, 49, 51]: 116 | # logger.debug('s->c {}'.format(packet['type'])) 117 | 118 | send_flag = await self.check_plugins(packet) 119 | if send_flag: 120 | await self.write(packet) 121 | except asyncio.IncompleteReadError: 122 | logger.error("IncompleteReadError: Connection ended abruptly.") 123 | finally: 124 | self.die() 125 | 126 | async def send_message(self, message, *messages, mode=ChatReceiveMode.BROADCAST, 127 | client_id=0, name="", channel=""): 128 | """ 129 | Convenience function to send chat messages to the client. Note that 130 | this does *not* send messages to the server at large; broadcast 131 | should be used for messages to all clients, or manually constructed 132 | chat messages otherwise. 133 | 134 | :param message: message text 135 | :param messages: used if there are more that one message to be sent 136 | :param client_id: who sent the message 137 | :param name: 138 | :param channel: 139 | :param mode: 140 | :return: 141 | """ 142 | header = {"mode": mode, "channel": channel, "client_id": client_id} 143 | try: 144 | if messages: 145 | for m in messages: 146 | await self.send_message(m, 147 | mode=mode, 148 | client_id=client_id, 149 | name=name, 150 | channel=channel) 151 | if "\n" in message: 152 | for m in message.splitlines(): 153 | await self.send_message(m, 154 | mode=mode, 155 | client_id=client_id, 156 | name=name, 157 | channel=channel) 158 | return 159 | 160 | if self.state is not None and self.state >= State.CONNECTED: 161 | chat_packet = ChatReceived.build({"message": message, 162 | "name": name, 163 | "junk": 0, 164 | "header": header}) 165 | to_send = build_packet(packets['chat_received'], chat_packet) 166 | await self.raw_write(to_send) 167 | except Exception as err: 168 | logger.exception("Error while trying to send message.") 169 | logger.exception(err) 170 | 171 | async def raw_write(self, data): 172 | self._writer.write(data) 173 | await self._writer.drain() 174 | 175 | async def client_raw_write(self, data): 176 | self._client_writer.write(data) 177 | await self._client_writer.drain() 178 | 179 | async def write(self, packet): 180 | self._writer.write(packet['original_data']) 181 | await self._writer.drain() 182 | 183 | async def write_client(self, packet): 184 | await self.client_raw_write(packet['original_data']) 185 | 186 | def die(self): 187 | """ 188 | Handle closeout from player disconnecting. 189 | 190 | :return: Null. 191 | """ 192 | if self._alive: 193 | if hasattr(self, "player"): 194 | logger.info("Removing player %s.", self.player.name) 195 | else: 196 | logger.info("Removing unknown player.") 197 | self._writer.close() 198 | self._client_writer.close() 199 | self._server_loop_future.cancel() 200 | self._client_loop_future.cancel() 201 | self.factory.remove(self) 202 | self.state = State.DISCONNECTED 203 | self._alive = False 204 | 205 | async def check_plugins(self, packet): 206 | return (await self.factory.plugin_manager.do( 207 | self, 208 | packets[packet['type']], 209 | packet)) 210 | 211 | def __del__(self): 212 | try: 213 | self.die() 214 | except Exception: 215 | logger.error("An error occurred while a player was disconnecting.") 216 | 217 | 218 | class ServerFactory: 219 | def __init__(self): 220 | try: 221 | self.connections = [] 222 | self.configuration_manager = ConfigurationManager() 223 | self.configuration_manager.load_config( 224 | path / 'config' / 'config.json', 225 | default=True) 226 | self.plugin_manager = PluginManager(self.configuration_manager, 227 | factory=self) 228 | self.plugin_manager.load_from_path( 229 | path / self.configuration_manager.config.plugin_path) 230 | self.plugin_manager.resolve_dependencies() 231 | except Exception as err: 232 | logger.exception("Error during server startup.", exc_info=True) 233 | raise err 234 | 235 | async def start_plugins(self): 236 | await self.plugin_manager.activate_all() 237 | 238 | async def broadcast(self, messages, *, mode=ChatReceiveMode.RADIO_MESSAGE, 239 | **kwargs): 240 | """ 241 | Send a message to all connected clients. 242 | 243 | :param messages: Message(s) to be sent. 244 | :param mode: Mode bit of message. 245 | :return: Null. 246 | """ 247 | for connection in self.connections: 248 | try: 249 | await connection.send_message( 250 | messages, 251 | mode=mode 252 | ) 253 | except Exception as err: 254 | logger.exception("Error while trying to broadcast.") 255 | logger.exception(err) 256 | continue 257 | 258 | def remove(self, connection): 259 | """ 260 | Remove a single connection. 261 | 262 | :param connection: Connection to be removed. 263 | :return: Null. 264 | """ 265 | self.connections.remove(connection) 266 | 267 | def __call__(self, reader, writer): 268 | """ 269 | Whenever a client connects, ping the server factory to start 270 | handling it. 271 | 272 | :param reader: Reader transport socket. 273 | :param writer: Writer transport socket. 274 | :return: Null. 275 | """ 276 | server = StarryPyServer(reader, writer, self.configuration_manager, 277 | factory=self) 278 | self.connections.append(server) 279 | logger.debug("New connection established.") 280 | 281 | def kill_all(self): 282 | """ 283 | Drop all connections. 284 | 285 | :return: Null. 286 | """ 287 | logger.debug("Dropping all connections.") 288 | for connection in self.connections: 289 | connection.die() 290 | 291 | 292 | async def start_server() -> tuple[ServerFactory, asyncio.AbstractServer]: 293 | """ 294 | Main function for kicking off the server factory. 295 | 296 | :return: Server factory object. 297 | """ 298 | _server_factory = ServerFactory() 299 | await _server_factory.start_plugins() 300 | config = _server_factory.configuration_manager.config 301 | try: 302 | srv = await asyncio.start_server(_server_factory, 303 | port=config['listen_port']) 304 | except OSError as err: 305 | logger.error("Error while trying to start server.") 306 | logger.error("{}".format(str(err))) 307 | sys.exit(1) 308 | return (_server_factory, srv) 309 | 310 | 311 | async def main(): 312 | formatter = logging.Formatter( 313 | '%(asctime)s - %(levelname)s - %(name)s # %(message)s', 314 | datefmt='%Y-%m-%d %H:%M:%S') 315 | aiologger = logging.getLogger("asyncio") 316 | aiologger.setLevel(loglevel) 317 | if DEBUG: 318 | fh_d = logging.FileHandler("config/debug.log") 319 | fh_d.setLevel(loglevel) 320 | fh_d.setFormatter(formatter) 321 | aiologger.addHandler(fh_d) 322 | logger.addHandler(fh_d) 323 | ch = logging.StreamHandler() 324 | ch.setLevel(loglevel) 325 | ch.setFormatter(formatter) 326 | aiologger.addHandler(ch) 327 | logger.addHandler(ch) 328 | 329 | signal.signal(signal.SIGINT, signal.default_int_handler) 330 | signal.signal(signal.SIGTERM, signal.default_int_handler) 331 | 332 | loop = asyncio.get_event_loop() 333 | loop.set_debug(False) # Removed in commit to avoid errors. 334 | 335 | logger.info("Starting server") 336 | 337 | (server_factory, srv) = await start_server() 338 | 339 | try: 340 | await srv.serve_forever() 341 | except (KeyboardInterrupt, SystemExit): 342 | logger.warning("Exiting") 343 | except Exception as e: 344 | logger.warning('An exception occurred, exiting: {}'.format(e)) 345 | finally: 346 | logger.info("Exiting StarryPy. Shutting down all plugins.") 347 | server_factory.kill_all() 348 | await server_factory.plugin_manager.deactivate_all() 349 | #_factory.configuration_manager.save_config() # this causes changes to the config while the server is running to be overwritten. Very annoying and makes quick restart cycles impossible. 350 | aiologger.removeHandler(fh_d) 351 | aiologger.removeHandler(ch) 352 | logger.info("Finished.") 353 | 354 | if __name__ == "__main__": 355 | try: 356 | asyncio.run(main()) 357 | except KeyboardInterrupt: 358 | logger.info("Exited due to interrupt.") -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarryPy/StarryPy3k/08b1ef5a1dec95ce9b9002f3319a8619b2f6b9ae/tests/__init__.py -------------------------------------------------------------------------------- /tests/test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarryPy/StarryPy3k/08b1ef5a1dec95ce9b9002f3319a8619b2f6b9ae/tests/test/__init__.py -------------------------------------------------------------------------------- /tests/test/run_tests.py: -------------------------------------------------------------------------------- 1 | import nose 2 | import nose.config 3 | 4 | result = nose.run() -------------------------------------------------------------------------------- /tests/test/test_configuration_manager.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from nose.tools import assert_equals, assert_true, assert_raises 4 | 5 | from configuration_manager import ConfigurationManager 6 | import utilities 7 | 8 | 9 | class TestConfigurationManager: 10 | 11 | def setup(self): 12 | self.base_path = utilities.path / 'tests' / 'test_config' 13 | self.output_path = self.base_path / 'test_outputs' / 'out.json' 14 | self.trivial_config_path = self.base_path / 'trivial_config.json' 15 | self.complex_config_path = self.base_path / 'complex_config.json' 16 | self.merged_path = self.base_path / 'complex_merged_config.json' 17 | self.config_with_utf_8_path = self.base_path / 'unicode_config.json' 18 | self.config = ConfigurationManager() 19 | 20 | def test_config_manager_loads_correct_path(self): 21 | with open(str(str(self.trivial_config_path))) as f: 22 | raw = f.read() 23 | self.config.load_config(str(self.trivial_config_path)) 24 | assert_equals(raw, self.config._raw_config) 25 | 26 | def test_config_manager_path_set_on_load(self): 27 | self.config.load_config(self.trivial_config_path) 28 | assert_equals(self.config._path, self.trivial_config_path) 29 | 30 | def test_config_manager_load_complex_with_default(self): 31 | with open(str(self.merged_path)) as f: 32 | merged = json.load(f) 33 | self.config.load_config(str(self.complex_config_path), default=True) 34 | assert_equals(merged, self.config.config) 35 | 36 | def test_config_manager_dot_notation(self): 37 | complex_config_path = str((self.base_path / 'complex_config.json')) 38 | self.config.load_config(str(complex_config_path)) 39 | assert_equals(self.config.config.test1.test5.test7.test8, 40 | ["test9", "test10"]) 41 | with assert_raises(AttributeError): 42 | self.config.config.borp 43 | 44 | def test_config_manager_add_dictionary_with_dot_notation(self): 45 | complex_config_path = str((self.base_path / 'complex_config.json')) 46 | self.config.load_config(str(complex_config_path)) 47 | self.config.config.testx = {"testy": "testz"} 48 | assert_equals(self.config.config.testx.testy, "testz") 49 | 50 | def test_config_manager_save_to_path(self): 51 | self.config.load_config(str(self.complex_config_path)) 52 | self.config.save_config(path=str(self.output_path)) 53 | assert_true(self.output_path.exists(), 54 | msg="Output file does not exist.") 55 | 56 | def test_config_manager_save_utf_8(self): 57 | self.config.load_config(str(self.config_with_utf_8_path)) 58 | self.config.save_config(path=str(self.output_path)) 59 | with self.config_with_utf_8_path.open() as f: 60 | original = f.read() 61 | with self.output_path.open() as f: 62 | saved = f.read() 63 | assert_equals(original, saved) 64 | 65 | def test_config_manager_with_path_object(self): 66 | self.config.load_config(self.trivial_config_path) 67 | 68 | 69 | if __name__ == "__main__": 70 | unittest.main() -------------------------------------------------------------------------------- /tests/test/test_plugin_manager.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from nose.tools import * 4 | 5 | from plugin_manager import PluginManager 6 | from utilities import path 7 | 8 | 9 | class TestPluginManager: 10 | def __init__(self): 11 | self.plugin_path = path / 'tests' / 'test_plugins' 12 | self.good_plugin = self.plugin_path / 'test_plugin_2.py' 13 | self.good_plugin_package = self.plugin_path / 'test_plugin_package' 14 | self.bad_plugin = self.plugin_path / 'bad_plugin' 15 | self.bad_path = self.plugin_path / 'bad_path.py' 16 | self.dependent_plugins = self.plugin_path / "dependent_plugins" 17 | self.plugin_manager = PluginManager(None) 18 | self.loop = None 19 | 20 | def setup(self): 21 | self.plugin_manager = PluginManager(None) 22 | self.loop = asyncio.new_event_loop() 23 | 24 | def test_bad_paths(self): 25 | assert_raises(FileNotFoundError, 26 | self.plugin_manager._load_module, self.bad_path) 27 | 28 | def test_load_good_plugins(self): 29 | self.plugin_manager.load_plugin(self.good_plugin) 30 | self.plugin_manager.load_plugin(self.good_plugin_package) 31 | self.plugin_manager.resolve_dependencies() 32 | assert_in("test_plugin_2", 33 | self.plugin_manager.list_plugins().keys()) 34 | assert_in("test_plugin_1", 35 | self.plugin_manager.list_plugins().keys()) 36 | 37 | def test_load_bad_plugin(self): 38 | with assert_raises(SyntaxError): 39 | self.plugin_manager.load_plugin(self.bad_plugin) 40 | self.plugin_manager.resolve_dependencies() 41 | 42 | def test_load_plugin_dir(self): 43 | self.plugin_manager.load_from_path(self.plugin_path) 44 | self.plugin_manager.resolve_dependencies() 45 | assert_in("test_plugin_2", 46 | self.plugin_manager.list_plugins()) 47 | assert_in("test_plugin_1", 48 | self.plugin_manager.list_plugins()) 49 | assert_in("bad_plugin", 50 | self.plugin_manager.failed) 51 | 52 | def test_the_do_method(self): 53 | self.plugin_manager.load_plugin(self.good_plugin) 54 | self.plugin_manager.load_plugin(self.good_plugin_package) 55 | self.plugin_manager.resolve_dependencies() 56 | result = self.loop.run_until_complete( 57 | self.plugin_manager.do("chat_sent", b"")) 58 | assert_equals(result, True) 59 | 60 | def test_dependency_check(self): 61 | with assert_raises(ImportError): 62 | self.plugin_manager.load_plugin(self.dependent_plugins / 'b.py') 63 | self.plugin_manager.resolve_dependencies() 64 | 65 | def test_dependency_resolution(self): 66 | self.plugin_manager.load_plugins([ 67 | self.dependent_plugins / 'a.py', 68 | self.dependent_plugins / 'b.py' 69 | ]) 70 | 71 | self.plugin_manager.resolve_dependencies() 72 | 73 | def test_circular_dependency_error(self): 74 | with assert_raises(ImportError): 75 | self.plugin_manager.load_plugin( 76 | self.dependent_plugins / 'circular.py') 77 | self.plugin_manager.resolve_dependencies() 78 | 79 | def test_empty_overrides(self): 80 | self.plugin_manager.resolve_dependencies() 81 | owners = self.loop.run_until_complete( 82 | self.plugin_manager.get_overrides()) 83 | assert_equal(owners, set()) 84 | 85 | def test_override(self): 86 | self.plugin_manager.load_plugin( 87 | self.plugin_path / 'test_plugin_package') 88 | self.plugin_manager.load_plugin(self.plugin_path / 'test_plugin_2.py') 89 | self.plugin_manager.resolve_dependencies() 90 | self.plugin_manager.activate_all() 91 | overrides = self.loop.run_until_complete( 92 | self.plugin_manager.get_overrides()) 93 | assert_equal(overrides, {'on_chat_sent'}) 94 | 95 | def test_override_caching(self): 96 | self.plugin_manager.load_plugin(self.plugin_path / 'test_plugin_2.py') 97 | assert_equal(self.plugin_manager._overrides, set()) 98 | assert_equal(self.plugin_manager._override_cache, set()) 99 | self.plugin_manager.activate_all() 100 | self.loop.run_until_complete(self.plugin_manager.get_overrides()) 101 | assert_is(self.plugin_manager._override_cache, 102 | self.plugin_manager._activated_plugins) 103 | cache = self.plugin_manager._override_cache 104 | self.loop.run_until_complete(self.plugin_manager.get_overrides()) 105 | assert_is(self.plugin_manager._override_cache, cache) 106 | 107 | 108 | def test_activate(self): 109 | self.plugin_manager.load_plugin( 110 | self.plugin_path / 'test_plugin_package') 111 | self.plugin_manager.load_plugin(self.plugin_path / 'test_plugin_2.py') 112 | self.plugin_manager.resolve_dependencies() 113 | self.plugin_manager.activate_all() 114 | assert_equal({x.name for x in self.plugin_manager._activated_plugins}, 115 | {'test_plugin_1', 'test_plugin_2'}) 116 | 117 | 118 | -------------------------------------------------------------------------------- /tests/test/test_server.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from server import ServerFactory 4 | 5 | 6 | async def start_server(): 7 | serverf = ServerFactory() 8 | await asyncio.start_server(serverf, '127.0.0.1', 21025) 9 | 10 | 11 | class TestServer: 12 | def setUp(self): 13 | self.loop = asyncio.get_event_loop() 14 | 15 | def tearDown(self): 16 | self.loop.stop() 17 | 18 | def testTest(self): 19 | asyncio.ensure_future(self.beep()) 20 | 21 | async def beep(self): 22 | x = await (lambda _: True)("") 23 | return x -------------------------------------------------------------------------------- /tests/test_config/complex_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "test1": { 3 | "test5": { 4 | "test7": { 5 | "test8": [ 6 | "test9", 7 | "test10" 8 | ] 9 | }, 10 | "test6": "here's another" 11 | }, 12 | "test4": [ 13 | "a", 14 | "b", 15 | "c" 16 | ], 17 | "test2": "should be retained", 18 | "test3": "this also" 19 | } 20 | } -------------------------------------------------------------------------------- /tests/test_config/complex_config.json.default: -------------------------------------------------------------------------------- 1 | { 2 | "test1": { 3 | "test2": "shouldn't be there", 4 | "test3": "or this", 5 | "test4": [ 6 | "z", 7 | "y", 8 | "x" 9 | ], 10 | "test5": { 11 | "test6": "here's another bad one", 12 | "test7": { 13 | "test10": [ 14 | "test93", 15 | "test82" 16 | ] 17 | } 18 | } 19 | } 20 | } -------------------------------------------------------------------------------- /tests/test_config/complex_merged_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "test1": { 3 | "test2": "should be retained", 4 | "test3": "this also", 5 | "test4": [ 6 | "a", 7 | "b", 8 | "c" 9 | ], 10 | "test5": { 11 | "test6": "here's another", 12 | "test7": { 13 | "test8": [ 14 | "test9", 15 | "test10" 16 | ], 17 | "test10": [ 18 | "test93", 19 | "test82" 20 | ] 21 | } 22 | } 23 | } 24 | } -------------------------------------------------------------------------------- /tests/test_config/trivial_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "test": "this is a good test" 3 | } -------------------------------------------------------------------------------- /tests/test_config/trivial_config.json.default: -------------------------------------------------------------------------------- 1 | { 2 | "test": "this shouldn't be in the merge", 3 | "test2": "beep" 4 | } -------------------------------------------------------------------------------- /tests/test_config/trivial_merged_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "test": "this is a good test", 3 | "test2": "beep" 4 | } -------------------------------------------------------------------------------- /tests/test_config/unicode_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "hebrew": "אבגדהוזח", 3 | "japanese": "まみむめも", 4 | "russian": "эль" 5 | } -------------------------------------------------------------------------------- /tests/test_plugins/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarryPy/StarryPy3k/08b1ef5a1dec95ce9b9002f3319a8619b2f6b9ae/tests/test_plugins/__init__.py -------------------------------------------------------------------------------- /tests/test_plugins/bad_plugin/__init__.py: -------------------------------------------------------------------------------- 1 | from tests.test_plugins.bad_plugin.bad_plugin import BadPlugin -------------------------------------------------------------------------------- /tests/test_plugins/bad_plugin/bad_plugin.py: -------------------------------------------------------------------------------- 1 | from base_plugin import BasePlugin 2 | 3 | 4 | class BadPlugin(BasePlugin): 5 | name = '' 6 | -------------------------------------------------------------------------------- /tests/test_plugins/dependent_plugins/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarryPy/StarryPy3k/08b1ef5a1dec95ce9b9002f3319a8619b2f6b9ae/tests/test_plugins/dependent_plugins/__init__.py -------------------------------------------------------------------------------- /tests/test_plugins/dependent_plugins/a.py: -------------------------------------------------------------------------------- 1 | from base_plugin import BasePlugin 2 | 3 | 4 | class A(BasePlugin): 5 | name = "a" -------------------------------------------------------------------------------- /tests/test_plugins/dependent_plugins/b.py: -------------------------------------------------------------------------------- 1 | from base_plugin import BasePlugin 2 | 3 | 4 | class B(BasePlugin): 5 | name = "b" 6 | depends = ["a"] -------------------------------------------------------------------------------- /tests/test_plugins/dependent_plugins/circular.py: -------------------------------------------------------------------------------- 1 | from base_plugin import BasePlugin 2 | 3 | 4 | class A(BasePlugin): 5 | name = "a" 6 | depends = ["b"] 7 | 8 | 9 | class B(BasePlugin): 10 | name = "b" 11 | depends = ["c"] 12 | 13 | 14 | class C(BasePlugin): 15 | name = "c" 16 | depends = ["a"] 17 | -------------------------------------------------------------------------------- /tests/test_plugins/test_plugin_2.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from base_plugin import BasePlugin 4 | 5 | 6 | class TestPlugin(BasePlugin): 7 | name = "test_plugin_2" 8 | 9 | async def on_chat_sent(self, data): 10 | return True -------------------------------------------------------------------------------- /tests/test_plugins/test_plugin_package/__init__.py: -------------------------------------------------------------------------------- 1 | from tests.test_plugins.test_plugin_package.plugin import GoodPlugin -------------------------------------------------------------------------------- /tests/test_plugins/test_plugin_package/plugin.py: -------------------------------------------------------------------------------- 1 | from base_plugin import BasePlugin 2 | 3 | 4 | class GoodPlugin(BasePlugin): 5 | name = "test_plugin_1" -------------------------------------------------------------------------------- /utilities.py: -------------------------------------------------------------------------------- 1 | """ 2 | StarryPy Utilities 3 | 4 | Provides a collection of commonly used utility objects, functions and classes 5 | that can be utilized. Boilerplate = bad. 6 | 7 | Original authors: AMorporkian 8 | Updated for release: kharidiron 9 | """ 10 | 11 | import asyncio 12 | import collections 13 | import io 14 | import re 15 | import zlib 16 | import dbm 17 | from enum import IntEnum 18 | from pathlib import Path 19 | from types import FunctionType 20 | from shelve import Shelf, _ClosedDict 21 | from pickle import Pickler, Unpickler 22 | from collections import abc 23 | 24 | path = Path(__file__).parent 25 | background_tasks = set() 26 | 27 | # Enums 28 | 29 | class State(IntEnum): 30 | DISCONNECTED = 0 31 | VERSION_SENT = 1 32 | CLIENT_CONNECT_RECEIVED = 2 33 | HANDSHAKE_CHALLENGE_SENT = 3 34 | HANDSHAKE_RESPONSE_RECEIVED = 4 35 | CONNECT_RESPONSE_SENT = 5 36 | CONNECTED = 6 37 | CONNECTED_WITH_HEARTBEAT = 7 38 | 39 | 40 | class Direction(IntEnum): 41 | TO_CLIENT = 0 42 | TO_SERVER = 1 43 | 44 | 45 | class WarpType(IntEnum): 46 | TO_WORLD = 1 47 | TO_PLAYER = 2 48 | TO_ALIAS = 3 49 | 50 | 51 | class WarpWorldType(IntEnum): 52 | CELESTIAL_WORLD = 1 53 | PLAYER_WORLD = 2 54 | UNIQUE_WORLD = 3 55 | MISSION_WORLD = 4 56 | 57 | 58 | class WarpAliasType(IntEnum): 59 | RETURN = 0 60 | ORBITED = 1 61 | SHIP = 2 62 | 63 | 64 | class ChatSendMode(IntEnum): 65 | UNIVERSE = 0 66 | LOCAL = 1 67 | PARTY = 2 68 | 69 | 70 | class ChatReceiveMode(IntEnum): 71 | LOCAL = 0 72 | PARTY = 1 73 | BROADCAST = 2 74 | WHISPER = 3 75 | COMMAND_RESULT = 4 76 | RADIO_MESSAGE = 5 77 | WORLD = 6 78 | 79 | 80 | class SystemLocationType(IntEnum): 81 | SYSTEM = 0 82 | COORDINATE = 1 83 | ORBIT = 2 84 | UUID = 3 85 | LOCATION = 4 86 | 87 | 88 | class DamageType(IntEnum): 89 | NO_DAMAGE = 0 # Assumed 90 | DAMAGE = 1 91 | IGNORES_DEF = 2 92 | KNOCKBACK = 3 93 | ENVIRONMENT = 4 94 | 95 | 96 | class DamageHitType(IntEnum): 97 | NORMAL = 0 98 | STRONG = 1 99 | WEAK = 2 100 | SHIELD = 3 101 | KILL = 4 102 | 103 | class EntityInteractionType(IntEnum): 104 | NOMINAL = 0 105 | OPEN_CONTAINER_UI = 1 106 | GO_PRONE = 2 107 | OPEN_CRAFTING_UI = 3 108 | OPEN_NPC_UI = 6 109 | OPEN_SAIL_UI = 7 110 | OPEN_TELEPORTER_UI = 8 111 | OPEN_SCRIPTED_UI = 10 112 | OPEN_SPECIAL_UI = 11 113 | OPEN_CREW_UI = 12 114 | 115 | 116 | class EntitySpawnType(IntEnum): 117 | PLANT = 0 118 | OBJECT = 1 119 | VEHICLE = 2 120 | ITEM_DROP = 3 121 | PLANT_DROP = 4 122 | PROJECTILE = 5 123 | STAGEHAND = 6 124 | MONSTER = 7 125 | NPC = 8 126 | PLAYER = 9 127 | 128 | 129 | # Useful things 130 | 131 | def recursive_dictionary_update(d, u): 132 | """ 133 | Given two dictionaries, update the first one with new values provided by 134 | the second. Works for nested dictionary sets. 135 | 136 | :param d: First Dictionary, to base off of. 137 | :param u: Second Dictionary, to provide updated values. 138 | :return: Dictionary. Merged dictionary with bias towards the second. 139 | """ 140 | for k, v in u.items(): 141 | if isinstance(v, abc.Mapping): 142 | r = recursive_dictionary_update(d.get(k, {}), v) 143 | d[k] = r 144 | else: 145 | d[k] = u[k] 146 | return d 147 | 148 | 149 | class DotDict(dict): 150 | """ 151 | Custom dictionary format that allows member access by using dot notation: 152 | eg - dict.key.subkey 153 | """ 154 | 155 | def __init__(self, d, **kwargs): 156 | super().__init__(**kwargs) 157 | for k, v in d.items(): 158 | if isinstance(v, abc.Mapping): 159 | v = DotDict(v) 160 | self[k] = v 161 | 162 | def __getattr__(self, item): 163 | try: 164 | return super().__getitem__(item) 165 | except KeyError as e: 166 | raise AttributeError(str(e)) from None 167 | 168 | def __setattr__(self, key, value): 169 | if isinstance(value, abc.Mapping): 170 | value = DotDict(value) 171 | super().__setitem__(key, value) 172 | 173 | __delattr__ = dict.__delitem__ 174 | 175 | 176 | async def detect_overrides(cls, obj): 177 | """ 178 | For each active plugin, check if it wield a packet hook. If it does, add 179 | make a not of it. Hand back all hooks for a specific packet type when done. 180 | """ 181 | res = set() 182 | for key, value in cls.__dict__.items(): 183 | if isinstance(value, classmethod): 184 | value = getattr(cls, key).__func__ 185 | if isinstance(value, (FunctionType, classmethod)): 186 | meth = getattr(obj, key) 187 | if meth.__func__ is not value: 188 | res.add(key) 189 | return res 190 | 191 | 192 | class BiDict(dict): 193 | """ 194 | A case-insensitive bidirectional dictionary that supports integers. 195 | """ 196 | def __init__(self, d, **kwargs): 197 | super().__init__(**kwargs) 198 | for k, v in d.items(): 199 | self[k] = v 200 | 201 | def __setitem__(self, key, value): 202 | if key in self: 203 | del self[key] 204 | if value in self: 205 | del self[value] 206 | super().__setitem__(str(key), str(value)) 207 | super().__setitem__(str(value), str(key)) 208 | 209 | def __getitem__(self, item): 210 | if isinstance(item, int): 211 | key = str(item) 212 | else: 213 | key = item 214 | res = super().__getitem__(key) 215 | if res.isdigit(): 216 | res = int(res) 217 | return res 218 | 219 | def __delitem__(self, key): 220 | super().__delitem__(self[key]) 221 | super().__delitem__(key) 222 | 223 | 224 | class AsyncBytesIO(io.BytesIO): 225 | """ 226 | This class just wraps a normal BytesIO.read() in a coroutine to make it 227 | easier to interface with functions designed to work on coroutines without 228 | having to monkey around with a type check and extra futures. 229 | """ 230 | async def read(self, *args, **kwargs): 231 | return super().read(*args, **kwargs) 232 | 233 | 234 | class Cupboard(Shelf): 235 | """ 236 | Custom Shelf implementation that only pickles values at save-time. 237 | Increases save/load times, decreases get/set item times. 238 | More suitable for use as a savable dictionary. 239 | """ 240 | def __init__(self, filename, flag='c', protocol=None, keyencoding='utf-8'): 241 | self.db = filename 242 | self.flag = flag 243 | self.dict = {} 244 | with dbm.open(self.db, self.flag) as db: 245 | for k in db.keys(): 246 | v = io.BytesIO(db[k]) 247 | self.dict[k] = Unpickler(v).load() 248 | Shelf.__init__(self, self.dict, protocol, False, keyencoding) 249 | 250 | def __getitem__(self, key): 251 | return self.dict[key.encode(self.keyencoding)] 252 | 253 | def __setitem__(self, key, value): 254 | self.dict[key.encode(self.keyencoding)] = value 255 | 256 | def __delitem__(self, key): 257 | del self.dict[key.encode(self.keyencoding)] 258 | 259 | def sync(self): 260 | res = {} 261 | with dbm.open(self.db, self.flag) as db: 262 | for k, v in self.dict.items(): 263 | f = io.BytesIO() 264 | p = Pickler(f, protocol=self._protocol) 265 | p.dump(v) 266 | db[k] = f.getvalue() 267 | try: 268 | db.sync() 269 | except AttributeError: 270 | pass 271 | 272 | def close(self): 273 | if(self.dict is None): 274 | return 275 | if isinstance(self.dict, _ClosedDict): 276 | # don't re-close, just exit 277 | return 278 | try: 279 | self.sync() 280 | finally: 281 | try: 282 | self.dict = _ClosedDict() 283 | except: 284 | self.dict = None 285 | 286 | def __del__(self): 287 | self.close() 288 | 289 | async def read_vlq(bytestream): 290 | """ 291 | Give a bytestream, extract the leading Variable Length Quantity (VLQ). 292 | 293 | We have to do this as a stream, since we don't know how long a VLQ is 294 | until we observe its end. 295 | """ 296 | d = b"" 297 | v = 0 298 | while True: 299 | tmp = await bytestream.readexactly(1) 300 | d += tmp 301 | tmp = ord(tmp) 302 | v <<= 7 303 | v |= tmp & 0x7f 304 | 305 | if tmp & 0x80 == 0: 306 | break 307 | return v, d 308 | 309 | 310 | async def read_signed_vlq(reader): 311 | """ 312 | Manipulate the read-in VLQ to account for signed-ness. 313 | """ 314 | v, d = await read_vlq(reader) 315 | if (v & 1) == 0x00: 316 | return v >> 1, d 317 | else: 318 | return -((v >> 1) + 1), d 319 | 320 | 321 | def extractor(*args): 322 | """ 323 | Extracts quoted arguments and puts them as a single argument in the 324 | passed iterator. 325 | """ 326 | # It's not elegant, but it's the best way to do it as far as I can tell. 327 | # My regex-fu isn't strong though, so if someone can come up with a 328 | # better way, great. 329 | x = re.split(r"(?:([^\"]\S*)|\"(.+?)(?= count: 25 | # print (f"Returning {count} bytes from buffer {self.direction}") 26 | return self.outputbuffer.read(count) 27 | 28 | # print(f"Reading from network since there are only {self.remaining} bytes in buffer") 29 | await self.read_from_network(count) 30 | 31 | async def read_from_network(self, target_count): 32 | while self.outputbuffer.remaining() < target_count: 33 | 34 | chunk = await self.raw_reader.read(32768) # Read in chunks; we'll only get what's available 35 | # print(f"Read {len(chunk)} bytes from network") 36 | if not chunk: 37 | raise asyncio.CancelledError("Connection closed") 38 | if not self.zstd_enabled: 39 | self.outputbuffer.write(chunk) 40 | else: 41 | try: 42 | self.decompressor.write(chunk) 43 | except zstd.ZstdError: 44 | print("Zstd error, dropping connection") 45 | raise asyncio.CancelledError("Error in compressed data stream!") 46 | 47 | class NonSeekableMemoryStream(io.RawIOBase): 48 | def __init__(self): 49 | self.buffer = bytearray() 50 | self.read_pos = 0 51 | self.write_pos = 0 52 | 53 | def write(self, b): 54 | self.buffer.extend(b) 55 | self.write_pos += len(b) 56 | return len(b) 57 | 58 | def read(self, size=-1): 59 | if size == -1 or size > self.write_pos - self.read_pos: 60 | size = self.write_pos - self.read_pos 61 | if size == 0: 62 | return b'' 63 | data = self.buffer[self.read_pos:self.read_pos + size] 64 | self.read_pos += size 65 | if self.read_pos == self.write_pos: 66 | self.buffer = bytearray() 67 | self.read_pos = 0 68 | self.write_pos = 0 69 | return bytes(data) 70 | 71 | def remaining(self): 72 | return self.write_pos - self.read_pos 73 | 74 | def readable(self): 75 | return True 76 | 77 | def writable(self): 78 | return True -------------------------------------------------------------------------------- /zstd_writer.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from io import BufferedReader, BytesIO 3 | import zstandard as zstd 4 | 5 | class ZstdFrameWriter: 6 | def __init__(self, raw_writer: asyncio.StreamWriter): 7 | self.compressor = zstd.ZstdCompressor() 8 | self.raw_writer = raw_writer 9 | self.skip_packets = 0 10 | self.zstd_enabled = False 11 | 12 | def enable_zstd(self, skip_packets=0): 13 | self.zstd_enabled = True 14 | self.skip_packets = skip_packets 15 | 16 | async def drain(self): 17 | await self.raw_writer.drain() 18 | 19 | def close(self): 20 | self.raw_writer.close() 21 | self.compressor = None 22 | 23 | def write(self, data): 24 | 25 | if not self.zstd_enabled: 26 | self.raw_writer.write(data) 27 | return 28 | 29 | if self.skip_packets > 0: 30 | self.skip_packets -= 1 31 | self.raw_writer.write(data) 32 | return 33 | 34 | self.raw_writer.write(self.compressor.compress(data)) --------------------------------------------------------------------------------