├── .github ├── ISSUE_TEMPLATE.md └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── CONTRIBUTORS.md ├── INSTALL.md ├── LICENSE ├── README.md ├── assets ├── action.gif └── terminal.png ├── cactusbot ├── __init__.py ├── api.py ├── cactus.py ├── commands │ ├── __init__.py │ ├── command.py │ └── magic │ │ ├── __init__.py │ │ ├── alias.py │ │ ├── cactus.py │ │ ├── command.py │ │ ├── config.py │ │ ├── cube.py │ │ ├── multi.py │ │ ├── quote.py │ │ ├── repeat.py │ │ ├── social.py │ │ ├── trust.py │ │ └── uptime.py ├── handler.py ├── handlers │ ├── __init__.py │ ├── command.py │ ├── events.py │ ├── logging.py │ ├── respond.py │ └── spam.py ├── packets │ ├── __init__.py │ ├── ban.py │ ├── event.py │ ├── message.py │ └── packet.py ├── sepal.py └── services │ ├── __init__.py │ ├── api.py │ ├── beam │ ├── __init__.py │ ├── api.py │ ├── chat.py │ ├── constellation.py │ ├── emoji.json │ ├── handler.py │ └── parser.py │ └── websocket.py ├── config.template.py ├── docs ├── conf.py ├── contents.rst ├── developer │ ├── commands.rst │ ├── handlers.rst │ └── packets.rst ├── header.png ├── index.md └── user │ ├── alias.md │ ├── command.md │ ├── config.md │ ├── multi.md │ ├── quote.md │ ├── repeat.md │ ├── social.md │ ├── trust.md │ └── variables.md ├── environment.yml ├── readthedocs.yml ├── requirements.txt ├── run.py ├── setup.py └── tests ├── commands ├── test_alias.py ├── test_cactus.py ├── test_command_command.py ├── test_cube.py ├── test_multi.py └── test_trust.py ├── handlers ├── test_command.py ├── test_events.py ├── test_respond.py └── test_spam.py ├── packets ├── test_ban.py └── test_message.py └── services └── test_beam.py /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## What is the issue? 2 | 3 | ## How do we reproduce this issue? 4 | 5 | ## Environment 6 | 7 | - [ ] Self-hosted 8 | - [ ] Python 3.5 9 | 10 | ## Other Information 11 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## What does this change? 2 | 3 | ## Requirements 4 | 5 | - [ ] Only PG-rated language used (code, commits, etc.) 6 | - [ ] Descriptive commit messages 7 | - [ ] Changes have been tested 8 | - [ ] Changes do not break any existing functionalities 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask instance folder 57 | instance/ 58 | 59 | # Sphinx documentation 60 | docs/_build/ 61 | 62 | # PyBuilder 63 | target/ 64 | 65 | # IPython Notebook 66 | .ipynb_checkpoints 67 | 68 | # pyenv 69 | .python-version 70 | 71 | # Virtualenv 72 | venv/ 73 | 74 | # CactusBot private data 75 | /config.py 76 | data/ 77 | caches/*.json 78 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - 3.5 4 | branches: 5 | only: 6 | - master 7 | - develop 8 | - /^release-v(\d+.){0,2}\d+$/ 9 | before_install: 10 | - pip install flake8 11 | - pip install pytest pytest-asyncio --upgrade 12 | install: 13 | - pip install -r requirements.txt 14 | env: 15 | - PYTHONPATH=. 16 | before_script: 17 | - cp config.template.py config.py 18 | script: 19 | - nosetests 20 | - flake8 run.py config.template.py cactusbot/ 21 | - pytest tests/ 22 | - pytest cactusbot/ --doctest-modules 23 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | ## Added 3 | - Command enable / disable 4 | - Command aliases with `!alias` ([documentation](http://cactusbot.readthedocs.io/en/stable/user/alias.html)) 5 | - Command modifiers ([documentation](http://cactusbot.readthedocs.io/en/stable/user/variables.html#modifiers)) 6 | - Better `!cactus` command: now with helpfulness! 7 | - Absolutely no easter eggs anywhere... 8 | - `!multi` command ([documentation](http://cactusbot.readthedocs.io/en/stable/user/multi.html)) 9 | - Custom `!social` storage ([documentation](http://cactusbot.readthedocs.io/en/stable/user/social.html)) 10 | - Better [documenation](http://cactusbot.readthedocs.io/en/stable/)! 11 | 12 | ## Fixed 13 | - Repeat system 14 | 15 | ## Changed 16 | - *Every single line of code* 17 | - `!config` command ([documentation](http://cactusbot.readthedocs.io/en/stable/user/config.html)) 18 | - Made all variables `%UPPERCASE%` 19 | - "targets" renamed to "variables" 20 | - `!friend` command renamed to `!trust` 21 | 22 | ## Removed 23 | - v0.3 nonsense™ 24 | - `!test` command 25 | 26 | ## [0.3.6](https://github.com/CactusDev/CactusBot/releases/tag/v0.3.6) 27 | 28 | #### Released: October 1st, 2016 29 | 30 | ### Fixed 31 | - Friend command regex (again) 32 | 33 | ### Added 34 | - Constellation support 35 | 36 | ### Removed 37 | - Liveloading 38 | 39 | ## [0.3.5](https://github.com/CactusDev/CactusBot/releases/tag/v0.3.5) 40 | 41 | #### Released: September 2nd, 2016 42 | 43 | ### Fixed 44 | - `!friend` command regex 45 | - Websocket reconnections 46 | - Deleting own links 47 | 48 | ### Removed 49 | - Unused statistics 50 | 51 | ## [0.3.4](https://github.com/CactusDev/CactusBot/releases/tag/v0.3.4) 52 | 53 | #### Released: July 31st, 2016 54 | 55 | ### Fixed 56 | - Beam CSRF token usage. 57 | - Message removal 58 | - `!repeat` command 59 | 60 | ## [0.3.3](https://github.com/CactusDev/CactusBot/releases/tag/v0.3.3) 61 | 62 | #### Released: July 21st, 2016 63 | 64 | ### Added 65 | - Documentation is in the bot now 66 | 67 | ### Fixed 68 | - Repeat command 69 | - Cube command crashing when no arguments are present 70 | - Social command 71 | 72 | ## [0.3.2](https://github.com/CactusDev/CactusBot/releases/tag/v0.3.2) 73 | 74 | #### Released: July 16th, 2016 75 | 76 | ### Added 77 | - User loading into the database on bot start 78 | - Hosting alerts 79 | 80 | ### Fixed 81 | - Exponential-backoff for chat connections 82 | - Liveloading connection now only happens once 83 | - Follow alerts spam 84 | - `spamprot` command crashing when no arguments are present 85 | - Friend command not allowing for `@` 86 | 87 | ### Changed 88 | - `silent` is now `quiet` 89 | 90 | ## [0.3.1](https://github.com/CactusDev/CactusBot/releases/tag/v0.3.1) 91 | 92 | #### Released: April 10th, 2016 93 | 94 | ### Added 95 | - Uptime command 96 | - Multi-message responses using `\n` 97 | - `beam` as a social argument 98 | 99 | ### Fixed 100 | - Ghost columns in the database 101 | - Autorestart conditions 102 | - `repeat` crash on command removal 103 | - Whispered responses 104 | - Message removal 105 | 106 | ### Changed 107 | - Command prefix limit to 1 108 | 109 | ## [0.3.0](https://github.com/CactusDev/CactusBot/releases/tag/v0.3) 110 | 111 | #### Released: April 9th, 2016 112 | 113 | ### Added 114 | - Repeating commands 115 | - Following and subscriptions notifications 116 | - User permissions for custom commands 117 | - Command line arguments for `silent` and `debug` 118 | 119 | ### Fixed 120 | - Reconnection spam 121 | - Random disconnections 122 | - `!cube`ing emotes 123 | 124 | ### Changed 125 | - Move from `asyncio` to `tornado` 126 | 127 | ## [0.2.1](https://github.com/CactusDev/CactusBot/releases/tag/v0.2.1) 128 | 129 | #### Released: March 27th, 2016 130 | 131 | ### Fixed 132 | - Spam protection command 133 | 134 | ### Disable 135 | - Statistics tracking 136 | 137 | ### Added 138 | - `autorestart` to config 139 | 140 | ## [0.2.0](https://github.com/CactusDev/CactusBot/releases/tag/v0.2) 141 | 142 | #### Released: March 27th, 2016 143 | 144 | ### Added 145 | - Command permissions 146 | - Spam protection 147 | 148 | ### Changed 149 | - Config organizations 150 | 151 | ## [0.1.0](https://github.com/CactusDev/CactusBot/releases/tag/v0.1) 152 | 153 | #### Released: March 5th, 2016 154 | 155 | ### Added 156 | - Custom commands 157 | - Quotes 158 | - Social command 159 | - Response targets 160 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # CactusBot Code Standards 2 | 3 | ## Python Formatting 4 | 5 | TL;DR: stick to [PEP8](https://www.python.org/dev/peps/pep-0008/) and you should be fine. 6 | 7 | > ### Keep it Clean 8 | > We don't want any profanity or anything offensive in our code. We get it, coding can be frustrating and some people don't mind using strong language. In this case, however, please refrain. 9 | > 10 | > ### Indentation 11 | > PEP8 states that spaces are the preferred indentation method. 12 | > 13 | > We use four-space indentation. Because Python requires consistent indentation, please do the same. 14 | > 15 | > ### Maximum Line Length 16 | > PEP8 puts the maximum line length at 79 characters. Please follow this guideline; it makes code much more readable. 17 | > 18 | > ### Encoding 19 | > File encoding must be UTF-8. Shouldn't be too much of an issue, as most computers support it, but it is required. (If you don't know what UTF-8 is, you're probably already using it.) 20 | > 21 | > ### Quotes 22 | > We use double quotes for text, and single quotes for symbols and characters. 23 | > 24 | > ### Everything Else 25 | > [PEP8](https://www.python.org/dev/peps/pep-0008/)! 26 | -------------------------------------------------------------------------------- /CONTRIBUTORS.md: -------------------------------------------------------------------------------- 1 | # Contributors 2 | 3 | ## Developers 4 | 5 | ### CactusDev 6 | - [2Cubed](https://github.com/2Cubed), [@2CubedTech](https://twitter.com/2CubedTech) 7 | - [Innectic](https://github.com/Innectic), [@Innectic](https://twitter.com/Innectic) 8 | - [ParadigmShift3d](https://github.com/RPiAwesomeness), [@ParadigmShift3d](https://twitter.com/ParadigmShift3d) 9 | 10 | 11 | ## Special Thanks 12 | 13 | - [artdude543](https://github.com/artdude543), [@artdude543](https://twitter.com/artdude543) 14 | - [ProbablePrime](https://github.com/ProbablePrime), [@ProbablePrime](https://twitter.com/ProbablePrime) 15 | 16 | ## Community Contributors 17 | 18 | - [MysticalMage](https://github.com/mysticalmage) Added follower caching 19 | -------------------------------------------------------------------------------- /INSTALL.md: -------------------------------------------------------------------------------- 1 | 2 | ## Setup CactusBot: 3 | ``` 4 | git clone https://github.com/CactusDev/CactusBot 5 | cd CactusBot 6 | pip3 install -r requirements.txt 7 | cp config.template.py config.py 8 | ``` 9 | 10 | Next, open `config.py` with your favorite text editor, and set 11 | `USERNAME` and `PASSWORD` to the bot's credentials, and set `CHANNEL` to your channel's name. 12 | 13 | # Usage 14 | 15 | ## Start RethinkDB: 16 | 17 | `rethinkdb` 18 | 19 | ## Start CactusAPI: 20 | 21 | `python run.py` 22 | 23 | ## Start Sepal: 24 | 25 | `npm start` 26 | 27 | ## Start CactusBot: 28 | 29 | `python run.py` 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2016 CactusDev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CactusBot 2 | 3 | CactusBot is a next-generation chat bot for live streams. 4 | Harnessing the power of open-source, and an extraordinary community to shape its path 5 | 6 | ![screenshot of the bot running in a terminal](./assets/terminal.png) 7 | 8 | ## CactusBot in action 9 | 10 | ![gif of CactusBot running in Beam chat](./assets/action.gif) 11 | 12 | # Installation 13 | 14 | *PYTHON 3.5 OR GREATER IS REQUIRED. ANY VERSION LOWER IS NOT SUPPORTED.* 15 | 16 | [Sepal Setup](https://github.com/CactusDev/Sepal) 17 | 18 | [CactusAPI Setup](https://github.com/CactusDev/CactusAPI) 19 | 20 | [CactusBot Setup](INSTALL.md) 21 | 22 | # Authors 23 | 24 | [@2CubedTech](https://twitter.com/2CubedTech) 25 | 26 | [@Innectic](https://twitter.com/Innectic) 27 | 28 | [@ParadigmShift3d](https://twitter.com/ParadigmShift3d) 29 | 30 | # Releases 31 | 32 | You can view the changelog [here](CHANGELOG.md)! 33 | -------------------------------------------------------------------------------- /assets/action.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CactusDev/CactusBot/6d035bf74bdc8f7fb3ee1e79f8d443f5b17e7ea5/assets/action.gif -------------------------------------------------------------------------------- /assets/terminal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CactusDev/CactusBot/6d035bf74bdc8f7fb3ee1e79f8d443f5b17e7ea5/assets/terminal.png -------------------------------------------------------------------------------- /cactusbot/__init__.py: -------------------------------------------------------------------------------- 1 | """CactusBot.""" 2 | 3 | from .cactus import run, __version__ 4 | 5 | __all__ = ["__version__", "run"] 6 | -------------------------------------------------------------------------------- /cactusbot/api.py: -------------------------------------------------------------------------------- 1 | """Interact with CactusAPI.""" 2 | 3 | import json 4 | 5 | from .services.api import API 6 | 7 | 8 | class CactusAPI(API): 9 | """Interact with CactusAPI.""" 10 | 11 | URL = "https://cactus.exoz.one/api/v1/" 12 | 13 | SCOPES = { 14 | "alias:create", "alias:manage", 15 | "command:create", "command:manage", 16 | "config:create", "config:manage", 17 | "quote:create", "quote:manage", 18 | "repeat:create", "repeat:manage", 19 | "social:create", "social:manage", 20 | "trust:create", "trust:manage", 21 | } 22 | 23 | def __init__(self, token, password, url=URL, auth_token="", **kwargs): 24 | super().__init__(**kwargs) 25 | 26 | self.token = token 27 | self.auth_token = auth_token 28 | self.password = password 29 | self.URL = url 30 | 31 | async def request(self, method, endpoint, is_json=True, **kwargs): 32 | """Send HTTP request to endpoint.""" 33 | 34 | headers = { 35 | "X-Auth-Token": self.token, 36 | "X-Auth-Key": self.auth_token 37 | } 38 | 39 | if is_json: 40 | headers["Content-Type"] = "application/json" 41 | 42 | if "headers" in kwargs: 43 | kwargs["headers"].update(headers) 44 | else: 45 | kwargs["headers"] = headers 46 | 47 | response = await super().request(method, endpoint, **kwargs) 48 | 49 | if response.status == 401: 50 | reauth = await self.login(self.password, *self.SCOPES) 51 | if reauth.status == 200: 52 | self.auth_token = (await reauth.json()).get("token") 53 | 54 | return response 55 | 56 | async def get(self, endpoint, **kwargs): 57 | return await self.request("GET", endpoint, is_json=False, **kwargs) 58 | 59 | async def login(self, *scopes, password=None): 60 | """Authenticate.""" 61 | 62 | if password is None: 63 | password = self.password 64 | 65 | data = { 66 | "token": self.token, 67 | "password": password, 68 | "scopes": scopes 69 | } 70 | 71 | response = await self.post("/login", data=json.dumps(data)) 72 | 73 | auth_response = await response.json() 74 | if response.status == 404: 75 | raise ValueError(auth_response["errors"]) 76 | else: 77 | self.auth_token = auth_response.get("token") 78 | 79 | return response 80 | 81 | async def get_command(self, name=None): 82 | """Get a command.""" 83 | 84 | if name is not None: 85 | return await self.get( 86 | "/user/{token}/command/{command}".format( 87 | token=self.token, command=name)) 88 | return await self.get("/user/{token}/command".format( 89 | token=self.token)) 90 | 91 | async def add_command(self, name, response, *, user_level=1): 92 | """Add a command.""" 93 | 94 | data = { 95 | "response": response, 96 | "userLevel": user_level 97 | } 98 | 99 | return await self.patch( 100 | "/user/{token}/command/{command}".format( 101 | token=self.token, command=name), 102 | data=json.dumps(data) 103 | ) 104 | 105 | async def remove_command(self, name): 106 | """Remove a command.""" 107 | 108 | return await self.delete("/user/{token}/command/{command}".format( 109 | token=self.token, command=name)) 110 | 111 | async def get_command_alias(self, command): 112 | """Get a command alias.""" 113 | return await self.get("/user/{token}/alias/{command}".format( 114 | token=self.token, command=command)) 115 | 116 | async def add_alias(self, command, alias, args=None): 117 | """Create a command alias.""" 118 | 119 | data = { 120 | "commandName": command, 121 | } 122 | 123 | if args is not None: 124 | data["arguments"] = args 125 | 126 | return await self.patch("/user/{user}/alias/{alias}".format( 127 | user=self.token, alias=alias), data=json.dumps(data)) 128 | 129 | async def remove_alias(self, alias): 130 | """Remove a command alias.""" 131 | 132 | return await self.delete("/user/{user}/alias/{alias}".format( 133 | user=self.token, alias=alias)) 134 | 135 | async def toggle_command(self, command, state): 136 | """Toggle the enabled state of a command""" 137 | 138 | data = {"enabled": state} 139 | 140 | return await self.patch("/user/{token}/command/{command}".format( 141 | token=self.token, command=command), data=json.dumps(data)) 142 | 143 | async def update_command_count(self, command, action): 144 | """Set the count of a command.""" 145 | 146 | data = {"count": action} 147 | 148 | return await( 149 | self.patch("/user/{token}/command/{command}/count".format( 150 | token=self.token, command=command), data=json.dumps(data))) 151 | 152 | async def get_quote(self, quote_id=None): 153 | """Get a quote.""" 154 | 155 | if quote_id is not None: 156 | return await self.get("/user/{token}/quote/{id}".format( 157 | token=self.token, id=quote_id)) 158 | return await self.get("/user/{token}/quote".format( 159 | token=self.token), params={"random": True}) 160 | 161 | async def add_quote(self, quote): 162 | """Add a quote.""" 163 | 164 | data = {"quote": quote} 165 | 166 | return await self.post( 167 | "/user/{token}/quote".format(token=self.token), 168 | data=json.dumps(data) 169 | ) 170 | 171 | async def edit_quote(self, quote_id, quote): 172 | """Edit a quote.""" 173 | 174 | data = {"quote": quote} 175 | 176 | return await self.patch( 177 | "/user/{token}/quote/{quote_id}".format( 178 | token=self.token, quote_id=quote_id), 179 | data=json.dumps(data) 180 | ) 181 | 182 | async def remove_quote(self, quote_id): 183 | """Remove a quote.""" 184 | 185 | return await self.delete("/user/{token}/quote/{id}".format( 186 | token=self.token, id=quote_id)) 187 | 188 | async def get_config(self, *keys): 189 | """Get the token config.""" 190 | 191 | if keys: 192 | return await self.get("/user/{token}/config".format( 193 | token=self.token), data=json.dumps({"keys": keys})) 194 | 195 | return await self.get("/user/{token}/config".format( 196 | token=self.token)) 197 | 198 | async def update_config(self, value): 199 | """Update config attributes.""" 200 | 201 | return await self.patch("/user/{user}/config".format( 202 | user=self.token), data=json.dumps(value)) 203 | 204 | async def add_repeat(self, command, period): 205 | """Add a repeat.""" 206 | 207 | data = { 208 | "commandName": command, 209 | "period": period 210 | } 211 | 212 | return await self.patch("/user/{user}/repeat/{command}".format( 213 | user=self.token, command=command), data=json.dumps(data)) 214 | 215 | async def remove_repeat(self, repeat): 216 | """Remove a repeat.""" 217 | 218 | return await self.delete("/user/{user}/repeat/{repeat}".format( 219 | user=self.token, repeat=repeat)) 220 | 221 | async def get_repeats(self): 222 | """Get all repeats.""" 223 | 224 | return await self.get("/user/{user}/repeat".format(user=self.token)) 225 | 226 | async def add_social(self, service, url): 227 | """Add a social service.""" 228 | 229 | data = {"url": url} 230 | 231 | return await self.patch("/user/{user}/social/{service}".format( 232 | user=self.token, service=service), data=json.dumps(data)) 233 | 234 | async def remove_social(self, service): 235 | """Remove a social service.""" 236 | 237 | return await self.delete("/user/{user}/social/{service}".format( 238 | user=self.token, service=service)) 239 | 240 | async def get_social(self, service=None): 241 | """Get social service.""" 242 | 243 | if service is None: 244 | return await self.get("/user/{user}/social".format( 245 | user=self.token)) 246 | return await self.get("/user/{user}/social/{service}".format( 247 | user=self.token, service=service)) 248 | 249 | async def get_trust(self, user_id=None): 250 | """Get trusted users.""" 251 | 252 | if user_id is None: 253 | return await self.get("/user/{user}/trust".format(user=self.token)) 254 | 255 | return await self.get("/user/{user}/trust/{user_id}".format( 256 | user=self.token, user_id=user_id)) 257 | 258 | async def add_trust(self, user_id, username): 259 | """Trust new user.""" 260 | 261 | data = {"userName": username} 262 | 263 | return await self.patch("/user/{user}/trust/{user_id}".format( 264 | user=self.token, user_id=user_id), data=json.dumps(data)) 265 | 266 | async def remove_trust(self, user_id): 267 | """Remove user trust.""" 268 | 269 | return await self.delete("/user/{user}/trust/{user_id}".format( 270 | user=self.token, user_id=user_id)) 271 | -------------------------------------------------------------------------------- /cactusbot/cactus.py: -------------------------------------------------------------------------------- 1 | """CactusBot!""" 2 | 3 | import asyncio 4 | import logging 5 | import time 6 | 7 | from .sepal import Sepal 8 | 9 | __version__ = "v0.4" 10 | 11 | 12 | CACTUS_ART = r"""CactusBot initialized! 13 | 14 | --` 15 | ` /++/- ` 16 | o+:. :+osy -/:.` 17 | oo+o/ /osyy -+///` 18 | /shh+ +oo+/ ./ooo` 19 | //+o+ /+osy /soo+` 20 | ++//- :oyhy /hyo+` 21 | /+oo/ /+/// -+/++` 22 | .:+/ ://++ :+///` 23 | :+ooo :oo+- 24 | +o++/ --` 25 | +o+oo 26 | -ohhy 27 | `:+ CactusBot {version} 28 | 29 | Made by: 2Cubed, Innectic, and ParadigmShift3d 30 | """.format(version=__version__) 31 | 32 | 33 | async def run(api, service, *auth): 34 | """Run bot.""" 35 | 36 | logger = logging.getLogger(__name__) 37 | logger.info(CACTUS_ART) 38 | 39 | await api.login(*api.SCOPES) 40 | 41 | sepal = Sepal(api.token, service) 42 | 43 | try: 44 | await sepal.connect() 45 | asyncio.ensure_future(sepal.read(sepal.handle)) 46 | await service.run(*auth) 47 | 48 | except KeyboardInterrupt: 49 | logger.info("Removing thorns... done.") 50 | 51 | except Exception: 52 | logger.critical("Oh no, I crashed!", exc_info=True) 53 | 54 | logger.info("Restarting in 10 seconds...") 55 | 56 | try: 57 | time.sleep(10) 58 | except KeyboardInterrupt: 59 | logger.info("CactusBot deactivated.") 60 | -------------------------------------------------------------------------------- /cactusbot/commands/__init__.py: -------------------------------------------------------------------------------- 1 | """Handle commands.""" 2 | 3 | from .command import Command 4 | from .magic import COMMANDS 5 | 6 | __all__ = ["Command", "COMMANDS"] 7 | -------------------------------------------------------------------------------- /cactusbot/commands/command.py: -------------------------------------------------------------------------------- 1 | """Magic command internals (and magic).""" 2 | 3 | import inspect 4 | import re 5 | 6 | ROLES = { 7 | 5: "Owner", 8 | 4: "Moderator", 9 | 2: "Subscriber", 10 | 1: "User", 11 | 0: "Banned" 12 | } 13 | 14 | REGEXES = { 15 | "command": r"!?([\w-]{1,32})" 16 | } 17 | 18 | 19 | class Command: 20 | """Parent class to all magic commands. 21 | 22 | Function definitions may use annotations to specify information about the 23 | arguments. 24 | 25 | Using a string signifies a required regular expression to match. (If no 26 | groups are specified, the entire match is returned. If one group is 27 | specified, it is returned as a string. Otherwise, the tuple of groups is 28 | returned.) 29 | 30 | Special shortcuts, beginning with a `?`, are taken from a built-in list. 31 | 32 | ============= =================== 33 | Shortcut Regular Expression 34 | ============= =================== 35 | ``?command`` ``!?([\\w-]{1,32})`` 36 | ============= =================== 37 | 38 | Using the ``False`` annotation on `*args` signifies that no arguments are 39 | required to successfully execute the command. 40 | 41 | An asynchronous function may be used as a validation annotation, as well. 42 | The function is passed the command argument. If an exception is not raised, 43 | the return value of the function is passed to the command. Otherwise, an 44 | error message is returned. 45 | 46 | Keyword-only arguments should be annotated with the requested metadata. 47 | 48 | ========= ======================================================= 49 | Value Description 50 | ========= ======================================================= 51 | username The username of the message sender. 52 | channel The name of the channel which the message was sent in. 53 | packet The entire :obj:`MessagePacket`. 54 | ========= ======================================================= 55 | 56 | The ``COMMAND`` attribute is required, and should be set to the command 57 | name string. 58 | 59 | Parameters 60 | ---------- 61 | api : :obj:`CactusAPI` or :obj:`None` 62 | Instance of :obj:`CactusAPI`. Must be provided to the top-level magic 63 | :obj:`Command`. 64 | 65 | Examples 66 | -------- 67 | >>> class Test(Command): 68 | ... 69 | ... COMMAND = "test" 70 | ... 71 | ... @Command.command() 72 | ... async def add(self, command: "?command", *response): 73 | ... return "Added !{command} with response {response}.".format( 74 | ... command=command, response=' '.join(response)) 75 | ... 76 | ... @Command.command(hidden=True) 77 | ... async def highfive(self, *, recipient: "username"): 78 | ... return "Have a highfive, {recipient}!".format( 79 | ... recipient=recipient) 80 | ... 81 | ... @Command.command() 82 | ... async def potato(self, *users: False): 83 | ... 84 | ... if not users: 85 | ... return "Have a potato!" 86 | ... 87 | ... return "Have a potato, {users}!".format(users=', '.join(users)) 88 | 89 | 90 | """ 91 | 92 | default = None 93 | 94 | api = None 95 | 96 | def __init__(self, api=None): 97 | 98 | if api is not None: 99 | self.api = api 100 | Command.api = api 101 | 102 | async def __call__(self, *args, **meta): 103 | 104 | commands = self.commands() 105 | assert self.default is None or callable(self.default) 106 | 107 | if args: 108 | 109 | command, *arguments = args 110 | 111 | if command in commands: 112 | 113 | role = commands[command].COMMAND_META.get("role", 1) 114 | 115 | if isinstance(role, str): 116 | role = list(ROLES.keys())[list(map( 117 | str.lower, ROLES.values())).index(role.lower())] 118 | if "packet" in meta and meta["packet"].role < role: 119 | return "Role level '{role}' or higher required.".format( 120 | role=ROLES[max(k for k in ROLES.keys() if k <= role)]) 121 | 122 | try: 123 | 124 | return await self._run_safe( 125 | commands[command], *arguments, **meta) 126 | 127 | except IndexError as error: 128 | 129 | if error.args[0] == 0: 130 | 131 | has_default = hasattr(commands[command], "default") 132 | has_commands = hasattr(commands[command], "commands") 133 | if not (has_default or has_commands): 134 | return "Not enough arguments. <{0}>".format( 135 | '> <'.join(arg.name for arg in error.args[1])) 136 | 137 | response = "Not enough arguments. <{0}>".format( 138 | '|'.join( 139 | commands[command].commands(hidden=False).keys() 140 | )) 141 | 142 | if commands[command].default is not None: 143 | 144 | try: 145 | return await self._run_safe( 146 | commands[command].default, 147 | *arguments, **meta) 148 | 149 | except IndexError: 150 | return response 151 | 152 | return response 153 | 154 | else: 155 | return "Too many arguments." 156 | 157 | if self.default is not None: 158 | try: 159 | return await self._run_safe(self.default, *args, **meta) 160 | except IndexError: 161 | pass 162 | 163 | if args: 164 | command, *_ = args 165 | return "Invalid argument: '{0}'.".format(command) 166 | 167 | return "Not enough arguments. <{0}>".format( 168 | '|'.join(self.commands(hidden=False).keys())) 169 | 170 | @classmethod 171 | def command(cls, name=None, **meta): 172 | """Accept arguments for command decorator. 173 | 174 | 175 | Parameters 176 | ---------- 177 | name : :obj:`str` or :obj:`None`, default :obj:`None` 178 | The name of the command. If :obj:`None`, the function name is used. 179 | hidden : :obj:`bool` 180 | Whether or not to hide the command from help messages. 181 | role : :obj:`str` or :obj:`int`, default ``1`` 182 | The minimum role required to run the command. 183 | String capitalization is ignored. 184 | **meta 185 | Custom meta filters. Any keyword arguments are valid. 186 | 187 | ======= =========== 188 | Number String 189 | ======= =========== 190 | 5 Owner 191 | 4 Moderator 192 | 2 Subscriber 193 | 1 User 194 | 0 Banned 195 | ======= =========== 196 | 197 | Returns 198 | ------- 199 | :obj:`function` 200 | Decorator command. 201 | 202 | Examples 203 | -------- 204 | >>> @Command.command() 205 | ... async def hello(): 206 | ... return "Hello, world." 207 | 208 | >>> @Command.command(name="return") 209 | ... async def return_(): 210 | ... return "Achievement Get: Return to Sender" 211 | 212 | >>> @Command.command(hidden=True) 213 | ... async def secret(): 214 | ... return "Wow, you found a secret!" 215 | 216 | >>> @Command.command(role="moderator") 217 | ... async def secure(): 218 | ... return "Moderator-only things have happened." 219 | """ 220 | 221 | def decorator(function): 222 | """Decorate a command.""" 223 | 224 | function.COMMAND_META = meta 225 | 226 | if inspect.isclass(function): 227 | COMMAND = getattr(function, "COMMAND", None) 228 | function = function(Command.api) 229 | function.__name__ = function.__class__.__name__ 230 | if COMMAND is not None: 231 | function.COMMAND = COMMAND 232 | 233 | if name is not None: 234 | assert ' ' not in name, "Command name may not contain spaces" 235 | if getattr(function, "COMMAND", name) is not name: 236 | raise NameError("Multiple command name declarations") 237 | function.COMMAND = name 238 | elif getattr(function, "COMMAND", None) is None: 239 | function.COMMAND = function.__name__.lower() 240 | 241 | return function 242 | 243 | return decorator 244 | 245 | async def _run_safe(self, function, *args, **meta): 246 | 247 | self._check_safe(function, *args) 248 | 249 | args = await self._clean_args(function, *args) 250 | if isinstance(args, str): 251 | return args 252 | kwargs = self._clean_kwargs(function, **meta) 253 | return await function(*args, **kwargs) 254 | 255 | @staticmethod 256 | def _check_safe(function, *args): 257 | 258 | params = inspect.signature(function).parameters.values() 259 | 260 | pos_args = tuple( 261 | p for p in params if p.kind is p.POSITIONAL_OR_KEYWORD) 262 | star_arg = next( 263 | (p for p in params if p.kind is p.VAR_POSITIONAL), None) 264 | 265 | arg_range = ( 266 | len(tuple( 267 | p for p in pos_args if p.default is p.empty 268 | )) + (bool(star_arg) if star_arg and star_arg.annotation else 0), 269 | len(args) if star_arg else len(pos_args) 270 | ) 271 | 272 | if not arg_range[0] <= len(args) <= arg_range[1]: 273 | 274 | if star_arg is not None: 275 | pos_args += (star_arg,) 276 | 277 | raise IndexError(len(args) > arg_range[0], pos_args) 278 | return True 279 | 280 | @staticmethod 281 | async def _clean_args(function, *args): 282 | 283 | params = inspect.signature(function).parameters.values() 284 | 285 | pos_args = tuple( 286 | p for p in params if p.kind is p.POSITIONAL_OR_KEYWORD) 287 | 288 | args = list(args) 289 | 290 | for index, arg in enumerate(pos_args[:len(args)]): 291 | if arg.annotation is not arg.empty: 292 | error_response = "Invalid {type}: '{value}'.".format( 293 | type=arg.name, value=args[index]) 294 | if isinstance(arg.annotation, str): 295 | annotation = arg.annotation 296 | if annotation.startswith('?'): 297 | assert annotation[1:] in REGEXES, "Invalid shortcut" 298 | annotation = REGEXES[annotation[1:]] 299 | match = re.match('^' + annotation + '$', args[index]) 300 | if match is None: 301 | return error_response 302 | else: 303 | groups = match.groups() 304 | if len(groups) == 1: 305 | args[index] = groups[0] 306 | elif len(groups) > 1: 307 | args[index] = groups 308 | elif callable(arg.annotation): 309 | try: 310 | args[index] = await arg.annotation(args[index]) 311 | except Exception: 312 | return error_response 313 | else: 314 | raise TypeError("Invalid annotation: {0}".format( 315 | arg.annotation)) 316 | 317 | return args 318 | 319 | @staticmethod 320 | def _clean_kwargs(function, **meta): 321 | 322 | params = inspect.signature(function).parameters.values() 323 | meta_args = (p for p in params if p.kind is p.KEYWORD_ONLY) 324 | return {p.name: meta.get(p.annotation) for p in meta_args} 325 | 326 | def commands(self, **meta): 327 | """Return commands belonging to the parent class. 328 | 329 | Parameters 330 | ---------- 331 | **meta 332 | Attributes to filter by. 333 | 334 | Returns 335 | ------- 336 | :obj:`dict` 337 | Commands which match the meta attributes. 338 | Keys are names, values are methods. 339 | 340 | Examples 341 | -------- 342 | >>> @Command.command() 343 | ... class Test(Command): 344 | ... 345 | ... @Command.command() 346 | ... async def simple(self): 347 | ... return "Simple response." 348 | ... 349 | ... @Command.command(hidden=True) 350 | ... async def secret(self): 351 | ... return "#secrets" 352 | ... 353 | >>> Test.commands(hidden=False).keys() 354 | dict_keys(['simple']) 355 | """ 356 | 357 | disallowed = ["commands", "__class__"] 358 | return { 359 | method.COMMAND: method for attr in dir(self) 360 | if attr not in disallowed 361 | for method in (getattr(self, attr),) 362 | if hasattr(method, "COMMAND") and 363 | all(method.COMMAND_META.get(key, value) == value 364 | for key, value in meta.items()) 365 | } 366 | -------------------------------------------------------------------------------- /cactusbot/commands/magic/__init__.py: -------------------------------------------------------------------------------- 1 | """Define custom magic commands.""" 2 | 3 | from .. import Command 4 | from .alias import Alias 5 | from .cactus import Cactus 6 | from .command import Meta 7 | from .config import Config 8 | from .cube import Cube, Temmie 9 | from .quote import Quote 10 | from .repeat import Repeat 11 | from .social import Social 12 | from .trust import Trust 13 | from .uptime import Uptime 14 | from .multi import Multi 15 | 16 | COMMANDS = (Alias, Cactus, Meta, Config, Cube, Temmie, 17 | Quote, Repeat, Social, Trust, Uptime, Multi) 18 | 19 | __all__ = ("Alias", "Command", "Cactus", "Meta", "Config", "Cube", 20 | "Temmie", "Quote", "Repeat", "Social", "Trust", "Uptime", 21 | "Multi") 22 | -------------------------------------------------------------------------------- /cactusbot/commands/magic/alias.py: -------------------------------------------------------------------------------- 1 | """Alias command.""" 2 | 3 | from . import Command 4 | from ...packets import MessagePacket 5 | 6 | 7 | class Alias(Command): 8 | """Alias command.""" 9 | 10 | COMMAND = "alias" 11 | 12 | @Command.command(role="moderator") 13 | async def add(self, alias: "?command", command: "?command", *_: False, 14 | raw: "packet"): 15 | """Add a new command alias.""" 16 | 17 | _, _, _, _, *args = raw.split() 18 | 19 | if args: 20 | packet_args = MessagePacket.join( 21 | *args, separator=' ').json["message"] 22 | else: 23 | packet_args = None 24 | 25 | response = await self.api.add_alias(command, alias, packet_args) 26 | 27 | if response.status == 201: 28 | return "Alias !{} for !{} created.".format(alias, command) 29 | elif response.status == 200: 30 | return "Alias !{} for command !{} updated.".format(alias, command) 31 | elif response.status == 404: 32 | return "Command !{} does not exist.".format(command) 33 | 34 | @Command.command(role="moderator") 35 | async def remove(self, alias: "?command"): 36 | """Remove a command alias.""" 37 | 38 | response = await self.api.remove_alias(alias) 39 | if response.status == 200: 40 | return "Alias !{} removed.".format(alias) 41 | elif response.status == 404: 42 | return "Alias !{} doesn't exist!".format(alias) 43 | 44 | @Command.command("list", role="moderator") 45 | async def list_aliases(self): 46 | """List all aliases.""" 47 | response = await self.api.get_command() 48 | 49 | if response.status == 200: 50 | commands = (await response.json())["data"] 51 | return "Aliases: {}.".format(', '.join(sorted( 52 | "{} ({})".format( 53 | command["attributes"]["name"], 54 | command["attributes"]["commandName"]) 55 | for command in commands 56 | if command.get("type") == "aliases" 57 | ))) 58 | return "No aliases added!" 59 | -------------------------------------------------------------------------------- /cactusbot/commands/magic/cactus.py: -------------------------------------------------------------------------------- 1 | """Cactus command.""" 2 | 3 | 4 | from . import Command 5 | from ...packets import MessagePacket 6 | 7 | from ...cactus import __version__ 8 | 9 | 10 | class Cactus(Command): 11 | """Ouch! That's pokey!""" 12 | 13 | COMMAND = "cactus" 14 | 15 | @Command.command(name="cactus") 16 | async def default(self): 17 | """Default response.""" 18 | 19 | return MessagePacket("Ohai! I'm CactusBot! ", ("emoji", "🌵")) 20 | 21 | @Command.command() 22 | async def docs(self): 23 | """Documentation response.""" 24 | 25 | return MessagePacket( 26 | ("text", "Check out my documentation at "), 27 | ("url", "https://cactusbot.rtfd.org", "cactusbot.rtfd.org"), 28 | ("text", ".") 29 | ) 30 | 31 | @Command.command() 32 | async def twitter(self): 33 | """Twitter response.""" 34 | 35 | return MessagePacket( 36 | ("text", "You can follow the team behind CactusBot at: "), 37 | ("url", "https://twitter.com/CactusDevTeam", 38 | "twitter.com/CactusDevTeam") 39 | ) 40 | 41 | @Command.command() 42 | async def github(self, project=None): 43 | """Github response.""" 44 | 45 | if project is None or project.lower() in ("bot", "cactusbot"): 46 | return MessagePacket( 47 | "Check out my GitHub repository at: ", 48 | ("url", "https://github.com/CactusDev/CactusBot", 49 | "github.com/CactusDev/CactusBot") 50 | ) 51 | elif project.lower() == "issue": 52 | return MessagePacket( 53 | "Create a GitHub issue at: ", 54 | ("url", "https://github.com/CactusDev/CactusBot/issues", 55 | "github.com/CactusDev/CactusBot/issues") 56 | ) 57 | elif project.lower() in ("cactusdev", "cactus"): 58 | return MessagePacket( 59 | "Check out the CactusDev GitHub organization at: ", 60 | ("url", "https://github.com/CactusDev", "github.com/CactusDev") 61 | ) 62 | elif project.lower() in ("api", "cactusapi"): 63 | return MessagePacket( 64 | "Check out the GitHub repository for CactusAPI at: ", 65 | ("url", "https://github.com/CactusDev/CactusAPI", 66 | "github.com/CactusDev/CactusAPI") 67 | ) 68 | elif project.lower() == "sepal": 69 | return MessagePacket( 70 | "Check out the GitHub repository for Sepal at: ", 71 | ("url", "https://github.com/CactusDev/Sepal", 72 | "github.com/CactusDev/Sepal") 73 | ) 74 | elif project.lower() in ("assets", "art"): 75 | return MessagePacket( 76 | "Check out the CactusDev assets at: ", 77 | ("url", "https://github.com/CactusDev/CactusAssets", 78 | "github.com/CactusDev/CactusAssets") 79 | ) 80 | return MessagePacket("Unknown project '{0}'.".format(project)) 81 | 82 | @Command.command() 83 | async def help(self): 84 | """Help response.""" 85 | 86 | return ("Try our docs (!cactus docs). If that doesn't help, tweet at" 87 | " us (!cactus twitter)!") 88 | 89 | @Command.command() 90 | async def version(self): 91 | """Version of the bot.""" 92 | 93 | return "CactusBot {version}".format(version=__version__) 94 | -------------------------------------------------------------------------------- /cactusbot/commands/magic/command.py: -------------------------------------------------------------------------------- 1 | """Manage commands.""" 2 | 3 | from . import Command 4 | 5 | 6 | class Meta(Command): 7 | """Manage commands.""" 8 | 9 | COMMAND = "command" 10 | 11 | ROLES = { 12 | '+': 4, 13 | '$': 2 14 | } 15 | 16 | @Command.command(role="moderator") 17 | async def add(self, command: r'!?([+$]?)([\w-]{1,32})', *response, 18 | raw: "packet"): 19 | """Add a command.""" 20 | 21 | symbol, name = command 22 | 23 | user_level = self.ROLES.get(symbol, 1) 24 | 25 | raw.role = user_level # HACK 26 | raw.target = None 27 | response = await self.api.add_command( 28 | name, raw.split(maximum=3)[-1].json, user_level=user_level) 29 | data = await response.json() 30 | 31 | if data["meta"].get("created"): 32 | return "Added command !{}.".format(name) 33 | return "Updated command !{}.".format(name) 34 | 35 | @Command.command(role="moderator") 36 | async def remove(self, name: "?command"): 37 | """Remove a command.""" 38 | response = await self.api.remove_command(name) 39 | if response.status == 200: 40 | return "Removed command !{}.".format(name) 41 | return "Command !{} does not exist!".format(name) 42 | 43 | @Command.command("list", role="moderator") 44 | async def list_commands(self): 45 | """List all custom commands.""" 46 | response = await self.api.get_command() 47 | 48 | if response.status == 200: 49 | commands = (await response.json())["data"] 50 | 51 | return "Commands: {}".format(', '.join(sorted( 52 | command["attributes"]["name"] for command in commands 53 | if command.get("type") in ( 54 | "command", "builtin", "builtins", "alias") 55 | ))) 56 | return "No commands added!" 57 | 58 | @Command.command(role="moderator") 59 | async def enable(self, command: "?command"): 60 | """Enable a command.""" 61 | 62 | response = await self.api.toggle_command(command, True) 63 | if response.status == 200: 64 | return "Command !{} has been enabled.".format(command) 65 | 66 | @Command.command(role="moderator") 67 | async def disable(self, command: "?command"): 68 | """Disable a command.""" 69 | 70 | response = await self.api.toggle_command(command, False) 71 | if response.status == 200: 72 | return "Command !{} has been disabled.".format(command) 73 | 74 | @Command.command(role="moderator") 75 | async def count(self, command: r'?command', 76 | action: r"([=+-]?)(\d+)"=None): 77 | """Update the count of a command.""" 78 | 79 | if action is None: 80 | response = await self.api.get_command(command) 81 | data = await response.json() 82 | if response.status == 404: 83 | return "Command !{} does not exist.".format(command) 84 | elif response.status == 200: 85 | return "!{command}'s count is {count}.".format( 86 | command=command, count=data["data"]["attributes"]["count"]) 87 | 88 | operator, value = action 89 | action_string = (operator or '=') + value 90 | 91 | response = await self.api.update_command_count(command, action_string) 92 | if response.status == 200: 93 | return "Count updated." 94 | -------------------------------------------------------------------------------- /cactusbot/commands/magic/config.py: -------------------------------------------------------------------------------- 1 | """Config command.""" 2 | 3 | from .command import Command 4 | 5 | VALID_TOGGLE_ON_STATES = ("on", "allow", "enable", "true") 6 | VALID_TOGGLE_OFF_STATES = ("off", "disallow", "disable", "false") 7 | 8 | 9 | async def _update_config(api, scope, field, section, value): 10 | return await api.update_config({ 11 | scope: { 12 | field: { 13 | section: value 14 | } 15 | } 16 | }) 17 | 18 | 19 | async def _update_spam_config(api, scope, field, value): 20 | return await api.update_config({ 21 | scope: { 22 | field: value 23 | } 24 | }) 25 | 26 | 27 | class Config(Command): 28 | """Config command""" 29 | 30 | COMMAND = "config" 31 | 32 | @Command.command(role="moderator") 33 | class Announce(Command): 34 | """Announce sub command.""" 35 | 36 | @Command.command() 37 | async def follow(self, value): 38 | """Follow subcommand.""" 39 | 40 | if value in VALID_TOGGLE_ON_STATES: 41 | await _update_config( 42 | self.api, "announce", "follow", "announce", True) 43 | return "Follow announcements are enabled." 44 | elif value in VALID_TOGGLE_OFF_STATES: 45 | await _update_config( 46 | self.api, "announce", "follow", "announce", False) 47 | return "Follow announcements are disabled." 48 | else: 49 | return "Invalid boolean value: '{value}'".format(value=value) 50 | 51 | @Command.command() 52 | async def subscribe(self, value): 53 | """Subscribe subcommand.""" 54 | 55 | if value in VALID_TOGGLE_ON_STATES: 56 | await _update_config( 57 | self.api, "announce", "subscribe", "announce", True) 58 | return "Subscribe announcements are enabled." 59 | elif value in VALID_TOGGLE_OFF_STATES: 60 | await _update_config( 61 | self.api, "announce", "subscribe", "announce", False) 62 | return "Subscribe announcements are disabled." 63 | else: 64 | return "Invalid boolean value: '{value}'".format(value=value) 65 | 66 | @Command.command() 67 | async def host(self, value): 68 | """Host subcommand.""" 69 | 70 | if value in VALID_TOGGLE_ON_STATES: 71 | await _update_config( 72 | self.api, "announce", "host", "announce", True) 73 | return "Host announcements are enabled." 74 | elif value in VALID_TOGGLE_OFF_STATES: 75 | await _update_config( 76 | self.api, "announce", "host", "announce", False) 77 | return "Host announcements are disabled." 78 | else: 79 | return "Invalid boolean value: '{value}'".format(value=value) 80 | 81 | @Command.command() 82 | async def leave(self, value): 83 | """Leave subcommand.""" 84 | 85 | if value in VALID_TOGGLE_ON_STATES: 86 | await _update_config( 87 | self.api, "announce", "leave", "announce", True) 88 | return "Leave announcements are enabled." 89 | elif value in VALID_TOGGLE_OFF_STATES: 90 | await _update_config( 91 | self.api, "announce", "leave", "announce", False) 92 | return "Leave announcements are disabled." 93 | 94 | @Command.command() 95 | async def join(self, value): 96 | """Join subcommand.""" 97 | 98 | if value in VALID_TOGGLE_ON_STATES: 99 | await _update_config( 100 | self.api, "announce", "join", "announce", True) 101 | return "Join announcements are enabled." 102 | elif value in VALID_TOGGLE_OFF_STATES: 103 | await _update_config( 104 | self.api, "announce", "join", "announce", False) 105 | return "Join announcements are disabled." 106 | 107 | @Command.command(role="moderator") 108 | class Spam(Command): 109 | """Spam subcommand.""" 110 | 111 | @Command.command() 112 | async def urls(self, value): 113 | """Urls subcommand.""" 114 | 115 | if value in VALID_TOGGLE_ON_STATES: 116 | await _update_spam_config( 117 | self.api, "spam", "allowUrls", True) 118 | return "URLs are now allowed." 119 | 120 | elif value in VALID_TOGGLE_OFF_STATES: 121 | await _update_spam_config( 122 | self.api, "spam", "allowUrls", False) 123 | return "URLs are now disallowed." 124 | 125 | else: 126 | return "Invalid boolean value: '{value}'.".format(value=value) 127 | 128 | @Command.command() 129 | async def emoji(self, value: r"\d+"): 130 | """Emoji subcommand.""" 131 | 132 | await _update_spam_config( 133 | self.api, "spam", "maxEmoji", int(value)) 134 | 135 | return "Maximum number of emoji is now {value}.".format( 136 | value=value) 137 | 138 | @Command.command() 139 | async def caps(self, value: r"\d+"): 140 | """Caps subcommand.""" 141 | 142 | await _update_spam_config( 143 | self.api, "spam", "maxCapsScore", int(value)) 144 | 145 | return "Maximum capitals score is now {value}.".format( 146 | value=value) 147 | -------------------------------------------------------------------------------- /cactusbot/commands/magic/cube.py: -------------------------------------------------------------------------------- 1 | """Cube things.""" 2 | 3 | import random 4 | import re 5 | from difflib import get_close_matches 6 | 7 | from . import Command 8 | from ...packets import MessagePacket 9 | 10 | 11 | class Cube(Command): 12 | """Cube things.""" 13 | 14 | COMMAND = "cube" 15 | 16 | NUMBER_EXPR = re.compile(r'^[-+]?\d*\.\d+|[-+]?\d+$') 17 | 18 | @Command.command(hidden=True) 19 | async def default(self, *args: False, username: "username", raw: "packet"): 20 | """Cube things!""" 21 | 22 | if not args: 23 | return self.cube(username) 24 | if args == ('2',): 25 | return "8. Whoa, that's 2Cubed!" 26 | elif len(args) > 8: 27 | return "Whoa, that's 2 many cubes!" 28 | 29 | result = [] 30 | 31 | for component in raw.split(maximum=1)[1]: 32 | if component.type == "text": 33 | if component.text.split(): 34 | result += self.join( 35 | map(self.cube, component.text.split()), ' · ') 36 | result.append(' · ') 37 | else: 38 | result.append(component) 39 | result.append('³') 40 | result.append(' · ') 41 | 42 | return MessagePacket(*result[:-1]) 43 | 44 | def cube(self, value: str): 45 | """Cube a value.""" 46 | 47 | match = re.match(self.NUMBER_EXPR, value) 48 | if match is not None: 49 | return '{:.4g}'.format(float(match.string)**3) 50 | return '{}³'.format(value) 51 | 52 | @staticmethod 53 | def join(iterable, delimeter): 54 | iterable = iter(iterable) 55 | yield next(iterable) 56 | for item in iterable: 57 | yield delimeter 58 | yield item 59 | 60 | 61 | class Temmie(Command): 62 | "awwAwa!!" 63 | 64 | COMMAND = "temmie" 65 | 66 | QUOTES = ( 67 | ("fhsdhjfdsfjsddshjfsd", False), 68 | ("hOI!!!!!! i'm tEMMIE!!", False), 69 | ("awwAwa cute!! (pets u)", False), 70 | ("OMG!! humans TOO CUTE (dies)", False), 71 | ("NO!!!!! muscles r... NOT CUTE", False), 72 | ("NO!!! so hungr... (dies)", False), 73 | ("FOOB!!!", False), 74 | ("can't blame a BARK for tryin'...", False), 75 | ("RATED TEM OUTTA TEM. Loves to pet cute humans. " 76 | "But you're allergic!", True), 77 | ("Special enemy Temmie appears here to defeat you!!", True), 78 | ("Temmie is trying to glomp you.", True), 79 | ("Temmie forgot her other attack.", True), 80 | ("Temmie is doing her hairs.", True), 81 | ("Smells like Temmie Flakes.", True), 82 | ("Temmie vibrates intensely.", True), 83 | ("Temmiy accidentally misspells her own name.", True), 84 | ("You flex at Temmie...", True), 85 | ("Temmie only wants the Temmie Flakes.", True), 86 | ("You say hello to Temmie.", True) 87 | ) 88 | 89 | @Command.command(hidden=True) 90 | async def default(self, *query: False): 91 | """hOI!!!!!!""" 92 | 93 | if query: 94 | quotes = dict(zip(( 95 | quote.lower() for quote, _ in self.QUOTES), self.QUOTES)) 96 | lowered = get_close_matches( 97 | ' '.join(query).lower(), quotes.keys(), n=1, cutoff=0)[0] 98 | quote, action = quotes[lowered] 99 | else: 100 | quote, action = random.choice(self.QUOTES) 101 | 102 | return MessagePacket(quote, action=action) 103 | -------------------------------------------------------------------------------- /cactusbot/commands/magic/multi.py: -------------------------------------------------------------------------------- 1 | """Generate a multistream link.""" 2 | 3 | from . import Command 4 | from ...packets import MessagePacket 5 | 6 | _BASE_URL = "https://multistream.me/" 7 | _SERVICES = ['t', 'b', 'h', 'y'] 8 | 9 | 10 | class Multi(Command): 11 | """Generate a multistream link.""" 12 | 13 | COMMAND = "multi" 14 | 15 | @Command.command(hidden=True) 16 | async def default(self, *channels): 17 | """Create a multistream link using a list of channels, and services 18 | seperated by `:`. 19 | 20 | !multistream b:fun h:streamer t:to y:watch 21 | """ 22 | 23 | link = _BASE_URL 24 | 25 | for channel in channels: 26 | service, channel_name = channel.split(':') 27 | 28 | if service not in _SERVICES: 29 | return "'{}' is not a valid service.".format(service) 30 | 31 | link += "{service}:{channel}/".format( 32 | service=service, channel=channel_name) 33 | 34 | return MessagePacket(("url", link)) 35 | -------------------------------------------------------------------------------- /cactusbot/commands/magic/quote.py: -------------------------------------------------------------------------------- 1 | """Manage quotes.""" 2 | 3 | from aiohttp import get 4 | 5 | from . import Command 6 | from ...packets import MessagePacket 7 | 8 | 9 | class Quote(Command): 10 | """Manage quotes.""" 11 | 12 | COMMAND = "quote" 13 | 14 | @Command.command(hidden=True) 15 | async def default(self, quote_id: r'[1-9]\d*'=None): 16 | """Get a quote based on ID. If no ID is provided, pick a random one.""" 17 | 18 | if quote_id is None: 19 | response = await self.api.get_quote() 20 | if response.status == 404: 21 | return "No quotes have been added!" 22 | return (await response.json())["data"][0]["attributes"]["quote"] 23 | else: 24 | response = await self.api.get_quote(quote_id) 25 | if response.status == 404: 26 | return "Quote {} does not exist!".format(quote_id) 27 | return (await response.json())["data"]["attributes"]["quote"] 28 | 29 | @Command.command(role="moderator") 30 | async def add(self, *quote): 31 | """Add a quote.""" 32 | response = await self.api.add_quote(' '.join(quote)) 33 | data = await response.json() 34 | return "Added quote #{}.".format( 35 | data["data"]["attributes"]["quoteId"]) 36 | 37 | @Command.command(role="moderator") 38 | async def edit(self, quote_id: r'[1-9]\d*', *quote): 39 | """Edit a quote based on ID.""" 40 | response = await self.api.edit_quote(quote_id, ' '.join(quote)) 41 | if response.status == 201: 42 | return "Added quote #{}.".format(quote_id) 43 | return "Edited quote #{}.".format(quote_id) 44 | 45 | @Command.command(role="moderator") 46 | async def remove(self, quote_id: r'[1-9]\d*'): 47 | """Remove a quote.""" 48 | response = await self.api.remove_quote(quote_id) 49 | if response.status == 404: 50 | return "Quote {} does not exist!".format(quote_id) 51 | return "Removed quote #{}.".format(quote_id) 52 | 53 | @Command.command(hidden=True, role="subscriber") 54 | async def inspirational(self): 55 | """Retrieve an inspirational quote.""" 56 | try: 57 | data = await (await get( 58 | "http://api.forismatic.com/api/1.0/", 59 | params=dict(method="getQuote", lang="en", format="json") 60 | )).json() 61 | except Exception: 62 | return MessagePacket( 63 | "Unable to get an inspirational quote. Have a ", 64 | ("emoji", "🐹"), 65 | " instead." 66 | ) 67 | else: 68 | return "\"{quote}\" -{author}".format( 69 | quote=data["quoteText"].strip(), 70 | author=data["quoteAuthor"].strip() or "Unknown" 71 | ) 72 | -------------------------------------------------------------------------------- /cactusbot/commands/magic/repeat.py: -------------------------------------------------------------------------------- 1 | """Manage repeats.""" 2 | 3 | from . import Command 4 | 5 | 6 | class Repeat(Command): 7 | """Manage repeats.""" 8 | 9 | COMMAND = "repeat" 10 | 11 | @Command.command(role="moderator") 12 | async def add(self, period: r"[1-9]\d*", command: "?command"): 13 | """Add a repeat.""" 14 | 15 | response = await self.api.add_repeat(command, int(period)) 16 | 17 | if response.status == 201: 18 | return "Repeat !{command} added on interval {period}.".format( 19 | command=command, period=period) 20 | elif response.status == 409: 21 | return "Repeat already exists!" 22 | else: 23 | return (await response.json()).get("errors", 24 | "Unknown error occured") 25 | 26 | @Command.command(role="moderator") 27 | async def remove(self, repeat: "?command"): 28 | """Remove a repeat""" 29 | 30 | response = await self.api.remove_repeat(repeat) 31 | 32 | if response.status == 200: 33 | return "Repeat removed." 34 | elif response.status == 404: 35 | return "Repeat with ID {} doesn't exist.".format(repeat) 36 | 37 | @Command.command("list", role="moderator") 38 | async def list_repeats(self): 39 | """List all repeats.""" 40 | 41 | response = await self.api.get_repeats() 42 | data = (await response.json())["data"] 43 | 44 | if not data: 45 | return "There are no active repeats in this channel." 46 | 47 | return "Active repeats: {}.".format(', '.join( 48 | repeat["attributes"]["commandName"] + " {}".format( 49 | repeat["attributes"]["period"]) for repeat in data)) 50 | -------------------------------------------------------------------------------- /cactusbot/commands/magic/social.py: -------------------------------------------------------------------------------- 1 | """Get social data.""" 2 | 3 | from . import Command 4 | from ...packets import MessagePacket 5 | 6 | 7 | class Social(Command): 8 | """Get social data.""" 9 | 10 | COMMAND = "social" 11 | 12 | @Command.command(hidden=True) 13 | async def default(self, *services: False): 14 | """Get a social service if it's provived, or give it all.""" 15 | 16 | if len(services) >= 12: 17 | return "Maximum number of requested services (12) exceeded." 18 | 19 | response = [] 20 | if services: 21 | for service in services: 22 | social = await self.api.get_social(service) 23 | if social.status == 200: 24 | data = await social.json() 25 | response.append( 26 | data["data"]["attributes"]["service"].title() + ': ') 27 | response.append( 28 | ("url", data["data"]["attributes"]["url"])) 29 | response.append(', ') 30 | else: 31 | return "'{}' not found on the streamer's profile!".format( 32 | service) 33 | 34 | return MessagePacket(*response[:-1]) 35 | else: 36 | social = await self.api.get_social() 37 | if social.status == 200: 38 | data = await social.json() 39 | 40 | for service in data["data"]: 41 | response.append( 42 | service["attributes"]["service"].title() + ': ') 43 | response.append(("url", service["attributes"]["url"])) 44 | response.append(', ') 45 | return MessagePacket(*response[:-1]) 46 | else: 47 | return "'{}' not found on the streamer's profile!".format( 48 | service) 49 | 50 | @Command.command() 51 | async def add(self, service, url): 52 | """Add a social service.""" 53 | 54 | response = await self.api.add_social(service, url) 55 | if response.status == 201: 56 | return "Added social service {}.".format(service) 57 | elif response.status == 200: 58 | return "Updated social service {}".format(service) 59 | 60 | @Command.command() 61 | async def remove(self, service): 62 | """Remove a social service.""" 63 | 64 | response = await self.api.remove_social(service) 65 | if response.status == 200: 66 | return "Removed social service {}.".format(service) 67 | elif response.status == 404: 68 | return "Social service {} doesn't exist!".format(service) 69 | -------------------------------------------------------------------------------- /cactusbot/commands/magic/trust.py: -------------------------------------------------------------------------------- 1 | """Trust command.""" 2 | 3 | import aiohttp 4 | 5 | from ...packets import MessagePacket 6 | from ..command import Command 7 | 8 | BASE_URL = "https://beam.pro/api/v1/channels/{username}" 9 | 10 | 11 | async def check_user(username): 12 | if username.startswith('@'): 13 | username = username[1:] 14 | async with aiohttp.get(BASE_URL.format(username=username)) as response: 15 | if response.status == 404: 16 | raise NameError 17 | return (username, (await response.json())["id"]) 18 | 19 | 20 | class Trust(Command): 21 | """Trust command.""" 22 | 23 | COMMAND = "trust" 24 | 25 | @Command.command(hidden=True) 26 | async def default(self, username: check_user): 27 | """Toggle a trust.""" 28 | 29 | user, user_id = username 30 | 31 | is_trusted = (await self.api.get_trust(user_id)).status == 200 32 | 33 | if is_trusted: 34 | await self.api.remove_trust(user_id) 35 | else: 36 | await self.api.add_trust(user_id, user) 37 | 38 | return MessagePacket( 39 | ("tag", user), " is {modifier} trusted.".format( 40 | modifier=("now", "no longer")[is_trusted])) 41 | 42 | @Command.command() 43 | async def add(self, username: check_user): 44 | """Add a trusted user.""" 45 | 46 | user, user_id = username 47 | 48 | response = await self.api.add_trust(user_id, user) 49 | 50 | if response.status in (201, 200): 51 | return MessagePacket("User ", ("tag", user), " has been trusted.") 52 | 53 | @Command.command() 54 | async def remove(self, username: check_user): 55 | """Remove a trusted user.""" 56 | 57 | user, user_id = username 58 | 59 | response = await self.api.remove_trust(user_id) 60 | 61 | if response.status == 200: 62 | return MessagePacket("Removed trust for user ", ("tag", user), '.') 63 | else: 64 | return MessagePacket(("tag", user), " is not a trusted user.") 65 | 66 | @Command.command("list") 67 | async def list_trusts(self): 68 | """Get the trused users in a channel.""" 69 | 70 | data = await (await self.api.get_trust()).json() 71 | 72 | if not data["data"]: 73 | return "No trusted users." 74 | 75 | return "Trusted users: {}.".format(', '.join( 76 | user["attributes"]["userName"] for user in data["data"])) 77 | -------------------------------------------------------------------------------- /cactusbot/commands/magic/uptime.py: -------------------------------------------------------------------------------- 1 | """Uptime command.""" 2 | 3 | import datetime 4 | 5 | import aiohttp 6 | 7 | from . import Command 8 | 9 | 10 | class Uptime(Command): 11 | """Uptime command.""" 12 | 13 | COMMAND = "uptime" 14 | 15 | BEAM_MANIFEST_URL = ("https://beam.pro/api/v1/channels/{channel}" 16 | "/manifest.light2") 17 | 18 | @Command.command(hidden=True) 19 | async def default(self, *, channel: "channel"): 20 | """Default response.""" 21 | 22 | response = await (await aiohttp.get( 23 | "https://beam.pro/api/v1/channels/{}".format(channel) 24 | )).json() 25 | 26 | if "id" in response: 27 | data = await (await aiohttp.get( 28 | self.BEAM_MANIFEST_URL.format(channel=response["id"]) 29 | )).json() 30 | 31 | if "startedAt" in data: 32 | time = datetime.datetime.utcnow() - datetime.datetime.strptime( 33 | data["startedAt"], "%Y-%m-%dT%H:%M:%S.%fZ") 34 | time -= datetime.timedelta(microseconds=time.microseconds) 35 | return "Channel has been live for {}.".format(time) 36 | 37 | return "Channel is offline." 38 | -------------------------------------------------------------------------------- /cactusbot/handler.py: -------------------------------------------------------------------------------- 1 | """Handle handlers.""" 2 | 3 | import logging 4 | 5 | from .packets import MessagePacket, Packet 6 | 7 | 8 | class Handlers(object): 9 | """Evented controller for individual handlers. 10 | 11 | For a method to have the ability to be used as an event handler, it must 12 | be prefixed with `on_`, and then followed by the event name. 13 | This method gets a single argument of packet. 14 | 15 | Packet can be the following types: 16 | 17 | ================= ==================== 18 | Event Packet Type 19 | ================= ==================== 20 | `message` :obj:`MessagePacket` 21 | `follow` :obj:`EventPacket` 22 | `subscribe` :obj:`EventPacket` 23 | `host` :obj:`EventPacket` 24 | `join` :obj:`EventPacket` 25 | `leave` :obj:`EventPacket` 26 | `repeat` :obj:`MessagePacket` 27 | `config` :obj:`Packet` 28 | `username_update` :obj:`Packet` 29 | ================= ==================== 30 | 31 | Other events will be of the packet type `Packet`. 32 | 33 | Parameters 34 | ---------- 35 | handlers : :obj:`Handler` 36 | Tuple of handlers that contain events. 37 | 38 | Examples 39 | -------- 40 | 41 | >>> class TestingHandler(Handler): 42 | ... async def on_message(self, packet): 43 | ... self.logger.info(packet) 44 | ... 45 | >>> handlers = Handlers(TestingHandler) 46 | >>> async def handle(): 47 | ... await handlers.handle("message", MessagePacket("Message!")) 48 | ... 49 | 50 | """ 51 | 52 | def __init__(self, *handlers): 53 | self.logger = logging.getLogger(__name__) 54 | 55 | self.handlers = handlers 56 | 57 | async def handle(self, event, packet): 58 | """Handle incoming data. 59 | 60 | Parameters 61 | ---------- 62 | event : :obj:`str` 63 | The event that should be handled 64 | packet : :obj:`Packet` 65 | The packet to send to the handler function 66 | 67 | Examples 68 | -------- 69 | >>> async def handle(): 70 | ... await handlers.handle("message", MessagePacket("Message!")) 71 | """ 72 | 73 | result = [] 74 | 75 | for handler in self.handlers: 76 | if hasattr(handler, "on_" + event): 77 | try: 78 | response = await getattr(handler, "on_" + event)(packet) 79 | except Exception: 80 | self.logger.warning( 81 | "Exception in handler %s:", type(handler).__name__, 82 | exc_info=1) 83 | else: 84 | for packet in self.translate(response, handler): 85 | if packet is StopIteration: 86 | return result 87 | result.append(packet) 88 | # TODO: In Python 3.6, with asynchronous generators: 89 | # yield packet 90 | 91 | return result 92 | 93 | def translate(self, packet, handler): 94 | """Translate :obj:`Handler` responses to :obj:`Packet`. 95 | 96 | Parameters 97 | ---------- 98 | packet : :obj:`Packet`, :obj:`str`, :obj:`tuple`, :obj:`list`, :exc:`S\ 99 | topIteration`, or :obj:`None` 100 | The packet to turn the handler response into 101 | - :obj:`Packet` is immediately yielded. 102 | - :obj:`str` is converted into a text field in a 103 | :obj:`MessagePacket`. 104 | - :obj:`tuple` or :obj:`list` is iterated over, passing each 105 | item through :meth:`translate` again. 106 | - :exc:`StopIteration` signifies that no future packets should be 107 | yielded, stopping the chain. 108 | - :obj:`None` is ignored, and is never yielded. 109 | handler : :obj:`Handler` 110 | The handler response to turn into a packet 111 | 112 | Examples 113 | -------- 114 | >>> handlers = Handlers() 115 | >>> translated = handlers.translate("Hello!", Handler()) 116 | >>> [(item.__class__.__name__, item.text) for item in translated] 117 | [('MessagePacket', 'Hello!')] 118 | 119 | >>> handlers = Handlers() 120 | >>> translated = handlers.translate(["Potato?", "Potato!"], Handler()) 121 | >>> [(item.__class__.__name__, item.text) for item in translated] 122 | [('MessagePacket', 'Potato?'), ('MessagePacket', 'Potato!')] 123 | 124 | >>> handlers = Handlers() 125 | >>> translated = handlers.translate( 126 | ... ["Stop spamming.", StopIteration, "Nice message!"], 127 | ... Handler() 128 | ... ) 129 | >>> [(item.__class__.__name__, item.text) for item in translated] 130 | [('MessagePacket', 'Stop spamming.')] 131 | """ 132 | 133 | if isinstance(packet, Packet): 134 | yield packet 135 | elif isinstance(packet, (tuple, list)): 136 | for component in packet: 137 | for item in self.translate(component, handler): 138 | if item is StopIteration: 139 | return item 140 | yield item 141 | elif isinstance(packet, str): 142 | yield MessagePacket(packet) 143 | elif packet is StopIteration: 144 | yield packet 145 | elif packet is None: 146 | pass 147 | else: 148 | self.logger.warning("Invalid return type from %s: %s", 149 | type(handler).__name__, type(packet).__name__) 150 | 151 | 152 | class Handler(object): 153 | """Parent class to all event handlers. 154 | 155 | Examples 156 | -------- 157 | >>> class TestingHandler: 158 | ... def on_message(self, packet): 159 | ... self.logger.info(packet) 160 | ... 161 | 162 | """ 163 | 164 | def __init__(self): 165 | self.logger = logging.getLogger(__name__) 166 | -------------------------------------------------------------------------------- /cactusbot/handlers/__init__.py: -------------------------------------------------------------------------------- 1 | """Handlers.""" 2 | 3 | from .command import CommandHandler 4 | from .logging import LoggingHandler 5 | from .spam import SpamHandler 6 | from .events import EventHandler 7 | from .respond import ResponseHandler 8 | 9 | __all__ = ["CommandHandler", "LoggingHandler", "SpamHandler", "EventHandler", 10 | "ResponseHandler"] 11 | -------------------------------------------------------------------------------- /cactusbot/handlers/command.py: -------------------------------------------------------------------------------- 1 | """Handle commands.""" 2 | 3 | import random 4 | 5 | from ..commands import COMMANDS 6 | from ..commands.command import ROLES 7 | from ..handler import Handler 8 | from ..packets import MessagePacket 9 | 10 | 11 | class CommandHandler(Handler): 12 | """Command handler.""" 13 | 14 | ARGN_EXPR = r'%ARG(\d+)(?:=([^|]+))?(?:((?:\|\w+)+))?%' 15 | ARGS_EXPR = r'%ARGS(?:=([^|]+))?(?:((?:\|\w+)+))?%' 16 | MODIFIERS = { 17 | "upper": str.upper, 18 | "lower": str.lower, 19 | "title": str.title, 20 | "reverse": lambda text: text[::-1], 21 | "tag": lambda tag: tag[1:] if tag[0] == '@' and len(tag) > 1 else tag, 22 | "shuffle": lambda text: ''.join(random.sample(text, len(text))) 23 | } 24 | 25 | def __init__(self, channel, api): 26 | super().__init__() 27 | 28 | self.channel = channel 29 | self.api = api 30 | 31 | self.magics = {command.COMMAND: command(api) for command in COMMANDS} 32 | 33 | async def on_message(self, packet): 34 | """Handle message events.""" 35 | 36 | if packet.target and packet.text == "/cry": 37 | return MessagePacket( 38 | "cries with ", ("tag", packet.user), action=True) 39 | 40 | if len(packet) > 1 and packet[0] == "!" and packet[1] != ' ': 41 | 42 | command, *args = packet[1:].text.split() 43 | 44 | data = { 45 | "username": packet.user, 46 | "channel": self.channel, 47 | "packet": packet 48 | } 49 | 50 | if command in self.magics: 51 | 52 | response = await self.magics[command](*args, **data) 53 | 54 | if packet.target and response: 55 | if not isinstance(response, MessagePacket): 56 | response = MessagePacket(response) 57 | response.target = packet.user 58 | 59 | return response 60 | 61 | else: 62 | 63 | split = packet.split() 64 | hyphenated_options = (((split[:index]), split[index:]) 65 | for index in range(len(split), 0, -1)) 66 | 67 | for command, args in hyphenated_options: 68 | 69 | command = MessagePacket.join(*command, 70 | separator='-').text[1:] 71 | args = tuple(arg.text for arg in args) 72 | 73 | response = await self.custom_response( 74 | packet, command, *args, **data) 75 | 76 | if response is not None: 77 | return response 78 | 79 | return MessagePacket("Command not found.", target=packet.user) 80 | 81 | async def custom_response(self, _packet, command, *args, **data): 82 | 83 | args = (command, *args) 84 | 85 | response = await self.api.get_command(command) 86 | 87 | if response.status != 200: 88 | return 89 | 90 | json = await response.json() 91 | 92 | if json["data"].get("type") == "aliases": 93 | 94 | command = json["data"]["attributes"]["commandName"] 95 | 96 | if "arguments" in json["data"]["attributes"]: 97 | args = (args[0], *tuple(MessagePacket( 98 | *json["data"]["attributes"]["arguments"] 99 | ).text.split()), *args[1:]) 100 | 101 | json = json["data"]["attributes"] 102 | 103 | if not json.get("enabled", True): 104 | return MessagePacket("Command is disabled.", target=_packet.user) 105 | 106 | if _packet.role < json["response"]["role"]: 107 | return MessagePacket( 108 | "Role level '{role}' or higher required.".format( 109 | role=ROLES[max(k for k in ROLES.keys() 110 | if k <= json["response"]["role"])]), 111 | target=_packet.user if _packet.target else None 112 | ) 113 | 114 | json["response"]["target"] = _packet.user if _packet.target else None 115 | 116 | await self.api.update_command_count(command, "+1") 117 | if "count" not in data: 118 | data["count"] = str(json["count"] + 1) 119 | 120 | return self._inject(MessagePacket.from_json(json["response"]), 121 | *args, **data) 122 | 123 | def _inject(self, _packet, *args, **data): 124 | """Inject targets into a packet.""" 125 | 126 | def sub_argn(match): 127 | """Substitute an argument in place of an ARGN target.""" 128 | 129 | argn, default, modifiers = match.groups() 130 | argn = int(argn) 131 | 132 | if default is None: 133 | result = args[argn] 134 | else: 135 | result = args[argn] if argn < len(args) else default 136 | 137 | if modifiers is not None: 138 | result = self._modify(result, *modifiers.split('|')[1:]) 139 | 140 | return result 141 | 142 | try: 143 | _packet.sub(self.ARGN_EXPR, sub_argn) 144 | except IndexError: 145 | return MessagePacket("Not enough arguments!") 146 | 147 | def sub_args(match): 148 | """Substitute all arguments in place of the ARGS target.""" 149 | 150 | default, modifiers = match.groups() 151 | 152 | if not args[1:] and default is not None: 153 | result = default 154 | else: 155 | result = ' '.join(args[1:]) 156 | 157 | if modifiers is not None: 158 | result = self._modify(result, *modifiers.split('|')[1:]) 159 | 160 | return result 161 | 162 | if "%ARGS%" in _packet and len(args) < 2: 163 | return MessagePacket("Not enough arguments!") 164 | 165 | _packet.sub(self.ARGS_EXPR, sub_args) 166 | 167 | _packet.replace(**{ 168 | "%USER%": data.get("username"), 169 | "%COUNT%": data.get("count"), 170 | "%CHANNEL%": data.get("channel") 171 | }) 172 | 173 | return _packet 174 | 175 | def _modify(self, argument, *modifiers): 176 | """Apply modifiers to an argument.""" 177 | 178 | for modifier in modifiers: 179 | if modifier in self.MODIFIERS: 180 | argument = self.MODIFIERS[modifier](argument) 181 | 182 | return argument 183 | 184 | async def on_repeat(self, packet): 185 | return packet 186 | -------------------------------------------------------------------------------- /cactusbot/handlers/events.py: -------------------------------------------------------------------------------- 1 | """Handle events""" 2 | 3 | import time 4 | 5 | from ..handler import Handler 6 | from ..packets import MessagePacket 7 | 8 | 9 | class EventHandler(Handler): 10 | """Events handler.""" 11 | 12 | def __init__(self, cache_data, api): 13 | super().__init__() 14 | 15 | self.cache_data = cache_data 16 | self.cached_events = {} 17 | 18 | self.api = api 19 | 20 | self.cached_events = { 21 | "follow": {}, 22 | "join": {}, 23 | "leave": {}, 24 | "host": {} 25 | } 26 | 27 | self.alert_messages = { 28 | "follow": { 29 | "announce": True, 30 | "message": "Thanks for following, %USER%!" 31 | }, 32 | "subscribe": { 33 | "announce": True, 34 | "message": "Thanks for subscribing, %USER%!" 35 | }, 36 | "host": { 37 | "announce": True, 38 | "message": "Thanks for hosting, %USER%!" 39 | }, 40 | "join": { 41 | "announce": False, 42 | "message": "Welcome to the channel, %USER%!" 43 | }, 44 | "leave": { 45 | "announce": False, 46 | "message": "Thanks for watching, %USER%!" 47 | } 48 | } 49 | 50 | async def load_messages(self): 51 | """Load alert messages.""" 52 | 53 | data = await (await self.api.get_config()).json() 54 | 55 | messages = data["data"]["attributes"]["announce"] 56 | 57 | self.alert_messages = { 58 | "follow": messages["follow"], 59 | "subscribe": messages["sub"], 60 | "host": messages["host"], 61 | "join": messages["join"], 62 | "leave": messages["leave"] 63 | } 64 | 65 | async def on_start(self, _): 66 | """Handle start packets.""" 67 | 68 | await self.load_messages() 69 | 70 | return MessagePacket("CactusBot activated. ", ("emoji", "🌵")) 71 | 72 | async def on_follow(self, packet): 73 | """Handle follow packets.""" 74 | 75 | if not self.alert_messages["follow"]["announce"]: 76 | return 77 | 78 | return await self._cache(packet, "follow") 79 | 80 | async def on_subscribe(self, packet): 81 | """Handle subscription packets.""" 82 | 83 | if self.alert_messages["subscribe"]["announce"]: 84 | return MessagePacket( 85 | self.alert_messages["subscribe"]["message"].replace( 86 | "%USER%", packet.user 87 | )) 88 | 89 | async def on_host(self, packet): 90 | """Handle host packets.""" 91 | 92 | if not self.alert_messages["host"]["announce"]: 93 | return 94 | 95 | return await self._cache(packet, "host") 96 | 97 | async def on_join(self, packet): 98 | """Handle join packets.""" 99 | 100 | if not self.alert_messages["join"]["announce"]: 101 | return 102 | 103 | return await self._cache(packet, "join") 104 | 105 | async def on_leave(self, packet): 106 | """Handle leave packets.""" 107 | 108 | if not self.alert_messages["leave"]["announce"]: 109 | return 110 | 111 | return await self._cache(packet, "leave") 112 | 113 | async def on_config(self, packet): 114 | """Handle config update events.""" 115 | 116 | values = packet.kwargs["values"] 117 | if packet.kwargs["key"] == "announce": 118 | self.alert_messages = { 119 | "follow": values["follow"], 120 | "subscribe": values["sub"], 121 | "host": values["host"], 122 | "join": values["join"], 123 | "leave": values["leave"] 124 | } 125 | 126 | async def _cache(self, packet, event): 127 | response = MessagePacket( 128 | self.alert_messages[event]["message"].replace( 129 | "%USER%", packet.user 130 | )) 131 | 132 | if packet.success: 133 | if self.cache_data["cache_{}".format(event)]: 134 | user = packet.user 135 | if user in self.cached_events[event]: 136 | since = time.time() - self.cached_events[event][user] 137 | if since >= self.cache_data["cache_time"]: 138 | self.cached_events[event][user] = time.time() 139 | return response 140 | else: 141 | self.cached_events[event][user] = time.time() 142 | return response 143 | else: 144 | return response 145 | return None 146 | -------------------------------------------------------------------------------- /cactusbot/handlers/logging.py: -------------------------------------------------------------------------------- 1 | """Handle logging.""" 2 | 3 | from ..handler import Handler 4 | 5 | 6 | class LoggingHandler(Handler): 7 | """Logging handler.""" 8 | 9 | async def on_message(self, packet): 10 | """Handle message events.""" 11 | self.logger.info("%s: %s", packet.user, packet.text) 12 | 13 | async def on_join(self, packet): 14 | """Handle user join events.""" 15 | self.logger.info("%s joined", packet.user) 16 | 17 | async def on_leave(self, packet): 18 | """Handle user join events.""" 19 | self.logger.info("%s left", packet.user) 20 | 21 | async def on_follow(self, packet): 22 | """Handle follow events.""" 23 | self.logger.info("%s followed", packet.user) 24 | 25 | async def on_subscribe(self, packet): 26 | """Handle subscription events.""" 27 | self.logger.info("%s subscribed", packet.user) 28 | 29 | async def on_resubscribe(self, packet): 30 | """Handle resubscription events.""" 31 | self.logger.info("%s resubscribed", packet.user) 32 | 33 | async def on_host(self, packet): 34 | """Handle host events.""" 35 | self.logger.info("%s hosted", packet.user) 36 | -------------------------------------------------------------------------------- /cactusbot/handlers/respond.py: -------------------------------------------------------------------------------- 1 | """Handle bot responses.""" 2 | 3 | from ..handler import Handler 4 | 5 | 6 | class ResponseHandler(Handler): 7 | """Handle bot responses.""" 8 | 9 | def __init__(self): 10 | super().__init__() 11 | 12 | self.username = "" 13 | 14 | async def on_username_update(self, packet): 15 | """Set the username of the bot.""" 16 | self.username = packet.json["username"] 17 | 18 | async def on_message(self, packet): 19 | """Handler message events.""" 20 | 21 | if packet.user.lower() == self.username.lower(): 22 | return StopIteration 23 | -------------------------------------------------------------------------------- /cactusbot/handlers/spam.py: -------------------------------------------------------------------------------- 1 | """Handle incoming spam messages.""" 2 | 3 | import aiohttp 4 | 5 | from ..handler import Handler 6 | from ..packets import BanPacket, MessagePacket 7 | 8 | BASE_URL = "https://beam.pro/api/v1/channels/{username}" 9 | 10 | 11 | async def get_user_id(username): 12 | async with aiohttp.get(BASE_URL.format(username=username)) as response: 13 | if response.status == 404: 14 | return 0 15 | return (await response.json())["id"] 16 | 17 | 18 | class SpamHandler(Handler): 19 | """Spam handler.""" 20 | 21 | def __init__(self, api): 22 | super().__init__() 23 | 24 | self.api = api 25 | 26 | self.config = { 27 | "max_score": 16, 28 | "max_emoji": 6, 29 | "allow_urls": False 30 | } 31 | 32 | async def on_message(self, packet): 33 | """Handle message events.""" 34 | 35 | if packet.role >= 4: 36 | return 37 | 38 | user_id = await get_user_id(packet.user) 39 | if (await self.api.get_trust(user_id)).status == 200: 40 | return 41 | 42 | exceeds_caps = self.check_caps(''.join( 43 | chunk.text for chunk in packet if 44 | chunk.type == "text" 45 | )) 46 | exceeds_emoji = self.check_emoji(packet) 47 | contains_urls = self.contains_urls(packet) 48 | 49 | if exceeds_caps: 50 | return (MessagePacket("Please do not spam capital letters.", 51 | target=packet.user), 52 | BanPacket(packet.user, 1), 53 | StopIteration) 54 | elif exceeds_emoji: 55 | return (MessagePacket("Please do not spam emoji.", 56 | target=packet.user), 57 | BanPacket(packet.user, 1), 58 | StopIteration) 59 | elif contains_urls: 60 | return (MessagePacket("Please do not post URLs.", 61 | target=packet.user), 62 | BanPacket(packet.user, 5), 63 | StopIteration) 64 | else: 65 | return None 66 | 67 | async def on_config(self, packet): 68 | """Handle config update events.""" 69 | 70 | if packet.kwargs["key"] == "spam": 71 | self.config["max_emoji"] = packet.kwargs["values"]["maxEmoji"] 72 | self.config["max_score"] = packet.kwargs["values"]["maxCapsScore"] 73 | self.config["allow_urls"] = packet.kwargs["values"]["allowUrls"] 74 | 75 | def check_caps(self, message): 76 | """Check for excessive capital characters in the message.""" 77 | return sum(char.isupper() - char.islower() for 78 | char in message) > self.config["max_score"] 79 | 80 | def check_emoji(self, packet): 81 | """Check for excessive emoji in the message.""" 82 | return sum(chunk.type == "emoji" for 83 | chunk in packet) > self.config["max_emoji"] 84 | 85 | def contains_urls(self, packet): 86 | """Check for URLs in the message.""" 87 | return not self.config["allow_urls"] and any( 88 | chunk.type == "url" for chunk in packet) 89 | -------------------------------------------------------------------------------- /cactusbot/packets/__init__.py: -------------------------------------------------------------------------------- 1 | from .ban import BanPacket 2 | from .event import EventPacket 3 | from .message import MessagePacket 4 | from .packet import Packet 5 | 6 | __all__ = ["BanPacket", "EventPacket", "MessagePacket", "Packet"] 7 | -------------------------------------------------------------------------------- /cactusbot/packets/ban.py: -------------------------------------------------------------------------------- 1 | """Ban packet.""" 2 | 3 | from .packet import Packet 4 | 5 | 6 | class BanPacket(Packet): 7 | """Packet to store bans. 8 | 9 | Parameters 10 | ---------- 11 | user : :obj:`str` 12 | User identifier. 13 | duration : :obj:`int`, optional 14 | The length of time for which the ban lasts, in seconds. 15 | 16 | If set to ``0``, the ban lasts for an unlimited amount of time. 17 | """ 18 | 19 | def __init__(self, user, duration=0): 20 | super().__init__() 21 | 22 | self.user = user 23 | self.duration = duration 24 | 25 | def __str__(self): 26 | if self.duration: 27 | return "".format(self.user, self.duration) 28 | return "".format(self.user) 29 | 30 | @property 31 | def json(self): 32 | """JSON representation of the packet. 33 | 34 | Returns 35 | ------- 36 | :obj:`dict` 37 | Object attributes, in a JSON-compatible format. 38 | 39 | Examples 40 | -------- 41 | >>> import pprint 42 | >>> pprint.pprint(BanPacket("Stanley", 60).json) 43 | {'duration': 60, 'user': 'Stanley'} 44 | """ 45 | return { 46 | "user": self.user, 47 | "duration": self.duration 48 | } 49 | -------------------------------------------------------------------------------- /cactusbot/packets/event.py: -------------------------------------------------------------------------------- 1 | """Event packet.""" 2 | 3 | from .packet import Packet 4 | 5 | 6 | class EventPacket(Packet): 7 | """Packet to store events. 8 | 9 | Parameters 10 | ---------- 11 | event_type : :obj:`str` 12 | Event type. 13 | user : :obj:`str` 14 | User identifier. 15 | success : :obj:`bool` 16 | Whether or not the event was positive or successful. 17 | """ 18 | 19 | def __init__(self, event_type, user, success=True, streak=1): 20 | super().__init__() 21 | 22 | self.event_type = event_type 23 | self.user = user 24 | self.success = success 25 | self.streak = streak 26 | 27 | def __str__(self): 28 | return "".format(self.user, self.event_type) 29 | 30 | @property 31 | def json(self): 32 | """JSON representation of the packet. 33 | 34 | Returns 35 | ------- 36 | :obj:`dict` 37 | Object attributes, in a JSON-compatible format. 38 | 39 | Examples 40 | -------- 41 | >>> import pprint 42 | >>> pprint.pprint(EventPacket("follow", "Stanley").json) 43 | {'event': 'follow', 'streak': 1, 'success': True, 'user': 'Stanley'} 44 | """ 45 | return { 46 | "user": self.user, 47 | "event": self.event_type, 48 | "success": self.success, 49 | "streak": self.streak 50 | } 51 | -------------------------------------------------------------------------------- /cactusbot/packets/message.py: -------------------------------------------------------------------------------- 1 | """Message packet.""" 2 | 3 | import re 4 | from collections import namedtuple 5 | 6 | from .packet import Packet 7 | 8 | 9 | class MessageComponent(namedtuple("Component", ("type", "data", "text"))): 10 | """:obj:`MessagePacket` component. 11 | 12 | Valid Types: 13 | 14 | ========= ===================================== =================== 15 | Type Description Sample Data 16 | ========= ===================================== =================== 17 | text Plaintext of any length. Hello, world. 18 | emoji Single emoji. 🌵 19 | tag Single user tag or mention. Username 20 | url URL. https://google.com 21 | variable Key to be replaced with live values. %ARGS% 22 | ========= ===================================== =================== 23 | 24 | Parameters 25 | ---------- 26 | type : :obj:`str` 27 | Component type. 28 | data : :obj:`str` 29 | Component data. 30 | text : :obj:`str` 31 | Text representation of the component. 32 | """ 33 | 34 | 35 | class MessagePacket(Packet): 36 | """Packet to store messages. 37 | 38 | Parameters 39 | ---------- 40 | message : :obj:`dict`, :obj:`tuple`, :obj:`str`, or :obj:`MessageComponent` 41 | Message content components. 42 | 43 | :obj:`dict` should contain ``"type"``, ``"data"``, and ``"text"`` keys. 44 | 45 | :obj:`tuple` will be interpreted as ``(type, data, text)``. If not 46 | supplied, ``text`` will be equivalent to ``data``. 47 | 48 | :obj:`str` will be interpreted as a component with ``type`` text. 49 | user : :obj:`str` 50 | The sender of the MessagePacket. 51 | role : :obj:`int` 52 | The role ID of the sender. 53 | action : :obj:`bool` 54 | Whether or not the message was sent in action form. 55 | target : :obj:`str` or :obj:`None` 56 | The single user target of the message. 57 | """ 58 | 59 | def __init__(self, *message, user="", role=1, action=False, target=None): 60 | super().__init__() 61 | 62 | message = list(message) 63 | for index, chunk in enumerate(message): 64 | if isinstance(chunk, dict): 65 | message[index] = MessageComponent(**chunk) 66 | elif isinstance(chunk, tuple): 67 | if len(chunk) == 2: 68 | chunk = chunk + (chunk[1],) 69 | message[index] = MessageComponent(*chunk) 70 | elif isinstance(chunk, str): 71 | message[index] = MessageComponent("text", chunk, chunk) 72 | 73 | self.message = message 74 | self._condense() 75 | 76 | self.user = user 77 | self.role = role 78 | self.action = action 79 | self.target = target 80 | 81 | def __str__(self): 82 | return "".format(self.user, self.text) 83 | 84 | def __len__(self): 85 | return len(''.join( 86 | chunk.text for chunk in self.message 87 | if chunk.type == "text" 88 | )) 89 | 90 | def __getitem__(self, key): 91 | 92 | if isinstance(key, int): 93 | return ''.join( 94 | chunk.text for chunk in self.message 95 | if chunk.type == "text" 96 | )[key] 97 | 98 | elif isinstance(key, slice): 99 | 100 | if key.stop is not None or key.step is not None: 101 | raise NotImplementedError # TODO 102 | 103 | count = key.start or 0 104 | message = self.message.copy() 105 | 106 | for index, component in enumerate(message.copy()): 107 | if component.type == "text": 108 | if len(component.text) <= count: 109 | count -= len(component.text) 110 | message.pop(0) 111 | else: 112 | while count > 0: 113 | new_text = component.text[1:] 114 | component = message[index] = component._replace( 115 | text=new_text, data=new_text) 116 | count -= 1 117 | else: 118 | message.pop(0) 119 | if count == 0: 120 | return self.copy(*message) 121 | return self.copy(*message) 122 | 123 | raise TypeError 124 | 125 | def __contains__(self, item): 126 | for chunk in self.message: 127 | if chunk.type == "text" and item in chunk.text: 128 | return True 129 | return False 130 | 131 | def __iter__(self): 132 | return self.message.__iter__() 133 | 134 | def __add__(self, other): 135 | 136 | return MessagePacket( 137 | *(self.message + other.message), 138 | user=self.user or other.user, 139 | role=self.role or other.role, 140 | action=self.action or other.action, 141 | target=self.target or other.target 142 | ) 143 | 144 | def _condense(self): 145 | 146 | message = [self.message[0]] 147 | 148 | for component in self.message[1:]: 149 | 150 | if message[-1].type == component.type == "text": 151 | new_text = message[-1].text + component.text 152 | message[-1] = message[-1]._replace( 153 | data=new_text, text=new_text) 154 | else: 155 | message.append(component) 156 | 157 | self.message = message 158 | 159 | return self 160 | 161 | @property 162 | def text(self): 163 | """Pure text representation of the packet. 164 | 165 | Returns 166 | ------- 167 | :obj:`str` 168 | Joined ``text`` of every component. 169 | 170 | Examples 171 | -------- 172 | >>> MessagePacket("Hello, world! ", ("emoji", "😃")).text 173 | 'Hello, world! 😃' 174 | """ 175 | return ''.join(chunk.text for chunk in self.message) 176 | 177 | @property 178 | def json(self): 179 | """JSON representation of the packet. 180 | 181 | Returns 182 | ------- 183 | :obj:`dict` 184 | Object attributes, in a JSON-compatible format. 185 | 186 | Examples 187 | -------- 188 | >>> import pprint 189 | >>> pprint.pprint(MessagePacket("Hello, world! ", ("emoji", "😃")).json) 190 | {'action': False, 191 | 'message': [{'data': 'Hello, world! ', 192 | 'text': 'Hello, world! ', 193 | 'type': 'text'}, 194 | {'data': '😃', 'text': '😃', 'type': 'emoji'}], 195 | 'role': 1, 196 | 'target': None, 197 | 'user': ''} 198 | """ 199 | return { 200 | "message": [ 201 | dict(component._asdict()) for component in self.message 202 | ], 203 | "user": self.user, 204 | "role": self.role, 205 | "action": self.action, 206 | "target": self.target 207 | } 208 | 209 | @classmethod 210 | def from_json(cls, json): 211 | """Convert :obj:`MessagePacket` JSON into an object. 212 | 213 | Parameters 214 | ---------- 215 | json : :obj:`dict` 216 | The JSON dictionary to convert. 217 | 218 | Returns 219 | ------- 220 | :obj:`MessagePacket` 221 | 222 | Examples 223 | -------- 224 | >>> MessagePacket.from_json({ 225 | ... 'action': False, 226 | ... 'message': [{'type': 'text', 227 | ... 'data': 'Hello, world! ', 228 | ... 'text': 'Hello, world! '}, 229 | ... {'data': '😃', 'text': '😃', 'type': 'emoji'}], 230 | ... 'role': 1, 231 | ... 'target': None, 232 | ... 'user': '' 233 | ... }).text 234 | 'Hello, world! 😃' 235 | """ 236 | return cls(*json.pop("message"), **json) 237 | 238 | def copy(self, *args, **kwargs): 239 | """Return a copy of :obj:`self`. 240 | 241 | Parameters 242 | ---------- 243 | *args 244 | If any are provided, will entirely override :attr:`self.message`. 245 | **kwargs 246 | Each will override class attributes provided in :func:`__init__`. 247 | 248 | Returns 249 | ------- 250 | :obj:`MessagePacket` 251 | Copy of :obj:`self`, with replaced attributes as specified in 252 | ``args`` and ``kwargs``. 253 | """ 254 | 255 | _args = args or self.message 256 | 257 | _kwargs = { 258 | "user": self.user, 259 | "role": self.role, 260 | "action": self.action, 261 | "target": self.target 262 | } 263 | _kwargs.update(kwargs) 264 | 265 | return MessagePacket(*_args, **_kwargs) 266 | 267 | def replace(self, **values): 268 | """Replace text in packet. 269 | 270 | Parameters 271 | ---------- 272 | values : :obj:`dict` 273 | The text to replace. 274 | 275 | Returns 276 | ------- 277 | :obj:`MessagePacket` 278 | :obj:`self`, with replaced text. 279 | 280 | Note 281 | ---- 282 | Modifies the object itself. Does *not* return a copy. 283 | 284 | Examples 285 | -------- 286 | >>> packet = MessagePacket("Hello, world!") 287 | >>> packet.replace(world="universe").text 288 | 'Hello, universe!' 289 | 290 | >>> packet = MessagePacket("Hello, world!") 291 | >>> packet.replace(**{ 292 | ... "Hello": "Goodbye", 293 | ... "world": "Python 2" 294 | ... }).text 295 | 'Goodbye, Python 2!' 296 | """ 297 | for index, chunk in enumerate(self.message): 298 | for old, new in values.items(): 299 | if new is not None: 300 | new_text = chunk.text.replace(old, new) 301 | new_data = new_text if chunk.type == "text" else chunk.data 302 | self.message[index] = self.message[index]._replace( 303 | data=new_data, text=new_text) 304 | chunk = self.message[index] 305 | return self 306 | 307 | def sub(self, pattern, repl): 308 | """Perform regex substitution on packet. 309 | 310 | Parameters 311 | ---------- 312 | pattern : :obj:`str` 313 | Regular expression to match. 314 | repl 315 | The replacement for the `pattern`. 316 | 317 | Accepts the same argument types as :func:`re.sub`. 318 | 319 | Returns 320 | ------- 321 | :obj:`MessagePacket` 322 | :obj:`self`, with replaced patterns. 323 | 324 | Note 325 | ---- 326 | Modifies the object itself. Does *not* return a copy. 327 | 328 | Examples 329 | -------- 330 | >>> packet = MessagePacket("I would like 3 ", ("emoji", "😃"), "s.") 331 | >>> packet.sub(r"\\d+", "").text 332 | 'I would like 😃s.' 333 | """ 334 | for index, chunk in enumerate(self.message): 335 | if chunk.type in ("text", "url"): 336 | self.message[index] = self.message[index]._replace( 337 | text=re.sub(pattern, repl, chunk.text)) 338 | return self 339 | 340 | def split(self, separator=' ', maximum=None): 341 | """Split into multiple MessagePackets, based on a separator. 342 | 343 | Parameters 344 | ---------- 345 | separator : :obj:`str`, default `' '` 346 | The characters to split the string with. 347 | maximum : :obj:`int` or :obj:`None` 348 | The maximum number of splits to perform. 349 | 350 | If less than the total number of potential splits, will result in a 351 | list of length `maximum + 1`. 352 | Otherwise, will perform all splits. 353 | 354 | If :obj:`None`, will perform all splits. 355 | 356 | Returns 357 | ------- 358 | :obj:`list` of :obj:`MessagePacket`s 359 | 360 | Examples 361 | -------- 362 | >>> packet = MessagePacket("0 1 2 3 4 5 6 7") 363 | >>> [component.text for component in packet.split()] 364 | ['0', '1', '2', '3', '4', '5', '6', '7'] 365 | 366 | >>> packet = MessagePacket("0 1 2 3 4 5 6 7") 367 | >>> [component.text for component in packet.split("2")] 368 | ['0 1 ', ' 3 4 5 6 7'] 369 | 370 | >>> packet = MessagePacket("0 1 2 3 4 5 6 7") 371 | >>> [component.text for component in packet.split(maximum=3)] 372 | ['0', '1', '2', '3 4 5 6 7'] 373 | """ 374 | 375 | result = [] 376 | components = [] 377 | 378 | if maximum is None: 379 | maximum = float('inf') 380 | 381 | for component in self: 382 | 383 | if len(result) == maximum: 384 | components.append(component) 385 | continue 386 | 387 | is_text = component.type == "text" 388 | if not is_text or separator not in component.text: 389 | components.append(component) 390 | continue 391 | 392 | new = MessageComponent("text", "", "") 393 | 394 | for index, character in enumerate(component.text): 395 | if len(result) == maximum: 396 | new_text = new.text + component.text[index:] 397 | new = new._replace(data=new_text, text=new_text) 398 | break 399 | 400 | if character == separator: 401 | components.append(new._replace()) 402 | result.append(components.copy()) 403 | components.clear() 404 | new = new._replace(data="", text="") 405 | else: 406 | new_text = new.text + character 407 | new = new._replace(data=new_text, text=new_text) 408 | 409 | components.append(new) 410 | 411 | result.append(components) 412 | 413 | result = [ 414 | filter(lambda component: component.text, message) 415 | for message in result 416 | if any(component.text for component in message) 417 | ] 418 | 419 | return [self.copy(*message) for message in result] 420 | 421 | @classmethod 422 | def join(cls, *packets, separator=''): 423 | """Join multiple message packets together. 424 | 425 | Parameters 426 | ---------- 427 | *packets : :obj:`MessagePacket` 428 | The packets to join. 429 | separator : str 430 | The string to place between every packet. 431 | 432 | Returns 433 | ------- 434 | :obj:`MessagePacket` 435 | Packet containing joined contents. 436 | 437 | Examples 438 | -------- 439 | >>> MessagePacket.join(MessagePacket("a"), MessagePacket("b"), Message\ 440 | Packet("c")).text 441 | 'abc' 442 | 443 | >>> MessagePacket.join(MessagePacket("a"), MessagePacket("b"), Message\ 444 | Packet("c"), separator='-').text 445 | 'a-b-c' 446 | """ 447 | 448 | if not packets: 449 | return MessagePacket("") 450 | 451 | result = packets[0] 452 | 453 | for packet in packets[1:]: 454 | 455 | result += MessagePacket(separator) 456 | result += packet 457 | 458 | return result 459 | -------------------------------------------------------------------------------- /cactusbot/packets/packet.py: -------------------------------------------------------------------------------- 1 | """Base packet.""" 2 | 3 | import json 4 | 5 | 6 | class Packet: 7 | """Base packet. 8 | 9 | May be used for packets which only require static attributes. 10 | 11 | Parameters 12 | ---------- 13 | packet_type : :obj:`str` or :obj:`None` 14 | The name for the packet type. If not specified, the class name is used. 15 | **kwargs 16 | Packet attributes. 17 | """ 18 | 19 | def __init__(self, packet_type=None, **kwargs): 20 | self.type = packet_type or type(self).__name__ 21 | self.kwargs = kwargs 22 | 23 | def __repr__(self): 24 | return '<{}: {}>'.format(self.type, json.dumps(self.json)) 25 | 26 | @property 27 | def json(self): 28 | """JSON representation of the packet. 29 | 30 | Returns 31 | ------- 32 | :obj:`dict` 33 | Object attributes, in a JSON-compatible format. 34 | 35 | Examples 36 | -------- 37 | >>> import pprint 38 | >>> pprint.pprint(Packet(key="key", value="value").json) 39 | {'key': 'key', 'value': 'value'} 40 | """ 41 | return self.kwargs 42 | -------------------------------------------------------------------------------- /cactusbot/sepal.py: -------------------------------------------------------------------------------- 1 | """Interact with Sepal.""" 2 | 3 | import json 4 | import logging 5 | 6 | from .packets import MessagePacket, Packet 7 | from .services.websocket import WebSocket 8 | 9 | 10 | class Sepal(WebSocket): 11 | """Interact with Sepal.""" 12 | 13 | def __init__(self, channel, service=None): 14 | super().__init__("wss://cactus.exoz.one/sepal") 15 | 16 | self.logger = logging.getLogger(__name__) 17 | 18 | self.channel = channel 19 | self.service = service 20 | self.parser = SepalParser() 21 | 22 | async def send(self, packet_type, **kwargs): 23 | """Send a packet to Sepal.""" 24 | 25 | packet = { 26 | "type": packet_type, 27 | "channel": self.channel 28 | } 29 | 30 | packet.update(kwargs) 31 | await super().send(json.dumps(packet)) 32 | 33 | async def initialize(self): 34 | """Send a subscribe packet.""" 35 | 36 | await self.send("subscribe") 37 | 38 | async def parse(self, packet): 39 | """Parse a Sepal packet.""" 40 | 41 | try: 42 | packet = json.loads(packet) 43 | except (TypeError, ValueError): 44 | self.logger.exception("Invalid JSON: %s.", packet) 45 | return None 46 | else: 47 | self.logger.debug(packet) 48 | return packet 49 | 50 | async def handle(self, packet): 51 | """Convert a JSON packet to a CactusBot packet.""" 52 | 53 | assert self.service is not None, "Must have a service to handle" 54 | 55 | if "event" not in packet: 56 | return 57 | 58 | event = packet["event"] 59 | 60 | if not hasattr(self.parser, "parse_" + event.lower()): 61 | return 62 | 63 | data = await getattr(self.parser, "parse_" + event)(packet) 64 | 65 | if data is None: 66 | return 67 | 68 | if isinstance(data, (list, tuple)): 69 | for packet in data: 70 | await self.service.handle(event, packet) 71 | else: 72 | await self.service.handle(event, data) 73 | 74 | 75 | class SepalParser: 76 | """Parse Sepal packets.""" 77 | 78 | async def parse_repeat(self, packet): 79 | """Parse the incoming repeat packets.""" 80 | 81 | if "response" in packet["data"]: 82 | return MessagePacket.from_json(packet["data"]["response"]) 83 | 84 | async def parse_config(self, packet): 85 | """Parse the incoming config packets.""" 86 | 87 | return [Packet("config", key=key, values=values) 88 | for key, values in packet["data"].items()] 89 | -------------------------------------------------------------------------------- /cactusbot/services/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | from .api import API 3 | from .websocket import WebSocket 4 | 5 | __all__ = ["API", "WebSocket"] 6 | -------------------------------------------------------------------------------- /cactusbot/services/api.py: -------------------------------------------------------------------------------- 1 | """Interact with a REST API.""" 2 | 3 | import json 4 | import logging 5 | from urllib.parse import urljoin 6 | 7 | from aiohttp import ClientHttpProcessingError, ClientSession 8 | 9 | 10 | class API(ClientSession): 11 | """Interact with a REST API.""" 12 | 13 | URL = None 14 | 15 | def __init__(self, **kwargs): 16 | super().__init__(**kwargs) 17 | 18 | self.logger = logging.getLogger(__name__) 19 | 20 | def _build(self, endpoint): 21 | return urljoin(self.URL, endpoint.lstrip('/')) 22 | 23 | async def request(self, method, endpoint, **kwargs): 24 | """Send HTTP request to an endpoint.""" 25 | 26 | url = self._build(endpoint) 27 | 28 | async with super().request(method, url, **kwargs) as response: 29 | try: 30 | text = await response.text() 31 | except json.decoder.JSONDecodeError: 32 | self.logger.warning("Response was not JSON!") 33 | self.logger.debug(response.text) 34 | raise ClientHttpProcessingError("Response was not JSON!") 35 | else: 36 | self.logger.debug( 37 | "{method} {endpoint} {data}:\n{code} {text}".format( 38 | method=method, endpoint=endpoint, data=kwargs, 39 | code=response.status, text=text)) 40 | return response 41 | 42 | async def get(self, endpoint, **kwargs): 43 | return await self.request("GET", endpoint, **kwargs) 44 | 45 | async def options(self, endpoint, **kwargs): 46 | return await self.request("OPTIONS", endpoint, **kwargs) 47 | 48 | async def head(self, endpoint, **kwargs): 49 | return await self.request("HEAD", endpoint, **kwargs) 50 | 51 | async def post(self, endpoint, **kwargs): 52 | return await self.request("POST", endpoint, **kwargs) 53 | 54 | async def put(self, endpoint, **kwargs): 55 | return await self.request("PUT", endpoint, **kwargs) 56 | 57 | async def patch(self, endpoint, **kwargs): 58 | return await self.request("PATCH", endpoint, **kwargs) 59 | 60 | async def delete(self, endpoint, **kwargs): 61 | return await self.request("DELETE", endpoint, **kwargs) 62 | -------------------------------------------------------------------------------- /cactusbot/services/beam/__init__.py: -------------------------------------------------------------------------------- 1 | """Interact with Beam.""" 2 | 3 | from .api import BeamAPI 4 | from .chat import BeamChat 5 | from .handler import BeamHandler 6 | from .constellation import BeamConstellation 7 | 8 | __all__ = ["BeamHandler", "BeamAPI", "BeamChat", "BeamConstellation"] 9 | -------------------------------------------------------------------------------- /cactusbot/services/beam/api.py: -------------------------------------------------------------------------------- 1 | """Interact with the Beam API.""" 2 | 3 | from ..api import API 4 | 5 | 6 | class BeamAPI(API): 7 | """Interact with the Beam API.""" 8 | 9 | URL = "https://beam.pro/api/v1/" 10 | 11 | headers = { 12 | "Content-Type": "application/json" 13 | } 14 | 15 | def authorize(self, token): 16 | self.token = token 17 | 18 | self.headers["Authorization"] = "Bearer {}".format(token) 19 | 20 | async def get_bot_channel(self, **params): 21 | """Get the bot's user id.""" 22 | response = await self.get("/users/current", params=params, 23 | headers=self.headers) 24 | return await response.json() 25 | 26 | async def get_channel(self, channel, **params): 27 | """Get channel data by username or ID.""" 28 | response = await self.get("/channels/{channel}".format( 29 | channel=channel), params=params, headers=self.headers) 30 | return await response.json() 31 | 32 | async def get_chat(self, chat): 33 | """Get required data for connecting to a chat server by channel ID.""" 34 | response = await self.get("/chats/{chat}".format(chat=chat), 35 | headers=self.headers) 36 | return await response.json() 37 | -------------------------------------------------------------------------------- /cactusbot/services/beam/chat.py: -------------------------------------------------------------------------------- 1 | """Interact with Beam chat.""" 2 | 3 | 4 | import itertools 5 | import json 6 | import logging 7 | 8 | from .. import WebSocket 9 | 10 | 11 | class BeamChat(WebSocket): 12 | """Interact with Beam chat.""" 13 | 14 | def __init__(self, channel, *endpoints): 15 | super().__init__(*endpoints) 16 | 17 | self.logger = logging.getLogger(__name__) 18 | 19 | assert isinstance(channel, int), "Channel ID must be an integer." 20 | self.channel = channel 21 | 22 | self._packet_counter = itertools.count() 23 | 24 | async def send(self, *args, max_length=360, **kwargs): 25 | """Send a packet.""" 26 | 27 | # TODO: lock before auth 28 | 29 | packet = { 30 | "type": "method", 31 | "method": "msg", 32 | "arguments": args, 33 | "id": kwargs.get("id") or self._packet_id 34 | } 35 | 36 | packet.update(kwargs) 37 | 38 | if packet["method"] == "msg": 39 | for message in packet.copy()["arguments"]: 40 | for index in range(0, len(message), max_length): 41 | packet["arguments"] = (message[index:index + max_length],) 42 | await super().send(json.dumps(packet)) 43 | else: 44 | await super().send(json.dumps(packet)) 45 | 46 | async def initialize(self, *auth): 47 | """Send an authentication packet.""" 48 | if auth: 49 | user_id, get_chat = auth 50 | authkey = (await get_chat())["authkey"] 51 | await self.send(self.channel, user_id, authkey, method="auth") 52 | else: 53 | await self.send(self.channel, method="auth") 54 | 55 | async def parse(self, packet): 56 | """Parse a chat packet.""" 57 | 58 | try: 59 | packet = json.loads(packet) 60 | except (TypeError, ValueError): 61 | self.logger.exception("Invalid JSON: %s.", packet) 62 | return None 63 | else: 64 | if packet.get("error") is not None: 65 | self.logger.error(packet) 66 | else: 67 | self.logger.debug(packet) 68 | return packet 69 | 70 | @property 71 | def _packet_id(self): 72 | return next(self._packet_counter) 73 | -------------------------------------------------------------------------------- /cactusbot/services/beam/constellation.py: -------------------------------------------------------------------------------- 1 | """Interact with Beam Constellation.""" 2 | 3 | import re 4 | import json 5 | 6 | from .. import WebSocket 7 | 8 | 9 | class BeamConstellation(WebSocket): 10 | """Interact with Beam Constellation.""" 11 | 12 | URL = "wss://constellation.beam.pro" 13 | 14 | RESPONSE_EXPR = re.compile(r'^(\d+)(.+)?$') 15 | INTERFACE_EXPR = re.compile(r'^([a-z]+):\d+:([a-z]+)') 16 | 17 | def __init__(self, channel, user): 18 | super().__init__(self.URL) 19 | 20 | assert isinstance(channel, int), "Channel ID must be an integer." 21 | self.channel = channel 22 | 23 | assert isinstance(user, int), "User ID must be an integer." 24 | self.user = user 25 | 26 | async def initialize(self, *interfaces): 27 | """Subscribe to Constellation interfaces.""" 28 | 29 | if not interfaces: 30 | interfaces = ( 31 | "channel:{channel}:update", 32 | "channel:{channel}:status", 33 | "channel:{channel}:followed", 34 | "channel:{channel}:subscribed", 35 | "channel:{channel}:resubscribed", 36 | "channel:{channel}:hosted", 37 | "user:{user}:followed", 38 | "user:{user}:subscribed", 39 | "user:{user}:achievement" 40 | ) 41 | 42 | interfaces = [ 43 | interface.format(channel=self.channel, user=self.user) 44 | for interface in interfaces 45 | ] 46 | 47 | packet = { 48 | "type": "method", 49 | "method": "livesubscribe", 50 | "params": { 51 | "events": interfaces 52 | }, 53 | "id": 1 54 | } 55 | 56 | self.websocket.send_str(json.dumps(packet)) 57 | await self.receive() 58 | 59 | self.logger.info( 60 | "Successfully subscribed to Constellation interfaces.") 61 | 62 | async def parse(self, packet): 63 | """Parse a chat packet.""" 64 | 65 | try: 66 | packet = json.loads(packet) 67 | except (TypeError, ValueError): 68 | self.logger.exception("Invalid JSON: %s.", packet) 69 | return None 70 | else: 71 | if packet.get("error") is not None: 72 | self.logger.error(packet) 73 | else: 74 | self.logger.debug(packet) 75 | return packet 76 | -------------------------------------------------------------------------------- /cactusbot/services/beam/emoji.json: -------------------------------------------------------------------------------- 1 | { 2 | ":spaceship": "🚀", 3 | ":127": "🔥", 4 | ":cactus": "🌵", 5 | ":whoappa": "😲:", 6 | ":bacon": "🥓", 7 | ":beer": "🍺", 8 | ":cake": "🍰", 9 | ":candy": "🍬", 10 | ":fries": "🍟", 11 | ":icecream": "🌶", 12 | ":pizza": "🍕", 13 | ":popcorn": "🍿", 14 | ":turqmelon": "🍉", 15 | ":afk": "🚪", 16 | ":controller": "🎮", 17 | ":delorean": "🚙", 18 | ":facepalm": "🤦", 19 | ":rmf": "🖥", 20 | ":swag": "😎", 21 | ":wrench": "🔧", 22 | ":christmas": "🎄", 23 | ":jinglebell": "🔔", 24 | "<3": "❤", 25 | ":)": "😆", 43 | ">;)": "😆", 44 | ">:-)": "😆", 45 | ">=)": "😆", 46 | ";)": "😉", 47 | ";-)": "😉", 48 | "*-)": "😉", 49 | "*)": "😉", 50 | ";-]": "😉", 51 | ";]": "😉", 52 | ";^)": "😉", 53 | "':(": "😓", 54 | "':-(": "😓", 55 | "'=(": "😓", 56 | ":*": "😘", 57 | ":-*": "😘", 58 | "=*": "😘", 59 | ":^*": "😘", 60 | ">:P": "😜", 61 | "X-P": "😜", 62 | "x-p": "😜", 63 | ">:[": "😞", 64 | ":-(": "😞", 65 | ":(": "😞", 66 | ":-[": "😞", 67 | ":[": "😞", 68 | "=(": "😞", 69 | ">:(": "😠", 70 | ">:-(": "😠", 71 | ":@": "😠", 72 | ":'(": "😢", 73 | ":'-(": "😢", 74 | ";(": "😢", 75 | ";-(": "😢", 76 | ">.<": "😣", 77 | ":$": "😳", 78 | "=$": "😳", 79 | "#-)": "😵", 80 | "#)": "😵", 81 | "%-)": "😵", 82 | "%)": "😵", 83 | "X)": "😵", 84 | "X-)": "😵", 85 | "*\\0/*": "🙆", 86 | "\\0/": "🙆", 87 | "*\\O/*": "🙆", 88 | "\\O/": "🙆", 89 | "O:-)": "😇", 90 | "0:-3": "😇", 91 | "0:3": "😇", 92 | "0:-)": "😇", 93 | "0:)": "😇", 94 | "0;^)": "😇", 95 | "O:-)": "😇", 96 | "O:)": "😇", 97 | "O;-)": "😇", 98 | "O=)": "😇", 99 | "0;-)": "😇", 100 | "O:-3": "😇", 101 | "O:3": "😇", 102 | "B-)": "😎", 103 | "B)": "😎", 104 | "8)": "😎", 105 | "8-)": "😎", 106 | "B-D": "😎", 107 | "8-D": "😎", 108 | "-_-": "😑", 109 | "-__-": "😑", 110 | "-___-": "😑", 111 | ">:\\": "😕", 112 | ">:/": "😕", 113 | ":-/": "😕", 114 | ":-.": "😕", 115 | ":/": "😕", 116 | ":\\": "😕", 117 | "=/": "😕", 118 | "=\\": "😕", 119 | ":L": "😕", 120 | "=L": "😕", 121 | ":P": "😛", 122 | ":-P": "😛", 123 | "=P": "😛", 124 | ":-p": "😛", 125 | ":p": "😛", 126 | "=p": "😛", 127 | ":-Þ": "😛", 128 | ":Þ": "😛", 129 | ":þ": "😛", 130 | ":-þ": "😛", 131 | ":-b": "😛", 132 | ":b": "😛", 133 | "d:": "😛", 134 | ":-O": "😮", 135 | ":O": "😮", 136 | ":-o": "😮", 137 | ":o": "😮", 138 | ">:O": "😮", 139 | ":-X": "😶", 140 | ":X": "😶", 141 | ":-#": "😶", 142 | ":#": "😶", 143 | "=X": "😶", 144 | "=x": "😶", 145 | ":x": "😶", 146 | ":-x": "😶", 147 | "=#": "😶", 148 | "3:)": "😈", 149 | "}:)": "😈", 150 | "}:]": "😈", 151 | ":fish": "🐟", 152 | ":chicken": "🐔", 153 | ":cat": "🐱", 154 | ":dog": "🐶", 155 | ":woof": "🐶", 156 | ":hamster": "🐹" 157 | } 158 | -------------------------------------------------------------------------------- /cactusbot/services/beam/handler.py: -------------------------------------------------------------------------------- 1 | """Handle data from Beam.""" 2 | 3 | import asyncio 4 | import logging 5 | from functools import partial 6 | 7 | from ...packets import BanPacket, MessagePacket, Packet 8 | from .api import BeamAPI 9 | from .chat import BeamChat 10 | from .constellation import BeamConstellation 11 | from .parser import BeamParser 12 | 13 | 14 | class BeamHandler: 15 | """Handle data from Beam services.""" 16 | 17 | def __init__(self, channel, token, handlers): 18 | 19 | self.logger = logging.getLogger(__name__) 20 | 21 | self.api = BeamAPI() 22 | self.api.authorize(token) 23 | 24 | self.parser = BeamParser() 25 | self.handlers = handlers # HACK, potentially 26 | 27 | self._channel = channel 28 | self.channel = "" 29 | 30 | self.chat = None 31 | self.constellation = None 32 | 33 | self.chat_events = { 34 | "ChatMessage": "message", 35 | "UserJoin": "join", 36 | "UserLeave": "leave" 37 | } 38 | 39 | self.constellation_events = { 40 | "channel:followed": "follow", 41 | "channel:subscribed": "subscribe", 42 | "channel:resubscribed": "resubscribe", 43 | "channel:hosted": "host" 44 | } 45 | 46 | async def run(self): 47 | """Connect to Beam chat and handle incoming packets.""" 48 | 49 | channel = await self.api.get_channel(self._channel) 50 | self.channel = str(channel["id"]) 51 | self.api.channel = self.channel # HACK 52 | 53 | user_id = channel["userId"] 54 | chat = await self.api.get_chat(channel["id"]) 55 | 56 | bot_channel = await self.api.get_bot_channel() 57 | bot_id = bot_channel["channel"]["userId"] 58 | 59 | await self.handle("username_update", 60 | Packet(username=bot_channel["channel"]["token"])) 61 | 62 | if "authkey" not in chat: 63 | self.logger.error("Failed to authenticate with Beam!") 64 | 65 | self.chat = BeamChat(channel["id"], *chat["endpoints"]) 66 | await self.chat.connect( 67 | bot_id, partial(self.api.get_chat, channel["id"])) 68 | asyncio.ensure_future(self.chat.read(self.handle_chat)) 69 | 70 | self.constellation = BeamConstellation(channel["id"], user_id) 71 | await self.constellation.connect() 72 | asyncio.ensure_future( 73 | self.constellation.read(self.handle_constellation)) 74 | 75 | await self.handle("start", None) 76 | 77 | async def handle_chat(self, packet): 78 | """Handle chat packets.""" 79 | 80 | data = packet.get("data") 81 | if data is None: 82 | return 83 | 84 | event = packet.get("event") 85 | 86 | if event in self.chat_events: 87 | event = self.chat_events[event] 88 | 89 | # HACK? 90 | if hasattr(self.parser, "parse_" + event): 91 | data = getattr(self.parser, "parse_" + event)(data) 92 | 93 | await self.handle(event, data) 94 | 95 | async def handle_constellation(self, packet): 96 | """Handle constellation packets.""" 97 | 98 | if "data" not in packet: 99 | return 100 | data = packet["data"]["payload"] 101 | 102 | scope, _, event = packet["data"]["channel"].split(":") 103 | event = scope + ':' + event 104 | 105 | if event in self.constellation_events: 106 | event = self.constellation_events[event] 107 | 108 | # HACK 109 | if hasattr(self.parser, "parse_" + event): 110 | data = getattr(self.parser, "parse_" + event)(data) 111 | 112 | await self.handle(event, data) 113 | 114 | async def handle(self, event, data): 115 | """Handle event.""" 116 | 117 | for response in await self.handlers.handle(event, data): 118 | if isinstance(response, MessagePacket): 119 | args, kwargs = self.parser.synthesize(response) 120 | await self.send(*args, **kwargs) 121 | 122 | elif isinstance(response, BanPacket): 123 | if response.duration: 124 | await self.send( 125 | response.user, 126 | response.duration, 127 | method="timeout" 128 | ) 129 | else: 130 | pass # TODO: full ban 131 | 132 | async def send(self, *args, **kwargs): 133 | """Send a packet to Beam.""" 134 | 135 | if self.chat is None: 136 | raise ConnectionError("Chat not initialized.") 137 | 138 | await self.chat.send(*args, **kwargs) 139 | -------------------------------------------------------------------------------- /cactusbot/services/beam/parser.py: -------------------------------------------------------------------------------- 1 | """Parse Beam packets.""" 2 | 3 | import json 4 | from os import path 5 | 6 | from ...packets import EventPacket, MessagePacket 7 | 8 | 9 | class BeamParser: 10 | """Parse Beam packets.""" 11 | 12 | # TODO: update with accurate values 13 | ROLES = { 14 | "Owner": 5, 15 | "Staff": 4, # Not necessarily bot staff. 16 | "Global Mod": 4, 17 | "Mod": 4, 18 | "Subscriber": 2, 19 | "Pro": 1, 20 | "User": 1, 21 | "Muted": 0, 22 | "Banned": 0 23 | } 24 | 25 | with open(path.join(path.dirname(__file__), "emoji.json"), 26 | encoding="utf-8") as file: 27 | EMOJI = json.load(file) 28 | 29 | @classmethod 30 | def parse_message(cls, packet): 31 | """Parse a Beam message packet.""" 32 | 33 | message = [] 34 | for component in packet["message"]["message"]: 35 | chunk = { 36 | "type": component["type"], 37 | "data": "", 38 | "text": component["text"] 39 | } 40 | if component["type"] == "emoticon": 41 | chunk["type"] = "emoji" 42 | chunk["data"] = cls.EMOJI.get(component["text"], "") 43 | message.append(chunk) 44 | elif component["type"] == "inaspacesuit": 45 | chunk["type"] = "emoji" 46 | chunk["data"] = "" 47 | message.append(chunk) 48 | elif component["type"] == "link": 49 | chunk["type"] = "url" 50 | chunk["data"] = component["url"] 51 | message.append(chunk) 52 | elif component["type"] == "tag": 53 | chunk["data"] = component["username"] 54 | message.append(chunk) 55 | elif component["text"]: 56 | chunk["data"] = component["text"] 57 | message.append(chunk) 58 | 59 | return MessagePacket( 60 | *message, 61 | user=packet["user_name"], 62 | role=cls.ROLES[packet["user_roles"][0]], 63 | action=packet["message"]["meta"].get("me", False), 64 | target=packet["message"]["meta"].get( 65 | "whisper", None) and packet["target"] 66 | ) 67 | 68 | @classmethod 69 | def parse_follow(cls, packet): 70 | """Parse follow packet.""" 71 | 72 | return EventPacket( 73 | "follow", 74 | packet["user"]["username"], 75 | packet["following"] 76 | ) 77 | 78 | @classmethod 79 | def parse_subscribe(cls, packet): 80 | """Parse subscribe packet.""" 81 | 82 | return EventPacket("subscribe", packet["user"]["username"]) 83 | 84 | @classmethod 85 | def parse_resubscribe(cls, packet): 86 | """Parse resubscribe packet.""" 87 | 88 | return EventPacket("subscribe", packet["user"]["username"], 89 | streak=packet["totalMonths"]) 90 | 91 | @classmethod 92 | def parse_host(cls, packet): 93 | """Parse host packet.""" 94 | 95 | return EventPacket("host", packet["hoster"]["token"]) 96 | 97 | @classmethod 98 | def parse_join(cls, packet): 99 | """Parse join packet.""" 100 | 101 | return EventPacket("join", packet["username"]) 102 | 103 | @classmethod 104 | def parse_leave(cls, packet): 105 | """Parse host packet.""" 106 | 107 | return EventPacket("leave", packet["username"]) 108 | 109 | @classmethod 110 | def synthesize(cls, packet): 111 | """Create a Beam packet from a :obj:`MessagePacket`.""" 112 | 113 | message = "" 114 | emoji = dict(zip(cls.EMOJI.values(), cls.EMOJI.keys())) 115 | 116 | if packet.action: 117 | message = "/me " 118 | 119 | for index, component in enumerate(packet): 120 | if component.type == "emoji": 121 | message += emoji.get(component.data, component.text) 122 | if (index < len(packet) - 1 and 123 | not packet[index + 1].startswith(' ')): 124 | message += ' ' 125 | elif component.type == "tag": 126 | message += '@' + component.data 127 | else: 128 | message += component.text 129 | 130 | if packet.target: 131 | return (packet.target, message), {"method": "whisper"} 132 | 133 | return (message,), {} 134 | -------------------------------------------------------------------------------- /cactusbot/services/websocket.py: -------------------------------------------------------------------------------- 1 | """Interact with WebSockets safely.""" 2 | 3 | import logging 4 | 5 | import asyncio 6 | 7 | import itertools 8 | 9 | from aiohttp import ClientSession 10 | from aiohttp.errors import DisconnectedError, HttpProcessingError, ClientError 11 | 12 | 13 | class WebSocket(ClientSession): 14 | """Interact with WebSockets safely.""" 15 | 16 | def __init__(self, *endpoints): 17 | super().__init__() 18 | 19 | self.logger = logging.getLogger(__name__) 20 | 21 | assert len(endpoints), "An endpoint is required to connect." 22 | 23 | self.websocket = None 24 | 25 | self._init_args = () 26 | self._init_kwargs = {} 27 | 28 | self._endpoint_cycle = itertools.cycle(endpoints) 29 | 30 | async def connect(self, *args, base=2, maximum=60, **kwargs): 31 | """Connect to a WebSocket.""" 32 | 33 | self._init_args = args 34 | self._init_kwargs = kwargs 35 | 36 | _backoff_count = itertools.count() 37 | self.logger.debug("Connecting...") 38 | 39 | while True: 40 | try: 41 | self.websocket = await self.ws_connect(self._endpoint) 42 | except (DisconnectedError, HttpProcessingError, ClientError): 43 | backoff = min(base**next(_backoff_count), maximum) 44 | self.logger.debug("Retrying in %s seconds...", backoff) 45 | await asyncio.sleep(backoff) 46 | else: 47 | await self.initialize(*args, **kwargs) 48 | self.logger.info("Connection established.") 49 | return self.websocket 50 | 51 | async def send(self, packet): 52 | """Send a packet to the WebSocket.""" 53 | assert self.websocket is not None, "Must connect to send." 54 | self.logger.debug(packet) 55 | self.websocket.send_str(packet) 56 | 57 | async def receive(self): 58 | """Receive a packet from the WebSocket.""" 59 | return (await self.websocket.receive()).data 60 | 61 | async def read(self, handle): 62 | """Read packets from the WebSocket.""" 63 | 64 | assert self.websocket is not None, "Must connect to read." 65 | assert callable(handle), "Handler must be callable." 66 | 67 | while True: 68 | packet = await self.receive() 69 | if isinstance(packet, str): 70 | packet = await self.parse(packet) 71 | if packet is not None: 72 | asyncio.ensure_future(handle(packet)) 73 | else: 74 | self.logger.warning("Connection lost. Reconnecting.") 75 | await self.connect(*self._init_args, **self._init_kwargs) 76 | 77 | async def initialize(self): 78 | """Run initialization procedure.""" 79 | pass 80 | 81 | async def parse(self, packet): 82 | """Parse a packet from the WebSocket.""" 83 | return packet 84 | 85 | @property 86 | def _endpoint(self): 87 | return next(self._endpoint_cycle) 88 | -------------------------------------------------------------------------------- /config.template.py: -------------------------------------------------------------------------------- 1 | """CactusBot configuration.""" 2 | 3 | from cactusbot.api import CactusAPI 4 | from cactusbot.handler import Handlers 5 | from cactusbot.handlers import (CommandHandler, EventHandler, LoggingHandler, 6 | ResponseHandler, SpamHandler) 7 | from cactusbot.services.beam.handler import BeamHandler 8 | 9 | TOKEN = "OAuth_Token" 10 | CHANNEL = "ChannelName" 11 | 12 | API_TOKEN = "CactusAPI_Token" 13 | API_PASSWORD = "CactusAPI_Password" 14 | API_URL = "https://cactus.exoz.one/api/v1/" 15 | api = CactusAPI(API_TOKEN, API_PASSWORD, url=API_URL) 16 | 17 | # CACHE_FOLLOWS: Cache to remove chat spam (Default: True) 18 | # CACHE_TIME: How long in seconds before resending message 19 | # Leave at 0 for no repeat follow messages 20 | # Only matters if CACHE_FOLLOWS is enabled 21 | CACHE_DATA = { 22 | "cache_follow": True, 23 | "cache_host": True, 24 | "cache_join": True, 25 | "cache_leave": True, 26 | "cache_time": 1200 27 | } 28 | 29 | 30 | handlers = Handlers( 31 | LoggingHandler(), 32 | ResponseHandler(), 33 | EventHandler(CACHE_DATA, api), 34 | SpamHandler(api), 35 | CommandHandler(CHANNEL, api) 36 | ) 37 | 38 | SERVICE = BeamHandler(CHANNEL, TOKEN, handlers) 39 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | from recommonmark.parser import CommonMarkParser 5 | 6 | sys.path.insert(0, os.path.abspath('..')) 7 | 8 | source_parsers = { 9 | ".md": CommonMarkParser, 10 | } 11 | 12 | source_suffix = [".rst", ".md"] 13 | 14 | extensions = ["sphinx.ext.autodoc", "sphinxcontrib.napoleon"] 15 | -------------------------------------------------------------------------------- /docs/contents.rst: -------------------------------------------------------------------------------- 1 | .. toctree:: 2 | :maxdepth: 2 3 | Home 4 | 5 | .. toctree:: 6 | :maxdepth: 2 7 | :caption: User 8 | 9 | !command 10 | Variables 11 | !alias 12 | !repeat 13 | !quote 14 | !social 15 | !config 16 | !trust 17 | !multi 18 | 19 | .. toctree:: 20 | :maxdepth: 2 21 | :caption: Developer 22 | 23 | Packets 24 | Commands 25 | Handlers 26 | -------------------------------------------------------------------------------- /docs/developer/commands.rst: -------------------------------------------------------------------------------- 1 | Magic Commands 2 | ======= 3 | 4 | .. autoclass:: cactusbot.commands.command.Command 5 | :members: 6 | -------------------------------------------------------------------------------- /docs/developer/handlers.rst: -------------------------------------------------------------------------------- 1 | Handlers 2 | ======== 3 | 4 | Base Handler 5 | ------------- 6 | 7 | .. autoclass:: cactusbot.handler.Handler 8 | :members: 9 | 10 | Handler Controller 11 | ------------------ 12 | 13 | .. autoclass:: cactusbot.handler.Handlers 14 | :members: 15 | 16 | -------------------------------------------------------------------------------- /docs/developer/packets.rst: -------------------------------------------------------------------------------- 1 | Packets 2 | ======= 3 | 4 | Base Packet 5 | ------------- 6 | 7 | .. autoclass:: cactusbot.packets.packet.Packet 8 | :members: 9 | 10 | Message Packet 11 | -------------------- 12 | 13 | .. autoclass:: cactusbot.packets.message.MessageComponent 14 | :undoc-members: 15 | 16 | .. autoclass:: cactusbot.packets.message.MessagePacket 17 | :members: 18 | :show-inheritance: 19 | 20 | Ban Packet 21 | ---------------- 22 | 23 | .. autoclass:: cactusbot.packets.ban.BanPacket 24 | :members: 25 | :show-inheritance: 26 | 27 | Event Packet 28 | ------------------ 29 | 30 | .. autoclass:: cactusbot.packets.event.EventPacket 31 | :members: 32 | :show-inheritance: 33 | -------------------------------------------------------------------------------- /docs/header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CactusDev/CactusBot/6d035bf74bdc8f7fb3ee1e79f8d443f5b17e7ea5/docs/header.png -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | ![CactusDev Header](header.png) 2 | 3 | # CactusBot Documentation 4 | 5 | ## Issues 6 | CactusBot not in your channel, being a jerk, or in need of some water? 7 | We'd love to help you out! Shoot us a direct message on 8 | [Twitter](https://twitter.com/CactusDevTeam), and we will do our best to get 9 | things sorted as soon as possible. 10 | 11 | ## Questions? Ideas? Hamster-powered floofle waffles? 12 | Like most other things, send us a Tweet, either public or as a direct message 13 | [@CactusDevTeam](https://twitter.com/CactusDevTeam)! 14 | 15 | ## Whoa. 16 | You actually read this far? Sweet! Send us a Tweet including `potato waffles` 17 | for a free… uh… high-five? Hug? Cookie? *Hamster*. 18 | -------------------------------------------------------------------------------- /docs/user/alias.md: -------------------------------------------------------------------------------- 1 | # `!alias` 2 | 3 | Minimum Role Required: **Moderator** 4 | 5 | Add and remove aliases for commands. 6 | 7 | ## `!alias add [args...]` 8 | 9 | Add alias `alias` for `command`, with arguments `args`. 10 | 11 | ``` 12 | [duke] !command add ip-hypixel mc.hypixel.net 13 | [CactusBot] Added command !ip-hypixel. 14 | 15 | [2Cubed] !alias add hypixel ip-hypixel 16 | [CactusBot] Alias !hypixel for !ip-hypixel created. 17 | ``` 18 | 19 | ``` 20 | [TransportLayer] !command add echo %ARGS% 21 | [CactusBot] Added command !echo. 22 | 23 | [ParadigmShift3d] !echo Hello, world! 24 | [CactusBot] Hello, world! 25 | 26 | [pingpong1109] !alias add cave echo Echoooo! 27 | [CactusBot] Alias !cave for !echo created. 28 | 29 | [ParadigmShift3d] !cave Is anyone theeeere? 30 | [CactusBot] Echoooo! Is anyone theeeere? 31 | ``` 32 | 33 | ## `!alias remove ` 34 | 35 | Remove alias `alias`. 36 | 37 | ``` 38 | [CallMeCyber] !alias remove cave 39 | [CactusBot] Alias !cave removed. 40 | ``` 41 | 42 | ## `!alias list` 43 | 44 | List all aliases. 45 | 46 | ``` 47 | [pylang] !alias list 48 | [CactusBot] Aliases: hypixel (ip-hypixel), meow (kitten). 49 | ``` 50 | -------------------------------------------------------------------------------- /docs/user/command.md: -------------------------------------------------------------------------------- 1 | # `!command` 2 | 3 | Minimum Role Required: **Moderator** 4 | 5 | ## `!command add [limiter] ` 6 | 7 | Create a custom command. 8 | 9 | If `` is in the form of `main-sub`, it may be run later as either `!main-sub` or `!main sub`. 10 | 11 | - `limiter` signifies the minimum role required to access the command. 12 | 13 | - `+`: Moderator-Only 14 | - `$`: Subscriber-Only 15 | 16 | `%VARIABLES%` may be used to create dynamic responses. 17 | 18 | If `!` already exists, its response is updated. 19 | 20 | ``` 21 | [Jello] !command add waffle Time to feed %ARGS% some waffles! 22 | [CactusBot] Added command !waffle. 23 | [BreachBreachBreach] !waffle Innectic 24 | [CactusBot] Time to feed Innectic some waffles! 25 | ``` 26 | 27 | ## `!command remove ` 28 | 29 | Remove a custom command. 30 | 31 | ``` 32 | [Epicness] !command remove waffle 33 | [CactusBot] Removed command !waffle. 34 | [Innectic] Oh no, my waffles! /cry 35 | ``` 36 | 37 | ## `!command list` 38 | 39 | List all custom commands. 40 | 41 | ``` 42 | [Xyntak] !command list 43 | [CactusBot] Commands: explosions, kittens, potato 44 | ``` 45 | 46 | ## `!command enable ` 47 | 48 | Enable a custom command. 49 | 50 | ``` 51 | [artdude543] !command enable typescript 52 | [CactusBot] Command !typescript has been enabled. 53 | ``` 54 | 55 | ## `!command disable ` 56 | 57 | Disable a custom command. 58 | 59 | ``` 60 | [Innectic] !command disable typescript 61 | [CactusBot] Command !typescript has been disabled. 62 | ``` 63 | 64 | ## `!command count [action]` 65 | 66 | Retrieve or modify the `count` value for a custom command. 67 | 68 | If `action` is not specified, the `count` is returned. 69 | 70 | ``` 71 | [AlphaBravoKilo] !command count derp 72 | [CactusBot] !derp's count is 9001. 73 | ``` 74 | 75 | Otherwise, the value is modified. 76 | 77 | If `action` is a number (optionally preceded by an `=`), the count value is set to that exact number. 78 | 79 | ``` 80 | [Kondrik] !command count derp 9053 81 | [CactusBot] Count updated. 82 | ``` 83 | 84 | Otherwise, `action` may begin with either a `+` or `-`, to increase or decrease the count value, respectively. 85 | 86 | ``` 87 | [MindlessPuppetz] !command count derp +12 88 | [CactusBot] Count updated. 89 | ``` 90 | -------------------------------------------------------------------------------- /docs/user/config.md: -------------------------------------------------------------------------------- 1 | # `!config` 2 | 3 | Minimum Role Required: **Moderator** 4 | 5 | Set a configuration option. 6 | 7 | ## `!config announce ` 8 | 9 | Edit the announcement messages for events. 10 | 11 | ### `!config announce ` 12 | 13 | Update the response for an event announcement. The `%USER%` variable may be used for username substitution. 14 | 15 | ``` 16 | [misterjoker] !config announce follow Thanks for following the channel, %USER%! 17 | [CactusBot] Updated announcement. 18 | 19 | *ParadigmShift3d follows* 20 | [CactusBot] Thanks for following the channel, ParadigmShift3d! 21 | ``` 22 | 23 | ### `!config announce toggle [on|off]` 24 | 25 | Toggle a specific type of event announcement. Either `on` or `off` may be used to set the exact state. 26 | 27 | ``` 28 | [Jello] !config announce follow toggle 29 | [CactusBot] Follow announcements are now disabled. 30 | 31 | *Innectic follows* 32 | *CactusBot does not respond* 33 | ``` 34 | 35 | ## `!config spam ` 36 | 37 | Change the configuration value for a spam filter. 38 | 39 | - `urls` accepts either `on` or `off`, which allows or disallows URLs, respectively. 40 | - `emoji` accepts a number, which is the maximum amount of emoji which one message may contain. 41 | - `caps` accepts a number, which is the maximum "score" which a message may have before being considered spam. 42 | 43 | - The "score" is calculated by subtracting the total number of lowercase letters from the total number of uppercase letters. 44 | 45 | ``` 46 | [DnatorGames] !config spam urls off 47 | [CactusBot] URLs are now disallowed. 48 | 49 | [QuirkySquid] Whoa, check out google.com! 50 | *CactusBot times out QuirkySquid* 51 | ``` 52 | 53 | ``` 54 | [QueenofArt] !config emoji 5 55 | [CactusBot] Maximum number of emoji is now 5. 56 | 57 | [pingpong1109] Wow! :O :O :O :D :D :D 58 | *CactusBot times out pingpong1109* 59 | ``` 60 | -------------------------------------------------------------------------------- /docs/user/multi.md: -------------------------------------------------------------------------------- 1 | # `!multi` 2 | 3 | Generate a multistream link. 4 | 5 | ## `!multi [service]:[channel]` 6 | 7 | `service` can be one of the following: 8 | - `b` (Beam) 9 | - `t` (Twitch) 10 | - `h` (Hitbox) 11 | - `y` (Youtube) 12 | 13 | `channel` represents the name of the channel on that platform 14 | 15 | ``` 16 | [ParadigmShift3d] !multi b:neat t:stream h:to y:watch 17 | [CactusBot] https://multistream.me/b:neat/t:stream/h:to/y:watch/ 18 | ``` 19 | -------------------------------------------------------------------------------- /docs/user/quote.md: -------------------------------------------------------------------------------- 1 | # `!quote` 2 | 3 | ## `!quote [id]` 4 | 5 | Minimum Role Required: **User** 6 | 7 | Retrieve a quote. If a numeric `id` is supplied, return the quote with the specific identifier. Otherwise, choose a random quote. 8 | 9 | ``` 10 | [cass3rz] !quote 11 | [CactusBot] "Someone stole my waffles!" -Innectic 12 | ``` 13 | 14 | ``` 15 | [TransportLayer] !quote 12 16 | [CactusBot] "Potato!" -2Cubed 17 | ``` 18 | 19 | ## `!quote add ` 20 | 21 | Minimum Role Required: **Moderator** 22 | 23 | Add a quote. 24 | 25 | ``` 26 | [Daegda] !quote add "Python 3.6 is out!" -pylang 27 | [CactusBot] Added quote #37. 28 | ``` 29 | 30 | ## `!quote edit ` 31 | 32 | Minimum Role Required: **Moderator** 33 | 34 | Edit the contents of a quote. 35 | 36 | ``` 37 | [alfw] !quote edit 12 "Potato salad!" -2Cubed 38 | [CactusBot] Edited quote #12. 39 | ``` 40 | 41 | ## `!quote remove ` 42 | 43 | Minimum Role Required: **Moderator** 44 | 45 | Remove a quote. 46 | 47 | ``` 48 | [QueenOfArt] !quote remove 4 49 | [CactusBot] Removed quote #4. 50 | ``` 51 | -------------------------------------------------------------------------------- /docs/user/repeat.md: -------------------------------------------------------------------------------- 1 | # `!repeat` 2 | 3 | Minimum Role Required: **Moderator** 4 | 5 | Send the contents of a command at a set interval. 6 | 7 | ## `!repeat add ` 8 | 9 | Add a repeat for a specific command. 10 | 11 | - `interval` is the amount of time between messages, in seconds. The minimum is `60`. 12 | - `command` is the command response to send at the interval. 13 | 14 | ``` 15 | [misterjoker] !repeat add 1200 waffle 16 | [CactusBot] Repeat !waffle added on interval 1200. 17 | [ParadigmShift3d] Yay! Time to eat all the waffles. :D 18 | ``` 19 | 20 | ## `!repeat remove ` 21 | 22 | Remove a repeat. 23 | 24 | ``` 25 | [AlphaBravoKilo] !repeat remove waffle 26 | [CactusBot] Repeat for command !waffle has been removed. 27 | [Innectic] Aww... no more waffles. 28 | ``` 29 | 30 | ## `!repeat list` 31 | 32 | List all repeats. 33 | 34 | ``` 35 | [impulseSV] !repeat list 36 | [CactusBot] Active repeats: waffle, kittens. 37 | ``` 38 | -------------------------------------------------------------------------------- /docs/user/social.md: -------------------------------------------------------------------------------- 1 | # `!social` 2 | 3 | Store and retrieve social data. 4 | 5 | ## `!social [services...]` 6 | 7 | Retrieve the URL for social services. 8 | 9 | If any `services` are provided, the data for only those will be returned. Otherwise, all social URLs will be returned. 10 | 11 | ``` 12 | [cass3rz] !social 13 | [CactusBot] Twitter: https://twitter.com/Innectic, Github: https://github.com/Innectic 14 | ``` 15 | 16 | ``` 17 | [innectic] !social github 18 | [CactusBot] Github: https://github.com/Innectic 19 | ``` 20 | 21 | ## `!social add ` 22 | 23 | Minimum Role Required: **Moderator** 24 | 25 | Store a social URL. 26 | 27 | ``` 28 | [eenofonn] !social add twitter https://twitter.com/eenofonn 29 | [CactusBot] Added social service twitter. 30 | 31 | [duke] !social twitter 32 | [CactusBot] Twitter: https://twitter.com/eenofonn 33 | ``` 34 | 35 | ## `!social remove ` 36 | 37 | Minimum Role Required: **Moderator** 38 | 39 | Remove a social URL. 40 | 41 | ``` 42 | [Daegda] !social remove twitch 43 | [CactusBot] Removed social service twitch. 44 | ``` 45 | -------------------------------------------------------------------------------- /docs/user/trust.md: -------------------------------------------------------------------------------- 1 | # `!trust` 2 | 3 | Minimum Role Required: **Moderator** 4 | 5 | Add and remove trusts. 6 | 7 | Trusted users can bypass spam filters. 8 | 9 | ## `!trust ` 10 | 11 | Toggle `user`'s trusted status. 12 | 13 | ``` 14 | [alfw] !trust Innectic 15 | [CactusBot] @Innectic is now trusted. 16 | 17 | [alfw] !trust Innectic 18 | [CactusBot] @Innectic is no longer trusted. 19 | ``` 20 | 21 | ## `!trust add ` 22 | 23 | Trust `user`. 24 | 25 | ``` 26 | [Rival_Laura] !trust add 2Cubed 27 | [CactusBot] @2Cubed has been trusted. 28 | [2Cubed] twitter.com/2Cubed 29 | ``` 30 | 31 | ## `!trust remove ` 32 | 33 | Remove `user`'s trust. 34 | 35 | ``` 36 | [CallMeCyber] !trust remove 2Cubed 37 | [CactusBot] Removed trust for @2Cubed. 38 | [2Cubed] Noooo! /cry 39 | ``` 40 | 41 | ## `!trust list` 42 | 43 | List all trusted users. 44 | 45 | ``` 46 | [DnatorGames] !trust list 47 | [CactusBot] Trusted users: Innectic, eenofonn, duke. 48 | ``` 49 | -------------------------------------------------------------------------------- /docs/user/variables.md: -------------------------------------------------------------------------------- 1 | # Variables 2 | 3 | Variables enable dynamic responses based on the message 4 | 5 | ## `%USER%` 6 | 7 | The username of the user who ran the command. 8 | 9 | ``` 10 | [2Cubed] !command add kittens %USER% likes kittens! 11 | [CactusBot] Added command !kittens. 12 | 13 | [ParadigmShift3d] !kittens 14 | [CactusBot] ParadigmShift3d likes kittens! 15 | ``` 16 | 17 | ## `%ARGN%` 18 | 19 | The `N`th argument passed with the command, where `N` is a nonnegative integer. 20 | 21 | ``` 22 | [ParadigmShift3d] !command add throw Hey, %ARG2%, have a %ARG1%! 23 | [CactusBot] Added command !throw. 24 | 25 | [Alkali_Metal] !throw potato Innectic 26 | [CactusBot] Hey, Innectic, have a potato! 27 | ``` 28 | 29 | ## `%ARGS%` 30 | 31 | All passed arguments, combined. (Excludes the command itself.) 32 | 33 | ``` 34 | [Innectic] !command add hug %USER% hugs %ARGS%! 35 | [CactusBot] Added command !hug. 36 | 37 | [BreachBreachBreach] !hug the whole chat 38 | [CactusBot] BreachBreachBreach hugs the whole chat! 39 | ``` 40 | 41 | ## `%COUNT%` 42 | 43 | The number of times the command has been run. 44 | 45 | ``` 46 | [misterjoker] !command add derp Joker has derped %COUNT% times! 47 | [CactusBot] Added command !derp. 48 | 49 | [ripbandit] !derp 50 | [CactusBot] Joker has derped 193 times! 51 | ``` 52 | 53 | ## `%CHANNEL%` 54 | 55 | The name of the channel. 56 | 57 | ``` 58 | [Rival_Laura] !command add welcome Welcome to %CHANNEL%'s stream! 59 | [CactusBot] Added command !welcome. 60 | 61 | [Epicness] !welcome 62 | [CactusBot] Welcome to Xyntak's stream! 63 | ``` 64 | 65 | # Modifiers 66 | 67 | Change the output of variables. To use a modifier, append `|` and the modifier to the end of the variable name. 68 | 69 | Multiple modifiers may be chained, and will be evaluated from left to right. 70 | 71 | ## `upper` 72 | 73 | Replace all lowercase letters with their uppercase equivalents. 74 | 75 | ``` 76 | %USER% -> 2Cubed 77 | %USER|upper% -> 2CUBED 78 | ``` 79 | 80 | ## `lower` 81 | 82 | Replace all uppercase letters with their lowercase equivalents. 83 | 84 | ``` 85 | %USER% -> ParadigmShift3d 86 | %USER|lower% -> paradigmshift3d 87 | ``` 88 | 89 | ## `title` 90 | 91 | Make the first letter and all letters following non-alphanumeric characters capitalized. 92 | 93 | ``` 94 | %ARG1% -> potatoes 95 | %ARG1|title% -> Potatoes 96 | ``` 97 | 98 | ## `reverse` 99 | 100 | Reverse the text. 101 | 102 | ``` 103 | %USER% -> Jello 104 | %USER|reverse% -> olleJ 105 | 106 | %USER|reverse|title% -> Ollej 107 | ``` 108 | 109 | ## `tag` 110 | 111 | Remove the initial `@`, if it exists. 112 | 113 | ``` 114 | %ARG1% -> @xCausxn 115 | %ARG1|tag% -> xCausxn 116 | 117 | %ARG1% -> UnwrittenFun 118 | %ARG1|tag% -> UnwrittenFun 119 | ``` 120 | 121 | ``` 122 | [artdude543] !command add +raid Let's go raid @%ARG1|tag%! beam.pro/%ARG1|tag% 123 | [CactusBot] Added command !raid. 124 | 125 | [Chikachi] !raid @Innectic 126 | [CactusBot] Let's go raid @Innectic! beam.pro/Innectic 127 | 128 | [alfw] !raid TransportLayer 129 | [CactusBot] Let's go raid @TransportLayer! beam.pro/TransportLayer 130 | ``` 131 | 132 | ## `shuffle` 133 | 134 | Shuffle the text. 135 | 136 | ``` 137 | %ARG1% -> eenofonn 138 | %ARG1|shuffle% -> fnonneoe 139 | 140 | %ARG1% -> @eenofonn 141 | %ARG1|tag|shuffle% -> ononneef 142 | ``` 143 | -------------------------------------------------------------------------------- /environment.yml: -------------------------------------------------------------------------------- 1 | name: py35 2 | dependencies: 3 | - openssl=1.0.2g=0 4 | - pip=8.1.1=py35_0 5 | - python=3.5.1=0 6 | - readline=6.2=2 7 | - setuptools=20.3=py35_0 8 | - sqlite=3.9.2=0 9 | - tk=8.5.18=0 10 | - wheel=0.29.0=py35_0 11 | - xz=5.0.5=1 12 | - zlib=1.2.8=0 13 | - pip: 14 | - sphinxcontrib-napoleon 15 | - aiohttp>=1.2.0 16 | -------------------------------------------------------------------------------- /readthedocs.yml: -------------------------------------------------------------------------------- 1 | conda: 2 | file: environment.yml 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp>=1.2.0 2 | -------------------------------------------------------------------------------- /run.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3.5 2 | 3 | """Run CactusBot.""" 4 | 5 | import logging 6 | from argparse import ArgumentParser 7 | from asyncio import get_event_loop 8 | 9 | from cactusbot.cactus import run 10 | from config import SERVICE, api 11 | 12 | if __name__ == "__main__": 13 | 14 | parser = ArgumentParser(description="Run CactusBot.") 15 | 16 | parser.add_argument( 17 | "--debug", 18 | help="set custom logger level", 19 | metavar="LEVEL", 20 | nargs='?', 21 | const="DEBUG", 22 | default="INFO" 23 | ) 24 | 25 | args = parser.parse_args() 26 | 27 | logging.basicConfig( 28 | level=args.debug, 29 | format="{asctime} {levelname} {name} {funcName}: {message}", 30 | datefmt="%Y-%m-%d %H:%M:%S", 31 | style='{' 32 | ) 33 | 34 | loop = get_event_loop() 35 | 36 | try: 37 | # TODO: Convert this to be able to have multiple services 38 | loop.run_until_complete(run(api, SERVICE)) 39 | loop.run_forever() 40 | finally: 41 | loop.close() 42 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from distutils.core import setup 2 | 3 | if __name__ == "__main__": 4 | with open("README.md", encoding="utf-8") as file: 5 | description = file.read() 6 | 7 | setup( 8 | name="CactusBot", 9 | version="v0.4-dev", 10 | packages=["cactusbot"], 11 | license="MIT", 12 | long_description=description 13 | ) 14 | -------------------------------------------------------------------------------- /tests/commands/test_alias.py: -------------------------------------------------------------------------------- 1 | """Test the alias command.""" 2 | 3 | import pytest 4 | 5 | from cactusbot.commands.magic import Alias 6 | from cactusbot.packets import MessagePacket 7 | 8 | 9 | class MockAPI: 10 | """Fake API.""" 11 | 12 | async def get_command_alias(self, command): 13 | """Get aliases.""" 14 | 15 | class Response: 16 | """Fake API response object.""" 17 | 18 | @property 19 | def status(self): 20 | """Response status.""" 21 | return 200 22 | 23 | def json(self): 24 | """Response from the api.""" 25 | 26 | return { 27 | "data": { 28 | "attributes": { 29 | "command": { 30 | "count": 1, 31 | "enabled": True, 32 | "id": "d23779ce-4522-431d-9095-7bf34718c39d", 33 | "name": "testing", 34 | "response": { 35 | "action": None, 36 | "message": [ 37 | { 38 | "data": "testing!", 39 | "text": "testing!", 40 | "type": "text" 41 | } 42 | ], 43 | "role": 1, 44 | "target": None, 45 | "user": "Stanley" 46 | }, 47 | "token": "Stanley" 48 | }, 49 | "commandName": "testing", 50 | "name": "test", 51 | "token": "Stanley" 52 | }, 53 | "id": "312ab175-fb52-4a7b-865d-4202176f9234", 54 | "type": "aliases" 55 | } 56 | } 57 | return Response() 58 | 59 | async def add_alias(self, command, alias, args=None): 60 | """Add a new alias.""" 61 | 62 | class Response: 63 | """Fake API response object.""" 64 | 65 | @property 66 | def status(self): 67 | """Response status.""" 68 | return 200 69 | 70 | def json(self): 71 | """Response from the api.""" 72 | return { 73 | "data": { 74 | "attributes": { 75 | "command": { 76 | "count": 1, 77 | "enabled": True, 78 | "id": "d23779ce-4522-431d-9095-7bf34718c39d", 79 | "name": "testing", 80 | "response": { 81 | "action": False, 82 | "message": [ 83 | { 84 | "data": "testing!", 85 | "text": "testing!", 86 | "type": "text" 87 | } 88 | ], 89 | "role": 1, 90 | "target": None, 91 | "user": "Stanley" 92 | }, 93 | "token": "Stanley" 94 | }, 95 | "commandName": "testing", 96 | "name": "test", 97 | "token": "Stanley" 98 | }, 99 | "id": "312ab175-fb52-4a7b-865d-4202176f9234", 100 | "type": "aliases" 101 | }, 102 | "meta": { 103 | "edited": True 104 | } 105 | } 106 | return Response() 107 | 108 | async def remove_alias(self, alias): 109 | """Remove an alias.""" 110 | 111 | class Response: 112 | """Fake API response.""" 113 | 114 | @property 115 | def status(self): 116 | """Response status.""" 117 | return 200 118 | 119 | def json(self): 120 | """JSON response.""" 121 | return { 122 | "meta": { 123 | "deleted": [ 124 | "312ab175-fb52-4a7b-865d-4202176f9234" 125 | ] 126 | } 127 | } 128 | return Response() 129 | 130 | async def get_command(self): 131 | """Get all the commands.""" 132 | 133 | class Response: 134 | """Fake API response.""" 135 | 136 | @property 137 | def status(self): 138 | """Status of the response.""" 139 | return 200 140 | 141 | async def json(self): 142 | """JSON response.""" 143 | return { 144 | "data": [ 145 | { 146 | "attributes": { 147 | "count": 2, 148 | "enabled": True, 149 | "name": "testing", 150 | "response": { 151 | "action": False, 152 | "message": [ 153 | { 154 | "data": "testing!", 155 | "text": "testing!", 156 | "type": "text" 157 | } 158 | ], 159 | "role": 1, 160 | "target": None, 161 | "user": "Stanley" 162 | }, 163 | "token": "Stanley" 164 | }, 165 | "id": "d23779ce-4522-431d-9095-7bf34718c39d", 166 | "type": "command" 167 | }, 168 | { 169 | "attributes": { 170 | "commandName": "testing", 171 | "count": 2, 172 | "enabled": True, 173 | "id": "d23779ce-4522-431d-9095-7bf34718c39d", 174 | "name": "test", 175 | "response": { 176 | "action": False, 177 | "message": [ 178 | { 179 | "data": "testing!", 180 | "text": "testing!", 181 | "type": "text" 182 | } 183 | ], 184 | "role": 1, 185 | "target": None, 186 | "user": "Stanley" 187 | }, 188 | "token": "Stanley" 189 | }, 190 | "id": "312ab175-fb52-4a7b-865d-4202176f9234", 191 | "type": "aliases" 192 | } 193 | ] 194 | } 195 | return Response() 196 | 197 | 198 | alias = Alias(MockAPI()) 199 | 200 | @pytest.mark.asyncio 201 | async def test_create_alias(): 202 | """Create an alias.""" 203 | assert (await alias("add", "test", "testing", packet=MessagePacket( 204 | "!alias add test testing", role=5)) 205 | ) == "Alias !test for command !testing updated." 206 | 207 | @pytest.mark.asyncio 208 | async def test_remove_alias(): 209 | """Remove an alias.""" 210 | assert (await alias("remove", "test", packet=MessagePacket( 211 | "!alias remove test", role=5))) == "Alias !test removed." 212 | 213 | @pytest.mark.asyncio 214 | async def test_list_alias(): 215 | """Lis aliases.""" 216 | assert (await alias("list", packet=MessagePacket( 217 | "!alias list", role=5))) == "Aliases: test (testing)." 218 | -------------------------------------------------------------------------------- /tests/commands/test_cactus.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from cactusbot.api import CactusAPI 4 | from cactusbot.commands.magic import Cactus 5 | 6 | from cactusbot.cactus import __version__ 7 | 8 | cactus = Cactus(CactusAPI("test_token", "test_password")) 9 | 10 | 11 | @pytest.mark.asyncio 12 | async def test_default(): 13 | assert (await cactus()).text == "Ohai! I'm CactusBot! 🌵" 14 | 15 | 16 | @pytest.mark.asyncio 17 | async def test_docs(): 18 | assert (await cactus("docs")).text == ("Check out my documentation at " 19 | "cactusbot.rtfd.org.") 20 | 21 | 22 | @pytest.mark.asyncio 23 | async def test_twitter(): 24 | assert (await cactus("twitter")).text == ( 25 | "You can follow the team behind CactusBot at: " 26 | "twitter.com/CactusDevTeam") 27 | 28 | 29 | @pytest.mark.asyncio 30 | async def test_github(): 31 | assert (await cactus("github")).text == ( 32 | "Check out my GitHub repository at: github.com/CactusDev/CactusBot") 33 | assert (await cactus("github", "cactusbot")).text == ( 34 | "Check out my GitHub repository at: github.com/CactusDev/CactusBot") 35 | assert (await cactus("github", "CactusDev")).text == ( 36 | "Check out the CactusDev GitHub organization at: github.com/CactusDev") 37 | assert (await cactus("github", "issue")).text == ( 38 | "Create a GitHub issue at: github.com/CactusDev/CactusBot/issues") 39 | assert (await cactus("github", "CactusAPI")).text == ( 40 | "Check out the GitHub repository for CactusAPI at: " 41 | "github.com/CactusDev/CactusAPI") 42 | assert (await cactus("github", "SEPAL")).text == ( 43 | "Check out the GitHub repository for Sepal at: " 44 | "github.com/CactusDev/Sepal") 45 | assert (await cactus("github", "assets")).text == ( 46 | "Check out the CactusDev assets at: github.com/CactusDev/CactusAssets") 47 | assert (await cactus("github", "nonexistant")).text == ( 48 | "Unknown project 'nonexistant'.") 49 | 50 | 51 | @pytest.mark.asyncio 52 | async def test_help(): 53 | assert (await cactus("help")) == ( 54 | "Try our docs (!cactus docs). " 55 | "If that doesn't help, tweet at us (!cactus twitter)!") 56 | 57 | @pytest.mark.asyncio 58 | async def test_version(): 59 | assert (await cactus("version")) == "CactusBot {version}".format(version=__version__) 60 | -------------------------------------------------------------------------------- /tests/commands/test_command_command.py: -------------------------------------------------------------------------------- 1 | """Test the command command.""" 2 | 3 | import pytest 4 | 5 | from cactusbot.commands.magic import Meta 6 | from cactusbot.packets import MessagePacket 7 | 8 | class MockAPI: 9 | """Fake API.""" 10 | 11 | async def get_command(self, command=None): 12 | """Get commands.""" 13 | 14 | class Response: 15 | """API response.""" 16 | 17 | @property 18 | def status(self): 19 | """Status of the response.""" 20 | return 200 21 | 22 | async def json(self): 23 | """JSON response.""" 24 | 25 | if command: 26 | return { 27 | "data": { 28 | "attributes": { 29 | "count": 0, 30 | "enabled": True, 31 | "name": "testing", 32 | "response": { 33 | "action": False, 34 | "message": [ 35 | { 36 | "data": "testing!", 37 | "text": "testing!", 38 | "type": "text" 39 | }, 40 | { 41 | "data": ":smile:", 42 | "text": ":)", 43 | "type": "emoji" 44 | } 45 | ], 46 | "target": None, 47 | "user": "Stanley" 48 | }, 49 | "role": 0, 50 | "token": "Stanley" 51 | }, 52 | "id": "3f51fc4d-d012-41c0-b98e-ff6257394f75", 53 | "type": "command" 54 | }, 55 | "meta": { 56 | "created": True 57 | } 58 | } 59 | else: 60 | return { 61 | "data": [ 62 | { 63 | "attributes": { 64 | "count": 2, 65 | "enabled": True, 66 | "name": "testing", 67 | "response": { 68 | "action": False, 69 | "message": [ 70 | { 71 | "data": "testing!", 72 | "text": "testing!", 73 | "type": "text" 74 | } 75 | ], 76 | "role": 1, 77 | "target": None, 78 | "user": "Stanley" 79 | }, 80 | "token": "Stanley" 81 | }, 82 | "id": "d23779ce-4522-431d-9095-7bf34718c39d", 83 | "type": "command" 84 | }, 85 | { 86 | "attributes": { 87 | "commandName": "testing", 88 | "count": 2, 89 | "enabled": True, 90 | "id": "d23779ce-4522-431d-9095-7bf34718c39d", 91 | "name": "test", 92 | "response": { 93 | "action": False, 94 | "message": [ 95 | { 96 | "data": "testing!", 97 | "text": "testing!", 98 | "type": "text" 99 | } 100 | ], 101 | "role": 1, 102 | "target": None, 103 | "user": "Stanley" 104 | }, 105 | "token": "Stanley" 106 | }, 107 | "id": "312ab175-fb52-4a7b-865d-4202176f9234", 108 | "type": "aliases" 109 | } 110 | ] 111 | } 112 | return Response() 113 | 114 | async def add_command(self, name, response, *, user_level=1): 115 | """Add a command.""" 116 | 117 | class Response: 118 | """API response.""" 119 | 120 | @property 121 | def status(self): 122 | """Status of the request.""" 123 | return 200 124 | 125 | async def json(self): 126 | """JSON response.""" 127 | 128 | return { 129 | "data": { 130 | "attributes": { 131 | "count": 0, 132 | "enabled": True, 133 | "name": "testing", 134 | "response": { 135 | "action": False, 136 | "message": [ 137 | { 138 | "data": "lol!", 139 | "text": "lol!", 140 | "type": "text" 141 | }, 142 | { 143 | "data": ":smile:", 144 | "text": ":)", 145 | "type": "emoji" 146 | } 147 | ], 148 | "role": 0, 149 | "target": None, 150 | "user": "" 151 | }, 152 | "token": "innectic2" 153 | }, 154 | "id": "d23779ce-4522-431d-9095-7bf34718c39d", 155 | "type": "command" 156 | }, 157 | "meta": { 158 | "edited": True 159 | } 160 | } 161 | return Response() 162 | 163 | async def remove_command(self, name): 164 | """Remove a command.""" 165 | 166 | class Response: 167 | """API response.""" 168 | 169 | @property 170 | def status(self): 171 | """Status of the request.""" 172 | return 200 173 | 174 | async def json(self): 175 | """JSON response.""" 176 | return { 177 | "meta": { 178 | "deleted": { 179 | "aliases": None, 180 | "command": [ 181 | "d23779ce-4522-431d-9095-7bf34718c39d" 182 | ], 183 | "repeats": None 184 | } 185 | } 186 | } 187 | return Response() 188 | 189 | command = Meta(MockAPI()) 190 | 191 | @pytest.mark.asyncio 192 | async def test_command_add(): 193 | """Add a command.""" 194 | packet = MessagePacket(("text", "lol"), ("emoji", "😃"), role=5) 195 | assert (await command("add", "testing", packet, packet=packet)) == "Updated command !testing." 196 | 197 | @pytest.mark.asyncio 198 | async def test_command_remove(): 199 | """Remove a command.""" 200 | 201 | packet = MessagePacket("!command remove testing", role=5) 202 | assert (await command("remove", "testing", packet=packet) 203 | ) == "Removed command !testing." 204 | 205 | @pytest.mark.asyncio 206 | async def test_command_list(): 207 | """List commands.""" 208 | 209 | packet = MessagePacket("!command list", role=5) 210 | assert (await command("list", packet=packet)) == "Commands: testing" 211 | -------------------------------------------------------------------------------- /tests/commands/test_cube.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from cactusbot.api import CactusAPI 4 | from cactusbot.commands.magic import Cube, Temmie 5 | from cactusbot.packets import MessagePacket 6 | 7 | cube = Cube(CactusAPI("test_token", "test_password")) 8 | 9 | async def verify_cube(packet, expected): 10 | _, *args = packet[1:].text.split() 11 | response = (await cube(*args, username=packet.user, packet=packet)) 12 | if isinstance(response, str): 13 | assert response == expected 14 | elif isinstance(response, MessagePacket): 15 | assert response.text == expected 16 | else: 17 | raise TypeError 18 | 19 | 20 | @pytest.mark.asyncio 21 | async def test_cube(): 22 | 23 | await verify_cube( 24 | MessagePacket("!cube", user="Username"), 25 | "Username³" 26 | ) 27 | 28 | await verify_cube( 29 | MessagePacket("!cube 2"), 30 | "8. Whoa, that's 2Cubed!" 31 | ) 32 | 33 | await verify_cube( 34 | MessagePacket("!cube a b c d e f g h"), 35 | ' · '.join([n + '³' for n in "a b c d e f g h".split()]) 36 | ) 37 | 38 | await verify_cube( 39 | MessagePacket("!cube a b c d e f g h i"), 40 | "Whoa, that's 2 many cubes!" 41 | ) 42 | 43 | await verify_cube( 44 | MessagePacket("!cube 3 eggs and 4 slices of toast"), 45 | "27 · eggs³ · and³ · 64 · slices³ · of³ · toast³" 46 | ) 47 | 48 | await verify_cube( 49 | MessagePacket("!cube lots of taco salad ", ("emoji", "😃")), 50 | "lots³ · of³ · taco³ · salad³ · 😃³" 51 | ) 52 | 53 | temmie = Temmie(CactusAPI("test_token", "test_password")) 54 | 55 | 56 | @pytest.mark.asyncio 57 | async def test_temmie(): 58 | 59 | await temmie() 60 | 61 | assert (await temmie("hoi")).text == "hOI!!!!!! i'm tEMMIE!!" 62 | 63 | assert not (await temmie("hoi")).action 64 | assert (await temmie("flakes")).action 65 | -------------------------------------------------------------------------------- /tests/commands/test_multi.py: -------------------------------------------------------------------------------- 1 | """Test the multi command.""" 2 | 3 | import pytest 4 | 5 | from cactusbot.api import CactusAPI 6 | from cactusbot.commands.magic import Multi 7 | from cactusbot.packets import MessagePacket 8 | 9 | multi = Multi(CactusAPI("test_token", "test_password")) 10 | 11 | 12 | @pytest.mark.asyncio 13 | async def test_multi(): 14 | assert (await multi("b:test", "h:test")).text == ( 15 | "https://multistream.me/b:test/h:test/" 16 | ) 17 | 18 | assert (await multi("fake:test")) == ( 19 | "'fake' is not a valid service." 20 | ) 21 | -------------------------------------------------------------------------------- /tests/commands/test_trust.py: -------------------------------------------------------------------------------- 1 | """Test the trust command.""" 2 | 3 | import pytest 4 | 5 | from cactusbot.commands.magic import Trust 6 | from cactusbot.packets import MessagePacket 7 | 8 | 9 | class MockAPI: 10 | """Fake API.""" 11 | 12 | async def get_trust(self, user_id=None): 13 | """Get trusts.""" 14 | 15 | class Response: 16 | """Fake API response object.""" 17 | @property 18 | def status(self): 19 | """Response status.""" 20 | return 200 21 | 22 | async def json(self): 23 | """JSON version of the response.""" 24 | 25 | if user_id: 26 | return { 27 | "data": { 28 | { 29 | "attributes": { 30 | "token": "TestChannel", 31 | "userId": "95845", 32 | "userName": "Stanley" 33 | } 34 | } 35 | } 36 | } 37 | else: 38 | return { 39 | "data": [ 40 | { 41 | "attributes": { 42 | "token": "TestChannel", 43 | "userId": "95845", 44 | "userName": "Stanley" 45 | } 46 | } 47 | ] 48 | } 49 | return Response() 50 | 51 | async def add_trust(self, user_id, username): 52 | """Add a new trust.""" 53 | class Response: 54 | """Fake API response object.""" 55 | @property 56 | def status(self): 57 | """Response status.""" 58 | return 200 59 | 60 | async def json(self): 61 | """JSON response.""" 62 | return { 63 | "attributes": { 64 | "attributes": { 65 | "token": "TestChannel", 66 | "userId": "95845", 67 | "userName": "Stanley" 68 | }, 69 | "id": "7875b898-fbb3-426f-aca3-7375d97326b0", 70 | "type": "trust" 71 | }, 72 | "meta": { 73 | "created": True 74 | } 75 | } 76 | return Response() 77 | 78 | async def remove_trust(self, user_id): 79 | """Remove a trust.""" 80 | class Response: 81 | """Fake API response.""" 82 | @property 83 | def status(self): 84 | """Response status.""" 85 | return 200 86 | 87 | async def json(self): 88 | """JSON response.""" 89 | return { 90 | "meta": { 91 | "deleted": [ 92 | "7875b898-fbb3-426f-aca3-7375d97326b0" 93 | ] 94 | } 95 | } 96 | return Response() 97 | 98 | trust = Trust(MockAPI()) 99 | 100 | @pytest.mark.asyncio 101 | async def test_trust_list(): 102 | """Get a list of trusts.""" 103 | assert (await trust("list")) == "Trusted users: Stanley." 104 | 105 | @pytest.mark.asyncio 106 | async def test_trust_add(): 107 | """Add a new trust.""" 108 | assert (await trust("add", "Stanley")).text == "User Stanley has been trusted." 109 | 110 | @pytest.mark.asyncio 111 | async def test_trust_remove(): 112 | """Remove a trust.""" 113 | assert (await trust("remove", "Stanley")).text == "Removed trust for user Stanley." 114 | 115 | @pytest.mark.asyncio 116 | async def test_trust_toggle(): 117 | """Toggle a trust.""" 118 | assert (await trust("Stanley")).text == "Stanley is no longer trusted." 119 | -------------------------------------------------------------------------------- /tests/handlers/test_command.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from cactusbot.api import CactusAPI 4 | from cactusbot.handlers import CommandHandler 5 | from cactusbot.packets import MessagePacket 6 | 7 | command_handler = CommandHandler( 8 | "TestChannel", CactusAPI("test_token", "test_password")) 9 | 10 | 11 | def verify(message, expected, *args, **kwargs): 12 | """Verify target substitutions.""" 13 | actual = command_handler._inject( 14 | MessagePacket( 15 | *message if isinstance(message, list) else (message,)), 16 | *args, **kwargs 17 | ).text 18 | assert actual == expected 19 | 20 | 21 | @pytest.mark.asyncio 22 | async def test_on_message(): 23 | assert (await command_handler.on_message( 24 | MessagePacket("!cactus") 25 | )).text == "Ohai! I'm CactusBot! 🌵" 26 | 27 | 28 | def test_inject_argn(): 29 | 30 | verify( 31 | "Let's raid %ARG1%!", 32 | "Let's raid GreatStreamer!", 33 | "raid", "GreatStreamer" 34 | ) 35 | 36 | verify( 37 | "Let's raid %ARG1%! #%ARG2%", 38 | "Let's raid GreatStreamer! #ChannelRaid", 39 | "raid", "GreatStreamer", "ChannelRaid" 40 | ) 41 | 42 | verify( 43 | "Let's raid %ARG1%!", 44 | "Not enough arguments!", 45 | "raid" 46 | ) 47 | 48 | verify( 49 | "This is the !%ARG0% command.", 50 | "This is the !test command.", 51 | "test", "arg1", "arg2" 52 | ) 53 | 54 | verify( 55 | "%ARG1|upper% IS AMAZING!", 56 | "SALAD IS AMAZING!", 57 | "amazing", "salad", "taco" 58 | ) 59 | 60 | verify( 61 | "If you reverse %ARG1%, you get %ARG1|reverse%!", 62 | "If you reverse potato, you get otatop!", 63 | "reverse", "potato" 64 | ) 65 | 66 | verify( 67 | ["Let's raid %ARG1%! ", ("url", "beam.pro/%ARG1|tag%")], 68 | "Let's raid @Streamer! beam.pro/Streamer", 69 | "raid", "@Streamer" 70 | ) 71 | 72 | 73 | def test_inject_args(): 74 | 75 | verify( 76 | "Have some %ARGS%!", 77 | "Have some hamster-powered floofle waffles!", 78 | "gift", *"hamster-powered floofle waffles".split() 79 | ) 80 | 81 | verify( 82 | "Have some %ARGS%.", 83 | "Not enough arguments!", 84 | "give" 85 | ) 86 | 87 | 88 | def test_inject_user(): 89 | 90 | verify( 91 | "Ohai, %USER%!", 92 | "Ohai, SomeUser!", 93 | "ohai", username="SomeUser" 94 | ) 95 | 96 | verify( 97 | "Ohai, %USER%!", 98 | "Ohai, %USER%!", 99 | "ohai" 100 | ) 101 | 102 | 103 | def test_inject_count(): 104 | pass 105 | 106 | 107 | def test_inject_channel(): 108 | 109 | verify( 110 | "Welcome to %CHANNEL%'s stream!", 111 | "Welcome to GreatStreamer's stream!", 112 | "welcome", channel="GreatStreamer" 113 | ) 114 | 115 | verify( 116 | "Welcome to %CHANNEL%'s stream!", 117 | "Welcome to %CHANNEL%'s stream!", 118 | "welcome" 119 | ) 120 | -------------------------------------------------------------------------------- /tests/handlers/test_events.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from cactusbot.handlers import EventHandler 4 | from cactusbot.packets import EventPacket 5 | 6 | 7 | class MockAPI: 8 | 9 | async def get_config(self): 10 | 11 | class Response: 12 | 13 | async def json(self): 14 | 15 | return { 16 | "data": {"attributes": {"announce": { 17 | "follow": { 18 | "announce": True, 19 | "message": "Thanks for following, %USER%!" 20 | }, 21 | "sub": { 22 | "announce": True, 23 | "message": "Thanks for subscribing, %USER%!" 24 | }, 25 | "host": { 26 | "announce": True, 27 | "message": "Thanks for hosting, %USER%!" 28 | }, 29 | "join": { 30 | "announce": True, 31 | "message": "Welcome to the channel, %USER%!" 32 | }, 33 | "leave": { 34 | "announce": True, 35 | "message": "Thanks for watching, %USER%!" 36 | } 37 | }}} 38 | } 39 | 40 | return Response() 41 | 42 | event_handler = EventHandler({ 43 | "cache_follow": True, 44 | "cache_host": True, 45 | "cache_join": True, 46 | "cache_leave": True, 47 | "cache_time": 1200 48 | }, MockAPI()) 49 | 50 | 51 | @pytest.mark.asyncio 52 | async def test_on_message(): 53 | 54 | assert (await event_handler.on_start( 55 | None 56 | )).text == "CactusBot activated. 🌵" 57 | 58 | 59 | @pytest.mark.asyncio 60 | async def test_on_follow(): 61 | 62 | assert (await event_handler.on_follow(EventPacket( 63 | "follow", "TestUser" 64 | ))).text == "Thanks for following, TestUser!" 65 | 66 | assert (await event_handler.on_follow(EventPacket( 67 | "follow", "TestUser", success=False 68 | ))) is None 69 | 70 | 71 | @pytest.mark.asyncio 72 | async def test_on_subscribe(): 73 | 74 | assert (await event_handler.on_subscribe(EventPacket( 75 | "subscribe", "TestUser" 76 | ))).text == "Thanks for subscribing, TestUser!" 77 | 78 | 79 | @pytest.mark.asyncio 80 | async def test_on_host(): 81 | 82 | assert (await event_handler.on_host(EventPacket( 83 | "host", "TestUser" 84 | ))).text == "Thanks for hosting, TestUser!" 85 | 86 | @pytest.mark.asyncio 87 | async def test_on_join(): 88 | 89 | assert (await event_handler.on_join(EventPacket( 90 | "join", "TestUser" 91 | ))).text == "Welcome to the channel, TestUser!" 92 | 93 | @pytest.mark.asyncio 94 | async def test_on_leave(): 95 | 96 | assert (await event_handler.on_leave(EventPacket( 97 | "leave", "TestUser" 98 | ))).text == "Thanks for watching, TestUser!" 99 | -------------------------------------------------------------------------------- /tests/handlers/test_respond.py: -------------------------------------------------------------------------------- 1 | """Test the respond handler.""" 2 | 3 | import pytest 4 | 5 | from cactusbot.api import CactusAPI 6 | from cactusbot.handlers import ResponseHandler 7 | from cactusbot.packets import Packet, MessagePacket 8 | 9 | response_handler = ResponseHandler() 10 | 11 | 12 | @pytest.mark.asyncio 13 | async def test_user_update(): 14 | """Test the user update event.""" 15 | 16 | await response_handler.on_username_update(Packet(username="TestUser")) 17 | assert response_handler.username == "TestUser" 18 | 19 | @pytest.mark.asyncio 20 | async def test_on_message(): 21 | """Test the message event.""" 22 | 23 | assert (await response_handler.on_message( 24 | MessagePacket("!testing", user="TestUser") 25 | )) == StopIteration 26 | -------------------------------------------------------------------------------- /tests/handlers/test_spam.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from cactusbot.handlers import SpamHandler 4 | from cactusbot.packets import MessagePacket 5 | 6 | async def get_user_id(_): 7 | return 0 8 | 9 | 10 | class MockAPI: 11 | 12 | async def get_trust(self, _): 13 | 14 | class Response: 15 | status = 404 16 | 17 | return Response() 18 | 19 | spam_handler = SpamHandler(MockAPI()) 20 | 21 | 22 | @pytest.mark.asyncio 23 | async def test_on_message(): 24 | 25 | assert (await spam_handler.on_message( 26 | MessagePacket("THIS CONTAINS EXCESSIVE CAPITAL LETTERS.") 27 | ))[0].text == "Please do not spam capital letters." 28 | 29 | assert (await spam_handler.on_message(MessagePacket( 30 | "This is what one hundred emoji looks like!", 31 | *(("emoji", "😮"),) * 100 32 | )))[0].text == "Please do not spam emoji." 33 | 34 | assert (await spam_handler.on_message(MessagePacket( 35 | "Check out my amazing Twitter!", 36 | ("url", "twitter.com/CactusDevTeam", 37 | "https://twitter.com/CactusDevTeam") 38 | )))[0].text == "Please do not post URLs." 39 | 40 | assert await spam_handler.on_message( 41 | MessagePacket("PLEASE STOP SPAMMING CAPITAL LETTERS.", role=50) 42 | ) is None 43 | 44 | 45 | def test_check_caps(): 46 | 47 | assert not spam_handler.check_caps("") 48 | assert not spam_handler.check_caps("X") 49 | assert not spam_handler.check_caps("3.14159265358979") 50 | 51 | assert not spam_handler.check_caps( 52 | "This is a reasonable message!") 53 | assert not spam_handler.check_caps("WOW, that was incredible!") 54 | 55 | assert spam_handler.check_caps( 56 | "THIS IS DEFINITELY CAPITALIZED SPAM.") 57 | assert spam_handler.check_caps( 58 | "THAT was SO COOL! OMG WOW FANTASTIC!") 59 | 60 | 61 | def test_check_emoji(): 62 | 63 | assert not spam_handler.check_emoji(MessagePacket( 64 | "This message contains no emoji." 65 | )) 66 | 67 | assert not spam_handler.check_emoji(MessagePacket( 68 | "Wow, that was great!", ("emoji", "😄"))) 69 | 70 | assert not spam_handler.check_emoji(MessagePacket( 71 | *(("emoji", "🌵"),) * 6 72 | )) 73 | 74 | assert not spam_handler.check_emoji(MessagePacket( 75 | ("emoji", "😃"), 76 | ("emoji", "😛"), 77 | ("emoji", "🌵"), 78 | ("emoji", "🐹"), 79 | ("emoji", "🥔"), 80 | ("emoji", "💚") 81 | )) 82 | 83 | assert spam_handler.check_emoji(MessagePacket( 84 | *(("emoji", "🌵"),) * 7 85 | )) 86 | 87 | assert spam_handler.check_emoji(MessagePacket( 88 | ("emoji", "😃"), 89 | ("emoji", "😛"), 90 | ("emoji", "🌵"), 91 | ("emoji", "🐹"), 92 | ("emoji", "🥔"), 93 | ("emoji", "💚"), 94 | ("emoji", "😎") 95 | )) 96 | 97 | assert spam_handler.check_emoji(MessagePacket( 98 | *(("emoji", "😄"),) * 100 99 | )) 100 | 101 | 102 | def test_check_urls(): 103 | 104 | assert not spam_handler.contains_urls(MessagePacket( 105 | "This message contains no URLs." 106 | )) 107 | 108 | assert not spam_handler.contains_urls(MessagePacket( 109 | "google.com was not parsed as a URL, and is therefore 'fine'." 110 | )) 111 | 112 | assert spam_handler.contains_urls(MessagePacket( 113 | "You should go check out ", 114 | ("url", "cactusbot.rtfd.org", "https://cactusbot.rtfd.org") 115 | )) 116 | -------------------------------------------------------------------------------- /tests/packets/test_ban.py: -------------------------------------------------------------------------------- 1 | """Test the ban packet.""" 2 | 3 | from cactusbot.packets import BanPacket 4 | 5 | def test_ban_packet(): 6 | packet = BanPacket("TestUser") 7 | 8 | assert packet.duration == 0 9 | assert packet.user == "TestUser" 10 | -------------------------------------------------------------------------------- /tests/packets/test_message.py: -------------------------------------------------------------------------------- 1 | from cactusbot.packets import MessagePacket 2 | 3 | 4 | def test_copy(): 5 | 6 | initial = MessagePacket("Test message.", user="TestUser") 7 | 8 | copy = initial.copy() 9 | assert copy.text == "Test message." 10 | assert copy.user == "TestUser" 11 | 12 | assert initial.copy() is not initial 13 | 14 | new_copy = initial.copy("New message!") 15 | assert new_copy.text == "New message!" 16 | assert new_copy.user == "TestUser" 17 | 18 | 19 | def test_replace(): 20 | 21 | assert MessagePacket("a b c").replace(a='x', b='y').text == "x y c" 22 | 23 | assert MessagePacket("a b c").replace().text == "a b c" 24 | 25 | assert MessagePacket("a b c").replace(b='').text == "a c" 26 | 27 | 28 | def test_sub(): 29 | """Test regex substitution.""" 30 | 31 | assert MessagePacket("%USER% is great!").sub( 32 | "%USER%", "Stanley").text == "Stanley is great!" 33 | assert MessagePacket("I would like 3 ", ("emoji", "😃"), "s.").sub( 34 | r'\d+', "").text == "I would like 😃s." 35 | 36 | 37 | def _split(text, *args, **kwargs): 38 | return [ 39 | component.text 40 | for component in 41 | MessagePacket(text).split(*args, **kwargs) 42 | ] 43 | 44 | 45 | def test_split(): 46 | """Test splitting message packets.""" 47 | 48 | assert _split("0 1 2 3") == ['0', '1', '2', '3'] 49 | assert _split("0 1 2 3", "2") == ['0 1 ', ' 3'] 50 | assert _split("0 1 2 3", maximum=2) == ['0', '1', '2 3'] 51 | assert _split("0 1 2 3 ") == ['0', '1', '2', '3'] 52 | assert _split(" 0 1 2 3") == ['0', '1', '2', '3'] 53 | assert _split(" 0 1 2 3 ") == ['0', '1', '2', '3'] 54 | 55 | 56 | def test_join(): 57 | """Test joining message packets.""" 58 | 59 | assert MessagePacket.join( 60 | MessagePacket(("text", "I like "), ("emoji", "😃")), 61 | MessagePacket(" kittens!") 62 | ).text == "I like 😃 kittens!" 63 | 64 | assert MessagePacket.join( 65 | MessagePacket("Testing"), 66 | MessagePacket(" Stuff!") 67 | ).text == "Testing Stuff!" 68 | 69 | assert MessagePacket.join().text == "" 70 | 71 | assert MessagePacket.join( 72 | MessagePacket("Hello"), 73 | MessagePacket("world!"), 74 | separator="... " 75 | ).text == "Hello... world!" 76 | -------------------------------------------------------------------------------- /tests/services/test_beam.py: -------------------------------------------------------------------------------- 1 | from cactusbot.packets import MessagePacket 2 | from cactusbot.services.beam.parser import BeamParser 3 | 4 | 5 | def test_parse_message(): 6 | 7 | assert BeamParser.parse_message({ 8 | 'channel': 2151, 9 | 'id': '7f43cca0-a9c5-11e6-9c8f-6bd6b629c2eb', 10 | 'message': { 11 | 'message': [ 12 | {'data': 'Hello, world!', 13 | 'text': 'Hello, world!', 14 | 'type': 'text'} 15 | ], 16 | 'meta': {} 17 | }, 18 | 'user_id': 2547, 19 | 'user_name': '2Cubed', 20 | 'user_roles': ['Owner'] 21 | }).json == { 22 | "message": [{ 23 | "type": "text", 24 | "data": "Hello, world!", 25 | "text": "Hello, world!" 26 | }], 27 | "user": "2Cubed", 28 | "role": 5, 29 | "action": False, 30 | "target": None 31 | } 32 | 33 | assert BeamParser.parse_message({ 34 | 'channel': 2151, 35 | 'id': '8ef6a160-a9c8-11e6-9c8f-6bd6b629c2eb', 36 | 'message': { 37 | 'message': [ 38 | {'data': 'waves ', 39 | 'text': 'waves ', 40 | 'type': 'text'}, 41 | {'coords': {'height': 24, 'width': 24, 'x': 72, 'y': 0}, 42 | 'pack': 'default', 43 | 'source': 'builtin', 44 | 'text': ':D', 45 | 'type': 'emoticon'}], 46 | 'meta': {'me': True}}, 47 | 'user_id': 95845, 48 | 'user_name': 'Stanley', 49 | 'user_roles': ['User'] 50 | }).json == { 51 | "message": [{ 52 | "type": "text", 53 | "data": "waves ", 54 | "text": "waves " 55 | }, { 56 | "type": "emoji", 57 | "data": "😃", 58 | "text": ":D" 59 | }], 60 | "user": "Stanley", 61 | "role": 1, 62 | "action": True, 63 | "target": None 64 | } 65 | 66 | 67 | def test_parse_follow(): 68 | 69 | assert BeamParser.parse_follow({ 70 | 'following': True, 71 | 'user': { 72 | 'avatarUrl': 'https://uploads.beam.pro/avatar/l0icubxz-95845.jpg', 73 | 'bio': None, 74 | 'channel': {'audience': 'teen', 75 | 'badgeId': None, 76 | 'coverId': None, 77 | 'createdAt': '2016-03-05T20:41:21.000Z', 78 | 'deletedAt': None, 79 | 'description': None, 80 | 'featured': False, 81 | 'ftl': 0, 82 | 'hasTranscodes': True, 83 | 'hasVod': False, 84 | 'hosteeId': None, 85 | 'id': 68762, 86 | 'interactive': False, 87 | 'interactiveGameId': None, 88 | 'languageId': None, 89 | 'name': "Stanley's Channel", 90 | 'numFollowers': 0, 91 | 'online': False, 92 | 'partnered': False, 93 | 'suspended': False, 94 | 'thumbnailId': None, 95 | 'token': 'Stanley', 96 | 'transcodingProfileId': None, 97 | 'typeId': None, 98 | 'updatedAt': '2016-08-16T02:53:01.000Z', 99 | 'userId': 95845, 100 | 'viewersCurrent': 0, 101 | 'viewersTotal': 0, 102 | 'vodsEnabled': True}, 103 | 'createdAt': '2016-03-05T20:41:21.000Z', 104 | 'deletedAt': None, 105 | 'experience': 401, 106 | 'frontendVersion': None, 107 | 'id': 95845, 108 | 'level': 13, 109 | 'primaryTeam': None, 110 | 'social': {'verified': []}, 111 | 'sparks': 2236, 112 | 'updatedAt': '2016-08-20T04:35:25.000Z', 113 | 'username': 'Stanley', 114 | 'verified': True 115 | } 116 | }).json == { 117 | "user": "Stanley", 118 | "event": "follow", 119 | "success": True, 120 | "streak": 1 121 | } 122 | 123 | assert BeamParser.parse_follow({ 124 | 'following': False, 125 | 'user': { 126 | 'avatarUrl': 'https://uploads.beam.pro/avatar/l0icubxz-95845.jpg', 127 | 'bio': None, 128 | 'channel': {'audience': 'teen', 129 | 'badgeId': None, 130 | 'coverId': None, 131 | 'createdAt': '2016-03-05T20:41:21.000Z', 132 | 'deletedAt': None, 133 | 'description': None, 134 | 'featured': False, 135 | 'ftl': 0, 136 | 'hasTranscodes': True, 137 | 'hasVod': False, 138 | 'hosteeId': None, 139 | 'id': 68762, 140 | 'interactive': False, 141 | 'interactiveGameId': None, 142 | 'languageId': None, 143 | 'name': "Stanley's Channel", 144 | 'numFollowers': 0, 145 | 'online': False, 146 | 'partnered': False, 147 | 'suspended': False, 148 | 'thumbnailId': None, 149 | 'token': 'Stanley', 150 | 'transcodingProfileId': None, 151 | 'typeId': None, 152 | 'updatedAt': '2016-08-16T02:53:01.000Z', 153 | 'userId': 95845, 154 | 'viewersCurrent': 0, 155 | 'viewersTotal': 0, 156 | 'vodsEnabled': True}, 157 | 'createdAt': '2016-03-05T20:41:21.000Z', 158 | 'deletedAt': None, 159 | 'experience': 401, 160 | 'frontendVersion': None, 161 | 'id': 95845, 162 | 'level': 13, 163 | 'primaryTeam': None, 164 | 'social': {'verified': []}, 165 | 'sparks': 2236, 166 | 'updatedAt': '2016-08-20T04:35:25.000Z', 167 | 'username': 'Stanley', 168 | 'verified': True 169 | } 170 | }).json == { 171 | "user": "Stanley", 172 | "event": "follow", 173 | "success": False, 174 | "streak": 1 175 | } 176 | 177 | 178 | def test_parse_subscribe(): 179 | 180 | assert BeamParser.parse_subscribe({ 181 | 'user': { 182 | 'avatarUrl': 'https://uploads.beam.pro/avatar/20621.jpg', 183 | 'bio': 'Broadcasting Daily at 10 AM PST. Join in on fun with mostly Minecraft.', 184 | 'createdAt': '2015-05-06T05:13:52.000Z', 185 | 'deletedAt': None, 186 | 'experience': 97980, 187 | 'frontendVersion': None, 188 | 'id': 20621, 189 | 'level': 88, 190 | 'primaryTeam': 89, 191 | 'social': { 192 | 'player': 'https://player.me/innectic', 193 | 'twitter': 'https://twitter.com/Innectic', 194 | 'verified': [] 195 | }, 196 | 'sparks': 174519, 197 | 'updatedAt': '2016-08-27T02:11:24.000Z', 198 | 'username': 'Innectic', 199 | 'verified': True 200 | } 201 | }).json == { 202 | "user": "Innectic", 203 | "event": "subscribe", 204 | "success": True, 205 | "streak": 1 206 | } 207 | 208 | def test_parse_resubscribe(): 209 | 210 | assert BeamParser.parse_resubscribe({ 211 | "totalMonths": 3, 212 | "user": { 213 | "level": 88, 214 | "social": { 215 | "player": "https://player.me/innectic", 216 | "twitter": "https://twitter.com/Innectic", 217 | "verified": [] 218 | }, 219 | "id": 20621, 220 | "username": 'Innectic', 221 | "verified": True, 222 | "experience": 97980, 223 | "sparks": 174519, 224 | "avatarUrl": 'https://uploads.beam.pro/avatar/20621.jpg', 225 | "bio": 'Broadcasting Daily at 10 AM PST. Join in on fun with mostly Minecraft.', 226 | "primaryTeam": 89, 227 | "createdAt": '2016-08-27T02:11:24.000Z', 228 | 'updatedAt': '2016-08-27T02:11:24.000Z', 229 | "deletedAt": None 230 | }, 231 | "since": '2016-11-12T20:01:55.000Z', 232 | "until": '2017-03-13T21:02:25.000Z' 233 | }).json == { 234 | "user": "Innectic", 235 | "event": "subscribe", 236 | "success": True, 237 | "streak": 3 238 | } 239 | 240 | 241 | def test_parse_host(): 242 | 243 | assert BeamParser.parse_host({ 244 | 'hoster': { 245 | 'audience': 'teen', 246 | 'badgeId': None, 247 | 'coverId': None, 248 | 'createdAt': '2016-03-05T20:41:21.000Z', 249 | 'deletedAt': None, 250 | 'description': None, 251 | 'featured': False, 252 | 'ftl': 0, 253 | 'hasTranscodes': True, 254 | 'hasVod': False, 255 | 'hosteeId': 3016, 256 | 'id': 68762, 257 | 'interactive': False, 258 | 'interactiveGameId': None, 259 | 'languageId': None, 260 | 'name': "Stanley's Channel", 261 | 'numFollowers': 0, 262 | 'online': False, 263 | 'partnered': False, 264 | 'suspended': False, 265 | 'thumbnailId': None, 266 | 'token': 'Stanley', 267 | 'transcodingProfileId': None, 268 | 'typeId': None, 269 | 'updatedAt': '2016-11-13T20:21:59.000Z', 270 | 'userId': 95845, 271 | 'viewersCurrent': 0, 272 | 'viewersTotal': 0, 273 | 'vodsEnabled': True}, 274 | 'hosterId': 68762 275 | }).json == { 276 | "user": "Stanley", 277 | "event": "host", 278 | "success": True, 279 | "streak": 1 280 | } 281 | 282 | 283 | def test_synthesize(): 284 | 285 | assert BeamParser.synthesize(MessagePacket( 286 | "Hey, ", 287 | ("tag", "Stanley"), 288 | "! ", 289 | ("emoji", "🌵"), 290 | " Check out ", 291 | ("url", "https://cactusbot.rtfd.org", "cactusbot.rtfd.org"), 292 | "!" 293 | )) == (("Hey, @Stanley! :cactus Check out cactusbot.rtfd.org!",), {}) 294 | 295 | assert BeamParser.synthesize(MessagePacket( 296 | "waves", action=True 297 | )) == (("/me waves",), {}) 298 | 299 | assert BeamParser.synthesize(MessagePacket( 300 | "Hello!", target="Stanley" 301 | )) == (("Stanley", "Hello!",), {"method": "whisper"}) 302 | --------------------------------------------------------------------------------