├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .gitlab-ci.yml ├── LICENSE ├── README.md ├── __init__.py ├── assets ├── builds.json ├── discovery_categories.json ├── experiments.json ├── guild_experiments.json └── patch.js ├── attachments └── .gitkeep ├── config.ci.py ├── config.example.py ├── discord_endpoints.txt ├── docs ├── README.md ├── admin_api.md ├── clients.md ├── differences.md ├── lvsp.md ├── operating.md ├── pubsub.md └── structure.md ├── ecosystem.config.js ├── images └── .gitkeep ├── litecord ├── __init__.py ├── admin_schemas.py ├── auth.py ├── blueprints │ ├── __init__.py │ ├── admin_api │ │ ├── __init__.py │ │ ├── channels.py │ │ ├── guilds.py │ │ ├── info.py │ │ ├── instance_invites.py │ │ ├── users.py │ │ └── voice.py │ ├── applications.py │ ├── attachments.py │ ├── auth.py │ ├── channel │ │ ├── __init__.py │ │ ├── messages.py │ │ ├── pins.py │ │ └── reactions.py │ ├── channels.py │ ├── checks.py │ ├── dm_channels.py │ ├── dms.py │ ├── gateway.py │ ├── guild │ │ ├── __init__.py │ │ ├── channels.py │ │ ├── emoji.py │ │ ├── members.py │ │ ├── mod.py │ │ └── roles.py │ ├── guilds.py │ ├── icons.py │ ├── invites.py │ ├── misc.py │ ├── read_states.py │ ├── relationships.py │ ├── static.py │ ├── stickers.py │ ├── store.py │ ├── user │ │ ├── __init__.py │ │ ├── billing.py │ │ ├── billing_job.py │ │ ├── fake_store.py │ │ └── settings.py │ ├── users.py │ ├── voice.py │ └── webhooks.py ├── common │ ├── __init__.py │ ├── channels.py │ ├── guilds.py │ ├── interop.py │ ├── messages.py │ └── users.py ├── dispatcher.py ├── embed │ ├── __init__.py │ ├── messages.py │ ├── sanitizer.py │ └── schemas.py ├── enums.py ├── errors.py ├── gateway │ ├── encoding.py │ ├── errors.py │ ├── gateway.py │ ├── opcodes.py │ ├── schemas.py │ ├── state.py │ ├── state_manager.py │ ├── utils.py │ └── websocket.py ├── guild_memory_store.py ├── images.py ├── jobs.py ├── json.py ├── permissions.py ├── presence.py ├── pubsub │ ├── __init__.py │ ├── channel.py │ ├── dispatcher.py │ ├── friend.py │ ├── guild.py │ ├── lazy_guild.py │ ├── member.py │ ├── user.py │ └── utils.py ├── ratelimits │ ├── bucket.py │ ├── handler.py │ └── main.py ├── schemas.py ├── snowflake.py ├── storage.py ├── system_messages.py ├── types.py ├── typing_hax.py ├── user_storage.py ├── utils.py └── voice │ ├── lvsp_conn.py │ ├── lvsp_manager.py │ ├── lvsp_opcodes.py │ ├── manager.py │ └── state.py ├── manage.py ├── manage ├── __init__.py ├── cmd │ ├── invites.py │ ├── migration │ │ ├── __init__.py │ │ ├── command.py │ │ └── scripts │ │ │ ├── 0_base.sql │ │ │ ├── 10_permissions.sql │ │ │ ├── 11_user_bio_and_accent_color.sql │ │ │ ├── 12_inline_replies.sql │ │ │ ├── 13_fix_member_foreign_key.sql │ │ │ ├── 14_add_user_system.sql │ │ │ ├── 15_remove_guild_region.sql │ │ │ ├── 16_add_guild_progress_bar.sql │ │ │ ├── 17_add_banners_member_stuff.sql │ │ │ ├── 18_refactor_images.sql │ │ │ ├── 19_member_bio.sql │ │ │ ├── 1_webhook_avatars.sql │ │ │ ├── 20_fuck_icons.sql │ │ │ ├── 21_fuck_icons_2.sql │ │ │ ├── 22_add_channel_banner.sql │ │ │ ├── 23_add_user_pronouns.sql │ │ │ ├── 24_add_avatar_decos.sql │ │ │ ├── 25_add_webhook_type.sql │ │ │ ├── 26_make_nonce_nullable.sql │ │ │ ├── 27_add_more_profile_stuff.sql │ │ │ ├── 28_nsfw_level.sql │ │ │ ├── 29_friend_nicknames.sql │ │ │ ├── 2_fix_chan_overwrites_constraint.sql │ │ │ ├── 30_proper_replies_type.sql │ │ │ ├── 31_allowed_mentions_proper.sql │ │ │ ├── 3_add_message_flags.sql │ │ │ ├── 4_fix_constraints.sql │ │ │ ├── 5_add_rules_channel_id.sql │ │ │ ├── 6_add_public_updates_channel_id.sql │ │ │ ├── 7_add_prefered_locale.sql │ │ │ ├── 8_add_discovery_splash.sql │ │ │ └── 9_add_custom_status_settings.sql │ └── users.py └── main.py ├── mypy.ini ├── nginx.example.conf ├── poetry.lock ├── pyproject.toml ├── run.py ├── setup.py ├── static ├── css │ └── invite_register.css ├── invite_register.html └── logo │ ├── logo.png │ ├── logo.svg │ └── logo@2x.png ├── templates ├── 2016.html ├── 2018.html ├── 2020.html └── build_override.html ├── tests ├── common.py ├── conftest.py ├── test_admin_api │ ├── test_guilds.py │ ├── test_instance_invites.py │ └── test_users.py ├── test_channels.py ├── test_embeds.py ├── test_gateway.py ├── test_guild.py ├── test_invites.py ├── test_main.py ├── test_messages.py ├── test_no_tracking.py ├── test_ratelimits.py ├── test_reactions.py ├── test_user.py ├── test_webhooks.py └── test_websocket.py └── tox.ini /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | on: push 3 | 4 | env: 5 | PIP_CACHE_DIR: '$GITHUB_WORKSPACE/.cache/pip' 6 | POSTGRES_HOST_AUTH_METHOD: 'trust' 7 | 8 | jobs: 9 | tests: 10 | name: Run tests 11 | runs-on: ubuntu-latest 12 | 13 | container: 14 | image: python:3.9-alpine 15 | 16 | services: 17 | postgres: 18 | image: postgres:10.8 19 | env: 20 | POSTGRES_USER: postgres 21 | POSTGRES_PASSWORD: '' 22 | POSTGRES_DB: postgres 23 | ports: 24 | - 5432:5432 25 | # needed because the postgres container does not provide a healthcheck 26 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 27 | 28 | steps: 29 | - uses: actions/checkout@v3 30 | 31 | - name: Install dependencies 32 | run: time apk --update add --no-cache build-base gcc libgcc libffi-dev openssl-dev git postgresql-client jpeg-dev openjpeg-dev zlib-dev freetype-dev lcms2-dev freetype-dev gifsicle rust cargo 33 | 34 | - name: Install Python dependencies 35 | run: time pip3 install -U pip wheel tox 36 | 37 | - name: Copy dummy config 38 | run: cp config.ci.py config.py 39 | 40 | - name: Run tests 41 | run: time tox 42 | -------------------------------------------------------------------------------- /.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 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | config.py 107 | images/* 108 | attachments/* 109 | 110 | .DS_Store 111 | .vscode 112 | .idea 113 | 114 | # other 115 | nginx.conf 116 | assets/* -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | image: python:3.9-alpine 2 | 3 | variables: 4 | PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip" 5 | POSTGRES_HOST_AUTH_METHOD: "trust" 6 | 7 | services: 8 | - postgres:alpine 9 | 10 | cache: 11 | paths: 12 | - .cache/pip 13 | 14 | tests: 15 | before_script: 16 | - python -V 17 | - time apk --update add --no-cache build-base gcc libgcc libffi-dev openssl-dev git postgresql-client jpeg-dev openjpeg-dev zlib-dev freetype-dev lcms2-dev freetype-dev gifsicle rust cargo 18 | - time pip3 install --upgrade pip 19 | - time pip3 install wheel tox 20 | script: 21 | - ls 22 | - cp config.ci.py config.py 23 | - time tox 24 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | -------------------------------------------------------------------------------- /assets/experiments.json: -------------------------------------------------------------------------------- 1 | [ 2 | [4202366309, 0, 1, -1, 0], 3 | [532191640, 0, 1, -1, 0], 4 | [1176769702, 0, 1, -1, 0], 5 | [488500683, 0, 1, -1, 0], 6 | [4181417939, 0, 1, -1, 0], 7 | [257812432, 0, 1, -1, 0], 8 | [1094004645, 0, 1, -1, 0], 9 | [2651041681, 0, 1, -1, 0], 10 | [4169687140, 0, 1, -1, 0], 11 | [468191215, 0, 1, -1, 0], 12 | [828251710, 0, 1, -1, 0], 13 | [1428438599, 0, 1, -1, 0], 14 | [2898887059, 0, 3, -1, 0], 15 | [1562372053, 0, 1, -1, 0] 16 | ] 17 | -------------------------------------------------------------------------------- /assets/guild_experiments.json: -------------------------------------------------------------------------------- 1 | [ 2 | [1610697782, null, 0, [[[[1, [{"s": 0, "e": 10000}]]], []]], [], []], 3 | [2009396848, null, 0, [[[[1, [{"s": 0, "e": 10000}]]], []]], [], []], 4 | [3579083301, null, 0, [[[[1, [{"s": 0, "e": 10000}]]], []]], [], []], 5 | [3832113202, null, 0, [[[[1, [{"s": 0, "e": 10000}]]], []]], [], []], 6 | [4270134199, null, 0, [[[[1, [{"s": 0, "e": 10000}]]], []]], [], []], 7 | [754016061, null, 0, [[[[1, [{"s": 0, "e": 10000}]]], []]], [], []], 8 | [1574506570, null, 0, [[[[1, [{"s": 0, "e": 10000}]]], []]], [], []], 9 | [258580919, null, 0, [[[[1, [{"s": 0, "e": 10000}]]], []]], [], []], 10 | [786464609, null, 0, [[[[1, [{"s": 0, "e": 10000}]]], []]], [], []], 11 | [3683063649, null, 0, [[[[1, [{"s": 0, "e": 10000}]]], []]], [], []], 12 | [1055563741, null, 0, [[[[1, [{"s": 0, "e": 10000}]]], []]], [], []] 13 | ] 14 | -------------------------------------------------------------------------------- /assets/patch.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hood-network/patchcord/d0d084c5dfa1c3b5f4af659836fc4ff2c42bad5d/assets/patch.js -------------------------------------------------------------------------------- /attachments/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hood-network/patchcord/d0d084c5dfa1c3b5f4af659836fc4ff2c42bad5d/attachments/.gitkeep -------------------------------------------------------------------------------- /config.ci.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | 20 | MODE = "CI" 21 | 22 | 23 | class Config: 24 | """Default configuration values for litecord.""" 25 | 26 | MAIN_URL = "localhost:1" 27 | NAME = "gitlab ci" 28 | 29 | # Enable debug logging? 30 | DEBUG = False 31 | 32 | # Enable ssl? (gives wss:// instead of ws:// on gateway route) 33 | IS_SSL = False 34 | 35 | # what to give on gateway route? 36 | # this must point to the websocket. 37 | 38 | # Set this url to somewhere *your users* 39 | # will hit the websocket. 40 | # e.g 'gateway.example.com' for reverse proxies. 41 | WEBSOCKET_URL = "localhost:5001" 42 | 43 | # Where to host the websocket? 44 | # (a local address the server will bind to) 45 | WS_HOST = "localhost" 46 | WS_PORT = 5001 47 | 48 | # Postgres credentials 49 | POSTGRES = {} 50 | 51 | 52 | class Development(Config): 53 | DEBUG = True 54 | POSTGRES = { 55 | "host": "localhost", 56 | "user": "litecord", 57 | "password": "123", 58 | "database": "litecord", 59 | } 60 | 61 | 62 | class Production(Config): 63 | DEBUG = False 64 | IS_SSL = True 65 | 66 | 67 | class CI(Config): 68 | DEBUG = True 69 | 70 | POSTGRES = {"host": "postgres", "user": "postgres", "password": ""} 71 | -------------------------------------------------------------------------------- /config.example.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | 20 | MODE = "Development" 21 | 22 | 23 | class Config: 24 | """Default configuration values for Patchcord.""" 25 | 26 | #: Main URL of the instance. 27 | MAIN_URL = "discordapp.io" 28 | 29 | #: Name of the instance 30 | NAME = "Patchcord/Nya" 31 | 32 | #: Enable debug logging? 33 | DEBUG = False 34 | 35 | #: Enable ssl? 36 | # many routes will start giving https / wss 37 | # urls depending of this config. 38 | IS_SSL = False 39 | 40 | #: Enable registrations in this instance? 41 | REGISTRATIONS = False 42 | 43 | # what to give on gateway route? 44 | # this must point to the websocket. 45 | 46 | # Set this url to somewhere *your users* 47 | # will hit the websocket. 48 | # e.g 'gateway.example.com' for reverse proxies. 49 | WEBSOCKET_URL = "localhost:5001" 50 | 51 | # Set these to file paths if you want to enable raw TLS support on 52 | # the websocket (without NGINX) 53 | WEBSOCKET_TLS_CERT_PATH = None 54 | WEBSOCKET_TLS_KEY_PATH = None 55 | 56 | #: Where to host the websocket? 57 | # (a local address the server will bind to) 58 | WS_HOST = "0.0.0.0" 59 | WS_PORT = 5001 60 | 61 | #: Mediaproxy URL on the internet 62 | # mediaproxy is made to prevent client IPs being leaked. 63 | # None is a valid value if you don't want to deploy mediaproxy. 64 | MEDIA_PROXY = "localhost:5002" 65 | 66 | #: Postgres credentials 67 | POSTGRES = {} 68 | 69 | #: Shared secret for LVSP 70 | LVSP_SECRET = "" 71 | 72 | #: Default client build 73 | DEFAULT_BUILD = "latest" 74 | 75 | #: Secret for various things 76 | SECRET_KEY = "secret" 77 | 78 | 79 | class Development(Config): 80 | DEBUG = True 81 | 82 | POSTGRES = { 83 | "host": "localhost", 84 | "user": "patchcord", 85 | "password": "123", 86 | "database": "patchcord", 87 | } 88 | 89 | 90 | class Production(Config): 91 | DEBUG = False 92 | IS_SSL = True 93 | 94 | POSTGRES = { 95 | "host": "some_production_postgres", 96 | "user": "some_production_user", 97 | "password": "some_production_password", 98 | "database": "patchcord_or_anything_else_really", 99 | } 100 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Internal documentation 2 | 3 | - `admin_api.md` for Litecord's Admin API. 4 | - `lvsp.md` for the Litecord Voice Server Protocol. 5 | - `pubsub.md` for how Pub/Sub works in Litecord. 6 | -------------------------------------------------------------------------------- /docs/clients.md: -------------------------------------------------------------------------------- 1 | # Using the client with Patchcord 2 | 3 | By default, Patchcord loads the latest Canary client on any url that isn't an explicit route or on `/api`. 4 | 5 | However, it has the capability of loading any build either in the (discord.sale)[https://discord.sale] API or `/assets/builds.json`. 6 | You can change the default client to any build in the config, or manually launch a seperate client. 7 | 8 | ## Loading other builds 9 | Builds can be loaded in two ways: 10 | 11 | 1. Launching directly 12 | You can navigate to `/launch/` or `/build/` to load a specific build hash. "latest" is a valid hash if you want to load the latest client. You can also navigate to `/launch` or `/build` for the latest build. 13 | 14 | 2. Build overrides 15 | Patchcord repurposes the build override system for loading Discord builds. This currently requires the staff flag (subject to change). Additionally, build overrides specified, again, in `/assets/builds.json` can also be loaded this way. As the whole system is based on build overrides, method 1 above also implicitly creates a build override. This allows for easily going back to the default client by just clearing the override. 16 | -------------------------------------------------------------------------------- /docs/differences.md: -------------------------------------------------------------------------------- 1 | # API Differences 2 | 3 | ## Request Guild Members 4 | 5 | ### In regards to ID serialization 6 | 7 | Request Guild Members does not follow the same logic Discord does when 8 | invalid IDs are given on the `user_ids` field. 9 | 10 | Instead of returning them as non-string numbers, **they're returned as-is.** 11 | 12 | This should not cause any problems to well-formed requests. 13 | 14 | ### Assumptions on business logic 15 | 16 | When using `user_ids`, Litecord will ignore the given `query` in the payload. 17 | -------------------------------------------------------------------------------- /docs/operating.md: -------------------------------------------------------------------------------- 1 | # Operating a Litecord instance 2 | 3 | `./manage.py` contains common admin tasks that you may want to do to the 4 | instance, e.g make someone an admin, or migrate the database, etc. 5 | 6 | Note, however, that many commands (example the ones related to user deletion) 7 | may be moved to the Admin API without proper notice. There is no frontend yet 8 | for the Admin API. 9 | 10 | The possible actions on `./manage.py` can be accessed via `./manage.py -h`, or 11 | `poetry run ./manage.py -h` if you're on poetry (recommended). 12 | 13 | ## `./manage.py generate_token`? 14 | 15 | You can generate a user token but only if that user is a bot. 16 | 17 | ## Instance invites 18 | 19 | If your instance has registrations disabled you can still get users to the 20 | instance via instance invites. This is something only Litecord does, using a 21 | separate API endpoint. 22 | 23 | Use `./manage.py makeinv` to generate an instance invite, give it out to users, 24 | point them towards `https:///invite_register.html`. Things 25 | should be straightforward from there. 26 | 27 | ## Making someone Staff 28 | 29 | **CAUTION:** Making someone staff, other than giving the Staff badge on their 30 | user flags, also gives complete access over the Admin API. Only make staff the 31 | people you (the instance OP) can trust. 32 | 33 | Use the `./manage.py make_staff` management task to make someone staff. There is 34 | no way to remove someone's staff with a `./manage.py` command _yet._ 35 | -------------------------------------------------------------------------------- /docs/pubsub.md: -------------------------------------------------------------------------------- 1 | # Pub/Sub (Publish/Subscribe) 2 | 3 | Please look over wikipedia or other sources to understand what it is. 4 | This only documents how an events are generated and dispatched to clients. 5 | 6 | ## Event definition 7 | 8 | Events are composed of two things: 9 | - Event type 10 | - Event payload 11 | 12 | More information on how events are structured are in the Discord Gateway 13 | API documentation. 14 | 15 | ## `StateManager` (litecord.gateway.state\_manager) 16 | 17 | StateManager stores all available instances of `GatewayState` that identified. 18 | Specific information is over the class' docstring, but we at least define 19 | what it does here for the next class: 20 | 21 | ## `EventDispatcher` (litecord.dispatcher) 22 | 23 | EventDispatcher declares the main interface between clients and the side-effects 24 | (events) from topics they can subscribe to. 25 | 26 | The topic / channel in EventDispatcher can be a User, or a Guild, or a Channel, 27 | etc. Users subscribe to the channels they have access to manually, and get 28 | unsubscribed when they e.g leave the guild the channel is on, etc. 29 | 30 | Channels are identified by their backend and a given key uniquely identifying 31 | the channel. The backend can be `guild`, `member`, `channel`, `user`, 32 | `friend`, and `lazy_guild`. Backends *can* store the list of subscribers, but 33 | that is not required. 34 | 35 | Each backend has specific logic around dispatching a single event towards 36 | all the subscribers of the given key. For example, the `guild` backend only 37 | dispatches events to shards that are properly subscribed to the guild, 38 | instead of all shards. The `user` backend just dispatches the event to 39 | all available shards in `StateManager`, etc. 40 | 41 | EventDispatcher also implements common logic, such as `dispatch_many` to 42 | dispatch a single event to multpiple keys in a single backend. This is useful 43 | e.g a user is updated and you want to dispatch `USER_UPDATE` events to many 44 | guilds without having to write a loop yourself. 45 | 46 | ## Backend superclasses (litecord.pubsub.dispatcher) 47 | 48 | The backend superclasses define what methods backends must provide to be 49 | fully functional within EventDispatcher. They define e.g what is the type 50 | of the keys, and have some specific backend helper methods, such as 51 | `_dispatch_states` to dispatch an event to a list of states without 52 | worrying about errors or writing the loop. 53 | 54 | The other available superclass is `DispatchWithState` for backends that 55 | require a list of subscribers to not repeat code. The only required method 56 | to be implemented is `dispatch()` and you can see how that works out 57 | on the backends that inherit from this class. 58 | 59 | ## Sending an event, practical 60 | 61 | Call `app.dispatcher.dispatch(backend_string, key, event_type, event_payload)`. 62 | 63 | example: 64 | - `dispatch('guild', guild_id, 'GUILD_UPDATE', guild)`, and other backends. 65 | The rules on how each backend dispatches its events can be found on the 66 | specific backend class. 67 | -------------------------------------------------------------------------------- /docs/structure.md: -------------------------------------------------------------------------------- 1 | # Project structure + Project-specific questions 2 | 3 | ## `attachments` and `images` 4 | 5 | They're empty folders on purpose. Litecord will write files to them to hold 6 | message attachments or avatars. 7 | 8 | ## `manage` 9 | 10 | Contains the `manage.py` script's main function, plus all the commands. 11 | A point of interest is the `manage/cmd/migration/scripts` folder, as they hold 12 | all the SQL scripts required for migrations. 13 | 14 | ## `litecord` 15 | 16 | The folder + `run.py` contain all of the backend's source code. The backend runs 17 | Quart as its HTTP server, and a `websockets` server for the Gateway. 18 | 19 | - `litecord/blueprints` for everything HTTP related. 20 | - `litecord/gateway` for main things related to the websocket or the Gateway. 21 | - `litecord/embed` contains code related to embed sanitization, schemas, and 22 | mediaproxy contact. 23 | - `litecord/ratelimits` hold the ratelimit implementation copied from 24 | discord.py plus a simple manager to hold the ratelimit objects. a point of 25 | interest is `litecord/ratelimits/handler.py` that holds the main thing. 26 | - `litecord/pubsub` is defined on `docs/pubsub.md`. 27 | - `litecord/voice` holds the voice implementation, LVSP client, etc. 28 | 29 | There are other files around `litecord/`, e.g the snowflake library, presence/ 30 | image/job managers, etc. 31 | 32 | ## `static` 33 | 34 | Holds static files, such as a basic index page and the `invite_register.html` 35 | page. 36 | 37 | ## `tests` 38 | 39 | Tests are run with `pytest` and the asyncio plugin for proper testing. A point 40 | of interest is `tests/conftest.py` as it contains test-specific configuration 41 | for the app object. Adding a test is trivial, as pytest will match against any 42 | file containing `test_` as a prefix. 43 | 44 | ## Litecord-specifics 45 | 46 | ### How do I fetch a User/Guild/Channel/DM/Group DM/...? 47 | 48 | You can find the common code to fetch those in `litecord/storage.py` on the 49 | `Storage` class, acessible via `app.storage`. A good example is 50 | `Storage.get_user`. There are no custom classes to hold common things used in 51 | the Discord API. 52 | 53 | They're all dictionaries and follow *at least* the same fields you would expect 54 | on the Discord API. 55 | 56 | ### How are API errors handled? 57 | 58 | Quart provides custom handling of errors via `errorhandler()`. You can look 59 | at the declared error handlers on `run.py`. The `500` error handler 60 | converts any 500 into a JSON error object for client ease-of-use. 61 | 62 | All litecord errors inherit from the `LitecordError` class, defining things 63 | on top such as its specific Discord error code and what HTTP status code 64 | to use. 65 | 66 | Error messages are documented [here](https://discordapp.com/developers/docs/topics/opcodes-and-status-codes#http). 67 | -------------------------------------------------------------------------------- /ecosystem.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | apps: [ 3 | { 4 | name: "litecord", 5 | script: "poetry run hypercorn run:app --bind 0.0.0.0:5000", 6 | watch: true, 7 | }, 8 | ], 9 | }; 10 | -------------------------------------------------------------------------------- /images/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hood-network/patchcord/d0d084c5dfa1c3b5f4af659836fc4ff2c42bad5d/images/.gitkeep -------------------------------------------------------------------------------- /litecord/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | -------------------------------------------------------------------------------- /litecord/admin_schemas.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | 20 | VOICE_SERVER = {"hostname": {"type": "string", "maxlength": 255, "required": True}} 21 | 22 | VOICE_REGION = { 23 | "id": {"type": "string", "maxlength": 255, "required": True}, 24 | "name": {"type": "string", "maxlength": 255, "required": True}, 25 | "vip": {"type": "boolean", "default": False}, 26 | "deprecated": {"type": "boolean", "default": False}, 27 | "custom": {"type": "boolean", "default": False}, 28 | } 29 | 30 | FEATURES = { 31 | "features": { 32 | "type": "list", 33 | "required": False, 34 | "schema": {"coerce": str}, 35 | } 36 | } 37 | 38 | USER_CREATE = { 39 | "id": {"coerce": int, "required": False}, 40 | "username": {"type": "username", "required": True}, 41 | "email": {"type": "email", "required": True}, 42 | "password": {"type": "string", "minlength": 5, "required": True}, 43 | "date_of_birth": {"type": "date", "required": False, "nullable": True}, 44 | } 45 | 46 | INSTANCE_INVITE = {"max_uses": {"type": "integer", "required": False, "default": 0}} 47 | 48 | GUILD_UPDATE = {"unavailable": {"type": "boolean", "required": False}} 49 | 50 | USER_UPDATE = {"flags": {"required": False, "coerce": int}} 51 | -------------------------------------------------------------------------------- /litecord/blueprints/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | 20 | from .gateway import bp as gateway 21 | from .auth import bp as auth 22 | from .users import bp as users 23 | from .guilds import bp as guilds 24 | from .channels import bp as channels 25 | from .webhooks import bp as webhooks 26 | from .misc import bp as misc 27 | from .voice import bp as voice 28 | from .invites import bp as invites 29 | from .relationships import bp as relationships 30 | from .dms import bp as dms 31 | from .icons import bp as icons 32 | from .static import bp as static 33 | from .attachments import bp as attachments 34 | from .dm_channels import bp as dm_channels 35 | from .read_states import bp as read_states 36 | from .stickers import bp as stickers 37 | from .applications import bp as applications 38 | from .store import bp as store 39 | 40 | __all__ = [ 41 | "gateway", 42 | "auth", 43 | "users", 44 | "guilds", 45 | "channels", 46 | "webhooks", 47 | "misc", 48 | "voice", 49 | "invites", 50 | "relationships", 51 | "dms", 52 | "icons", 53 | "static", 54 | "attachments", 55 | "dm_channels", 56 | "read_states", 57 | "stickers", 58 | "applications", 59 | "store", 60 | ] 61 | -------------------------------------------------------------------------------- /litecord/blueprints/admin_api/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | 20 | from .voice import bp as voice 21 | from .guilds import bp as guilds 22 | from .users import bp as users 23 | from .channels import bp as channels 24 | from .instance_invites import bp as instance_invites 25 | from .info import bp as info 26 | 27 | __all__ = ["voice", "guilds", "users", "channels", "instance_invites", "info"] 28 | -------------------------------------------------------------------------------- /litecord/blueprints/admin_api/channels.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | 20 | from quart import Blueprint, jsonify 21 | from typing import TYPE_CHECKING 22 | 23 | from litecord.auth import admin_check 24 | from litecord.blueprints.channel.messages import handle_get_messages 25 | from litecord.common.interop import message_view 26 | from litecord.schemas import validate 27 | from litecord.errors import InternalServerError, NotFound 28 | from litecord.utils import extract_limit 29 | 30 | if TYPE_CHECKING: 31 | from litecord.typing_hax import app, request 32 | else: 33 | from quart import current_app as app, request 34 | 35 | 36 | 37 | bp = Blueprint("channels_admin", __name__) 38 | 39 | 40 | @bp.route("", methods=["GET"], strict_slashes=False) 41 | async def query_channels(): 42 | await admin_check() 43 | 44 | limit = extract_limit(request, 1, 25, 100) 45 | j = validate( 46 | request.args.to_dict(), 47 | { 48 | "q": {"coerce": str, "required": False, "maxlength": 32}, 49 | "offset": {"coerce": int, "default": 0}, 50 | }, 51 | ) 52 | query = j.get("q") or "" 53 | offset = j["offset"] 54 | 55 | # TODO 56 | raise InternalServerError() 57 | 58 | 59 | @bp.route("/", methods=["GET"]) 60 | async def get_other(target_id): 61 | await admin_check() 62 | other = await app.storage.get_channel(target_id) 63 | if not other: 64 | raise NotFound(10003) 65 | return jsonify(other) 66 | 67 | 68 | @bp.route("/", methods=["DELETE"]) 69 | @bp.route("//delete", methods=["POST"]) 70 | async def delete_channel(channel_id: int): 71 | await admin_check() 72 | # TODO 73 | raise InternalServerError() 74 | 75 | 76 | @bp.route("/", methods=["PATCH"]) 77 | async def edit_channel(channel_id: int): 78 | await admin_check() 79 | # TODO 80 | raise InternalServerError() 81 | 82 | 83 | @bp.route("//messages", methods=["GET"]) 84 | async def get_messages(channel_id): 85 | await admin_check() 86 | return jsonify(await handle_get_messages(channel_id)) 87 | 88 | 89 | @bp.route("//messages/", methods=["GET"]) 90 | async def get_single_message(channel_id, message_id): 91 | await admin_check() 92 | 93 | message = await app.storage.get_message(message_id, user_id) 94 | if not message: 95 | raise NotFound(10008) 96 | 97 | return jsonify(message_view(message)) 98 | -------------------------------------------------------------------------------- /litecord/blueprints/admin_api/info.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | 20 | from quart import Blueprint, jsonify 21 | from typing import TYPE_CHECKING 22 | 23 | from litecord.auth import admin_check 24 | 25 | if TYPE_CHECKING: 26 | from litecord.typing_hax import app 27 | else: 28 | from quart import current_app as app 29 | 30 | bp = Blueprint("info_admin", __name__) 31 | 32 | 33 | @bp.route("/db", methods=["GET"]) 34 | async def get_db_url(): 35 | """Discover the app's DB URL.""" 36 | await admin_check() 37 | 38 | db = app.config["POSTGRES"] 39 | host = db["host"] 40 | if host in ("localhost", "0.0.0.0"): 41 | host = app.config["MAIN_URL"] 42 | 43 | return jsonify( 44 | { 45 | "url": f"postgres://{db['user']}:{db['password']}@{host}:5432/{db['database']}" 46 | } 47 | ) 48 | 49 | 50 | @bp.route("/snowflake", methods=["GET"]) 51 | async def generate_snowflake(): 52 | """Generate a snowflake.""" 53 | await admin_check() 54 | return jsonify({"id": str(app.winter_factory.snowflake())}) 55 | 56 | 57 | @bp.route("/counts", methods=["GET"]) 58 | async def get_counts(): 59 | """Get total counts of various things.""" 60 | counts = await app.db.fetchrow( 61 | """ 62 | SELECT COUNT(*) AS users, 63 | (SELECT COUNT(*) FROM icons) AS icons, 64 | (SELECT COUNT(*) FROM guilds) AS guilds, 65 | (SELECT COUNT(*) FROM bans) AS bans, 66 | (SELECT COUNT(*) FROM channels) AS channels, 67 | (SELECT COUNT(*) FROM guild_channels) AS guild_channels, 68 | (SELECT COUNT(*) FROM dm_channels) AS dms, 69 | (SELECT COUNT(*) FROM group_dm_channels) AS group_dms, 70 | (SELECT COUNT(*) FROM channel_overwrites) AS overwrites, 71 | (SELECT COUNT(*) FROM channel_pins) AS pins, 72 | (SELECT COUNT(*) FROM roles) AS roles, 73 | (SELECT COUNT(*) FROM messages) AS messages, 74 | (SELECT COUNT(*) FROM attachments) AS attachments, 75 | (SELECT COUNT(*) FROM message_reactions) AS reactions, 76 | (SELECT COUNT(*) FROM message_webhook_info) AS webhook_messages, 77 | (SELECT COUNT(*) FROM webhooks) AS webhooks, 78 | (SELECT COUNT(*) FROM invites) AS invites, 79 | (SELECT COUNT(*) FROM guild_emoji) AS emojis, 80 | (SELECT COUNT(*) FROM vanity_invites) AS vanities, 81 | (SELECT COUNT(*) FROM guild_integrations) AS integrations, 82 | (SELECT COUNT(*) FROM connections) AS connections, 83 | (SELECT COUNT(*) FROM relationships) AS relationships, 84 | (SELECT COUNT(*) FROM notes) AS notes 85 | FROM users 86 | """ 87 | ) 88 | 89 | counts = dict(counts) 90 | counts["private_channels"] = counts["dms"] + counts["group_dms"] 91 | return jsonify(counts) 92 | -------------------------------------------------------------------------------- /litecord/blueprints/admin_api/instance_invites.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | 20 | import string 21 | from random import choice 22 | from typing import Optional, TYPE_CHECKING 23 | 24 | from quart import Blueprint, jsonify 25 | 26 | from litecord.auth import admin_check 27 | from ...errors import InternalServerError, NotFound 28 | from litecord.types import timestamp_ 29 | from litecord.schemas import validate 30 | from litecord.admin_schemas import INSTANCE_INVITE 31 | 32 | if TYPE_CHECKING: 33 | from litecord.typing_hax import app, request 34 | else: 35 | from quart import current_app as app, request 36 | 37 | bp = Blueprint("instance_invites", __name__) 38 | ALPHABET = string.ascii_lowercase + string.ascii_uppercase + string.digits 39 | 40 | 41 | def _gen_inv() -> str: 42 | """Generate an invite code""" 43 | return "".join(choice(ALPHABET) for _ in range(6)) 44 | 45 | 46 | async def gen_inv(ctx) -> Optional[str]: 47 | """Generate an invite.""" 48 | for _ in range(10): 49 | possible_inv = _gen_inv() 50 | 51 | created_at = await ctx.db.fetchval( 52 | """ 53 | SELECT created_at 54 | FROM instance_invites 55 | WHERE code = $1 56 | """, 57 | possible_inv, 58 | ) 59 | 60 | if created_at is None: 61 | return possible_inv 62 | 63 | return None 64 | 65 | 66 | @bp.route("", methods=["GET"], strict_slashes=False) 67 | async def _all_instance_invites(): 68 | await admin_check() 69 | 70 | rows = await app.db.fetch( 71 | """ 72 | SELECT code, created_at, uses, max_uses 73 | FROM instance_invites 74 | """ 75 | ) 76 | 77 | rows = [dict(row) for row in rows] 78 | 79 | for row in rows: 80 | row["created_at"] = timestamp_(row["created_at"]) 81 | 82 | return jsonify(rows) 83 | 84 | 85 | @bp.route("", methods=["POST"], strict_slashes=False) 86 | async def _create_invite(): 87 | await admin_check() 88 | 89 | j = validate(await request.get_json(), INSTANCE_INVITE) 90 | 91 | code = await gen_inv(app) 92 | if code is None: 93 | raise InternalServerError() 94 | 95 | await app.db.execute( 96 | """ 97 | INSERT INTO instance_invites (code, max_uses) 98 | VALUES ($1, $2) 99 | """, 100 | code, 101 | j["max_uses"], 102 | ) 103 | 104 | inv = await app.db.fetchrow( 105 | """ 106 | SELECT code, created_at, uses, max_uses 107 | FROM instance_invites 108 | WHERE code = $1 109 | """, 110 | code, 111 | ) 112 | dinv = dict(inv) 113 | dinv["created_at"] = timestamp_(dinv["created_at"]) 114 | 115 | return jsonify(dinv) 116 | 117 | 118 | @bp.route("/", methods=["GET"]) 119 | async def _get_invite(invite): 120 | inv = await app.db.fetchrow( 121 | """ 122 | SELECT code, created_at, uses, max_uses 123 | FROM instance_invites 124 | WHERE code = $1 125 | """, 126 | invite, 127 | ) 128 | 129 | if not inv: 130 | raise NotFound(10006) 131 | dinv = dict(inv) 132 | dinv["created_at"] = timestamp_(dinv["created_at"]) 133 | return jsonify(dinv) 134 | 135 | 136 | @bp.route("/", methods=["DELETE"]) 137 | async def _del_invite(invite: str): 138 | await admin_check() 139 | 140 | res = await app.db.execute( 141 | """ 142 | DELETE FROM instance_invites 143 | WHERE code = $1 144 | """, 145 | invite, 146 | ) 147 | if res == "DELETE 0": 148 | raise NotFound(10006) 149 | 150 | return "", 204 151 | -------------------------------------------------------------------------------- /litecord/blueprints/admin_api/voice.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | 20 | import asyncpg 21 | from quart import Blueprint, jsonify 22 | from logbook import Logger 23 | from typing import TYPE_CHECKING 24 | from litecord.auth import admin_check 25 | from litecord.schemas import validate 26 | from litecord.admin_schemas import VOICE_SERVER, VOICE_REGION 27 | from litecord.errors import BadRequest 28 | 29 | if TYPE_CHECKING: 30 | from litecord.typing_hax import app, request 31 | else: 32 | from quart import current_app as app, request 33 | 34 | log = Logger(__name__) 35 | bp = Blueprint("voice_admin", __name__) 36 | 37 | 38 | @bp.route("/regions/", methods=["GET"]) 39 | async def get_region_servers(region): 40 | """Return a list of all servers for a region.""" 41 | await admin_check() 42 | servers = await app.voice.voice_server_list(region) 43 | return jsonify(servers) 44 | 45 | 46 | @bp.route("/regions", methods=["PUT"]) 47 | async def insert_new_region(): 48 | """Create a voice region.""" 49 | await admin_check() 50 | j = validate(await request.get_json(), VOICE_REGION) 51 | 52 | j["id"] = j["id"].lower() 53 | 54 | await app.db.execute( 55 | """ 56 | INSERT INTO voice_regions (id, name, vip, deprecated, custom) 57 | VALUES ($1, $2, $3, $4, $5) 58 | """, 59 | j["id"], 60 | j["name"], 61 | j["vip"], 62 | j["deprecated"], 63 | j["custom"], 64 | ) 65 | 66 | regions = await app.storage.all_voice_regions() 67 | region_count = len(regions) 68 | 69 | # if region count is 1, this is the first region to be created, 70 | # so we should update all guilds to that region 71 | if region_count == 1: 72 | res = await app.db.execute( 73 | """ 74 | UPDATE guilds 75 | SET region = $1 76 | """, 77 | j["id"], 78 | ) 79 | 80 | log.info("updating guilds to first voice region: {}", res) 81 | 82 | await app.voice.lvsp.refresh_regions() 83 | return jsonify(regions) 84 | 85 | 86 | @bp.route("/regions//server", methods=["PUT"]) 87 | async def put_region_server(region): 88 | """Insert a voice server to a region""" 89 | await admin_check() 90 | j = validate(await request.get_json(), VOICE_SERVER) 91 | 92 | try: 93 | await app.db.execute( 94 | """ 95 | INSERT INTO voice_servers (hostname, region_id) 96 | VALUES ($1, $2) 97 | """, 98 | j["hostname"], 99 | region, 100 | ) 101 | except asyncpg.UniqueViolationError: 102 | raise BadRequest(message="voice server already exists with given hostname") 103 | 104 | return "", 204 105 | 106 | 107 | @bp.route("/regions//deprecate", methods=["PUT"]) 108 | async def deprecate_region(region): 109 | """Deprecate a voice region.""" 110 | await admin_check() 111 | 112 | # TODO: write this 113 | await app.voice.disable_region(region) 114 | 115 | await app.db.execute( 116 | """ 117 | UPDATE voice_regions 118 | SET deprecated = true 119 | WHERE id = $1 120 | """, 121 | region, 122 | ) 123 | 124 | return "", 204 125 | 126 | 127 | async def guild_region_check(): 128 | """Check all guilds for voice region inconsistencies. 129 | 130 | Since the voice migration caused all guilds.region columns 131 | to become NULL, we need to remove such NULLs if we have more 132 | than one region setup. 133 | """ 134 | 135 | regions = await app.storage.all_voice_regions() 136 | 137 | if not regions: 138 | log.info("region check: no regions to move guilds to") 139 | return 140 | 141 | res = await app.db.execute( 142 | """ 143 | UPDATE guilds 144 | SET region = ( 145 | SELECT id 146 | FROM voice_regions 147 | OFFSET floor(random()*$1) 148 | LIMIT 1 149 | ) 150 | WHERE region = NULL 151 | """, 152 | len(regions), 153 | ) 154 | 155 | log.info("region check: updating guild.region=null: {!r}", res) 156 | -------------------------------------------------------------------------------- /litecord/blueprints/applications.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | 20 | from quart import Blueprint, redirect 21 | 22 | bp = Blueprint("applications", __name__) 23 | 24 | 25 | @bp.route("/detectable") 26 | async def _detectable_stub(): 27 | return redirect("https://discord.com/api/v9/applications/detectable", code=308) 28 | -------------------------------------------------------------------------------- /litecord/blueprints/attachments.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | 20 | from pathlib import Path 21 | 22 | from quart import Blueprint, send_file, current_app as app, request 23 | from PIL import Image, UnidentifiedImageError 24 | 25 | from litecord.images import resize_gif 26 | 27 | bp = Blueprint("attachments", __name__) 28 | ATTACHMENTS = Path.cwd() / "attachments" 29 | 30 | 31 | async def _resize_gif( 32 | attach_id: int, resized_path: Path, width: int, height: int 33 | ) -> str: 34 | """Resize a GIF attachment.""" 35 | 36 | # get original gif bytes 37 | orig_path = ATTACHMENTS / f"{attach_id}.gif" 38 | orig_bytes = orig_path.read_bytes() 39 | 40 | # give them and the target size to the 41 | # image module's resize_gif 42 | 43 | _, raw_data = await resize_gif(orig_bytes, (width, height)) 44 | 45 | # write raw_data to the destination 46 | resized_path.write_bytes(raw_data) 47 | 48 | return str(resized_path) 49 | 50 | 51 | FORMAT_HARDCODE = {"jpg": "jpeg", "jpe": "jpeg"} 52 | 53 | 54 | def to_format(ext: str) -> str: 55 | """Return a proper format string for Pillow consumption.""" 56 | ext = ext.lower() 57 | 58 | if ext in FORMAT_HARDCODE: 59 | return FORMAT_HARDCODE[ext] 60 | 61 | return ext 62 | 63 | 64 | async def _resize(image, attach_id: int, ext: str, width: int, height: int) -> str: 65 | """Resize an image.""" 66 | # check if we have it on the folder 67 | resized_path = ATTACHMENTS / f"{attach_id}_{width}_{height}.{ext}" 68 | 69 | # keep a str-fied instance since that is what 70 | # we'll return. 71 | resized_path_s = str(resized_path) 72 | 73 | if resized_path.exists(): 74 | return resized_path_s 75 | 76 | # if we dont, we need to generate it off the 77 | # given image instance. 78 | 79 | # the process is different for gif files because we need 80 | # gifsicle. doing it manually is too troublesome. 81 | if ext == "gif": 82 | return await _resize_gif(attach_id, resized_path, width, height) 83 | 84 | # NOTE: this is the same resize mode for icons. 85 | resized = image.resize((width, height), resample=Image.LANCZOS) 86 | resized.save(resized_path_s, format=to_format(ext)) 87 | 88 | return resized_path_s 89 | 90 | 91 | @bp.route("/attachments///", methods=["GET"]) 92 | async def _get_attachment(channel_id: int, message_id: int, filename: str): 93 | 94 | attach_id = await app.db.fetchval( 95 | """ 96 | SELECT id 97 | FROM attachments 98 | WHERE channel_id = $1 99 | AND message_id = $2 100 | AND filename = $3 101 | """, 102 | channel_id, 103 | message_id, 104 | filename, 105 | ) 106 | 107 | if attach_id is None: 108 | return "", 404 109 | 110 | ext = filename.split(".")[-1] 111 | filepath = f"./attachments/{attach_id}.{ext}" 112 | 113 | try: 114 | image = Image.open(filepath) 115 | im_width, im_height = image.size 116 | except UnidentifiedImageError: 117 | return await send_file(filepath) 118 | 119 | try: 120 | width = int(request.args.get("width", 0)) or im_width 121 | except ValueError: 122 | return "", 400 123 | 124 | try: 125 | height = int(request.args.get("height", 0)) or im_height 126 | except ValueError: 127 | return "", 400 128 | 129 | # if width and height are the same (happens if they weren't provided) 130 | if width == im_width and height == im_height: 131 | return await send_file(filepath) 132 | 133 | # resize image 134 | new_filepath = await _resize(image, attach_id, ext, width, height) 135 | return await send_file(new_filepath) 136 | -------------------------------------------------------------------------------- /litecord/blueprints/channel/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | 20 | from .messages import bp as channel_messages 21 | from .reactions import bp as channel_reactions 22 | from .pins import bp as channel_pins 23 | 24 | __all__ = ["channel_messages", "channel_reactions", "channel_pins"] 25 | -------------------------------------------------------------------------------- /litecord/blueprints/channel/pins.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | 20 | from quart import Blueprint, jsonify 21 | from typing import TYPE_CHECKING 22 | 23 | from litecord.auth import token_check 24 | from litecord.blueprints.checks import channel_check, channel_perm_check 25 | from litecord.types import timestamp_ 26 | 27 | from litecord.system_messages import send_sys_message 28 | from litecord.enums import MessageType, SYS_MESSAGES 29 | from litecord.errors import BadRequest 30 | from litecord.common.interop import message_view 31 | 32 | if TYPE_CHECKING: 33 | from litecord.typing_hax import app, request 34 | else: 35 | from quart import current_app as app, request 36 | 37 | bp = Blueprint("channel_pins", __name__) 38 | 39 | 40 | async def _dispatch_pins_update(channel_id: int) -> None: 41 | message_id = await app.db.fetchval( 42 | """ 43 | SELECT message_id 44 | FROM channel_pins 45 | WHERE channel_id = $1 46 | ORDER BY message_id ASC 47 | LIMIT 1 48 | """, 49 | channel_id, 50 | ) 51 | 52 | timestamp = ( 53 | app.winter_factory.to_datetime(message_id) if message_id is not None else None 54 | ) 55 | await app.dispatcher.channel.dispatch( 56 | channel_id, 57 | ( 58 | "CHANNEL_PINS_UPDATE", 59 | { 60 | "channel_id": str(channel_id), 61 | "last_pin_timestamp": timestamp_(timestamp), 62 | }, 63 | ), 64 | ) 65 | 66 | 67 | @bp.route("//pins", methods=["GET"]) 68 | async def get_pins(channel_id): 69 | """Get the pins for a channel""" 70 | user_id = await token_check() 71 | await channel_check(user_id, channel_id) 72 | 73 | # TODO: proper ordering 74 | messages = await app.storage.get_messages( 75 | user_id=user_id, 76 | where_clause=""" 77 | WHERE channel_id = $1 AND NOT (pinned = NULL) 78 | ORDER BY message_id DESC 79 | """, 80 | args=(channel_id,), 81 | ) 82 | 83 | return jsonify([message_view(message) for message in messages]) 84 | 85 | 86 | @bp.route("//pins/", methods=["PUT"]) 87 | async def add_pin(channel_id, message_id): 88 | """Add a pin to a channel""" 89 | user_id = await token_check() 90 | _ctype, guild_id = await channel_check(user_id, channel_id) 91 | 92 | await channel_perm_check(user_id, channel_id, "manage_messages") 93 | 94 | mtype = await app.db.fetchval( 95 | """ 96 | SELECT message_type 97 | FROM messages 98 | WHERE id = $1 99 | """, 100 | message_id, 101 | ) 102 | 103 | if mtype in SYS_MESSAGES: 104 | raise BadRequest(50021) 105 | 106 | await app.db.execute( 107 | """ 108 | INSERT INTO channel_pins (channel_id, message_id) 109 | VALUES ($1, $2) 110 | """, 111 | channel_id, 112 | message_id, 113 | ) 114 | 115 | await _dispatch_pins_update(channel_id) 116 | 117 | await send_sys_message( 118 | channel_id, MessageType.CHANNEL_PINNED_MESSAGE, message_id, user_id 119 | ) 120 | 121 | return "", 204 122 | 123 | 124 | @bp.route("//pins/", methods=["DELETE"]) 125 | async def delete_pin(channel_id, message_id): 126 | user_id = await token_check() 127 | _ctype, guild_id = await channel_check(user_id, channel_id) 128 | 129 | await channel_perm_check(user_id, channel_id, "manage_messages") 130 | 131 | await app.db.execute( 132 | """ 133 | DELETE FROM channel_pins 134 | WHERE channel_id = $1 AND message_id = $2 135 | """, 136 | channel_id, 137 | message_id, 138 | ) 139 | 140 | await _dispatch_pins_update(channel_id) 141 | 142 | return "", 204 143 | -------------------------------------------------------------------------------- /litecord/blueprints/dms.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | 20 | """ 21 | blueprint for direct messages 22 | """ 23 | 24 | from quart import Blueprint, request, current_app as app, jsonify 25 | from logbook import Logger 26 | 27 | from ..schemas import validate, CREATE_DM, CREATE_DM_V9 28 | from ..enums import ChannelType 29 | 30 | 31 | from .auth import token_check 32 | 33 | from litecord.blueprints.dm_channels import gdm_create, gdm_add_recipient, gdm_pubsub 34 | from litecord.common.channels import try_dm_state 35 | from litecord.utils import index_by_func 36 | 37 | log = Logger(__name__) 38 | bp = Blueprint("dms", __name__) 39 | 40 | 41 | @bp.route("/@me/channels", methods=["GET"]) 42 | async def get_dms(): 43 | """Get the open DMs for the user.""" 44 | user_id = await token_check() 45 | return jsonify(await app.user_storage.get_dms(user_id)) 46 | 47 | 48 | async def jsonify_dm(dm_id: int, user_id: int): 49 | dm_chan = await app.storage.get_dm(dm_id, user_id) 50 | self_user_index = index_by_func( 51 | lambda user: user["id"] == str(user_id), dm_chan["recipients"] 52 | ) 53 | 54 | if request.discord_api_version > 7: 55 | assert self_user_index is not None 56 | dm_chan["recipients"].pop(self_user_index) 57 | else: 58 | if self_user_index == 0: 59 | dm_chan["recipients"].append(dm_chan["recipients"].pop(0)) 60 | 61 | return jsonify(dm_chan) 62 | 63 | 64 | async def create_dm(user_id: int, recipient_id: int): 65 | """Create a new dm with a user, 66 | or get the existing DM id if it already exists.""" 67 | 68 | dm_id = await app.db.fetchval( 69 | """ 70 | SELECT id 71 | FROM dm_channels 72 | WHERE (party1_id = $1 OR party2_id = $1) AND 73 | (party1_id = $2 OR party2_id = $2) 74 | """, 75 | user_id, 76 | recipient_id, 77 | ) 78 | 79 | if dm_id: 80 | await gdm_pubsub(dm_id, (user_id, recipient_id)) 81 | return await jsonify_dm(dm_id, user_id) 82 | 83 | # if no dm was found, create a new one 84 | 85 | dm_id = app.winter_factory.snowflake() 86 | await app.db.execute( 87 | """ 88 | INSERT INTO channels (id, channel_type) 89 | VALUES ($1, $2) 90 | """, 91 | dm_id, 92 | ChannelType.DM.value, 93 | ) 94 | 95 | await app.db.execute( 96 | """ 97 | INSERT INTO dm_channels (id, party1_id, party2_id) 98 | VALUES ($1, $2, $3) 99 | """, 100 | dm_id, 101 | user_id, 102 | recipient_id, 103 | ) 104 | 105 | # the dm state is something we use 106 | # to give the currently "open dms" 107 | # on the client. 108 | 109 | # we don't open a dm for the peer/recipient 110 | # until the user sends a message. 111 | await try_dm_state(user_id, dm_id) 112 | 113 | await gdm_pubsub(dm_id, (user_id, recipient_id)) 114 | return await jsonify_dm(dm_id, user_id) 115 | 116 | 117 | async def _handle_dm(user_id: int, data: dict): 118 | """Handle DM creation requests.""" 119 | 120 | if "recipient_ids" in data: 121 | j = validate(data, CREATE_DM) 122 | else: 123 | j = validate(data, CREATE_DM_V9) 124 | 125 | recipients = j.get("recipient_ids", j["recipients"]) 126 | if len(recipients) > 1: 127 | channel_id = await gdm_create(user_id, int(recipients[0])) 128 | for recipient in recipients[1:]: 129 | await gdm_add_recipient(channel_id, int(recipient)) 130 | return jsonify(await app.storage.get_channel(channel_id, user_id=user_id)) 131 | 132 | return await create_dm(user_id, int(recipients[0])) 133 | 134 | 135 | @bp.route("/@me/channels", methods=["POST"]) 136 | async def start_dm(): 137 | """Create a DM with a user.""" 138 | user_id = await token_check() 139 | return await _handle_dm(user_id, await request.get_json()) 140 | 141 | 142 | @bp.route("//channels", methods=["POST"]) 143 | async def create_group_dm(p_user_id: int): 144 | """Create a DM or a Group DM with user(s).""" 145 | user_id = await token_check() 146 | assert user_id == p_user_id 147 | 148 | return await _handle_dm(user_id, await request.get_json()) 149 | -------------------------------------------------------------------------------- /litecord/blueprints/gateway.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | 20 | import time 21 | 22 | from quart import Blueprint, jsonify, current_app as app 23 | 24 | from ..auth import token_check 25 | 26 | bp = Blueprint("gateway", __name__) 27 | 28 | 29 | def get_gw(): 30 | """Get the gateway's web""" 31 | proto = "wss://" if app.config["IS_SSL"] else "ws://" 32 | return f'{proto}{app.config["WEBSOCKET_URL"]}' 33 | 34 | 35 | @bp.route("/gateway") 36 | def api_gateway(): 37 | """Get the raw URL.""" 38 | return jsonify({"url": get_gw()}) 39 | 40 | 41 | @bp.route("/gateway/bot") 42 | async def api_gateway_bot(): 43 | user_id = await token_check() 44 | 45 | guild_count = await app.db.fetchval( 46 | """ 47 | SELECT COUNT(*) 48 | FROM members 49 | WHERE user_id = $1 50 | """, 51 | user_id, 52 | ) 53 | 54 | shards = max(int(guild_count / 1000), 1) 55 | 56 | # get _ws.session ratelimit 57 | ratelimit = app.ratelimiter.get_ratelimit("_ws.session") 58 | bucket = ratelimit.get_bucket(user_id) 59 | 60 | # timestamp of bucket reset 61 | reset_ts = bucket._window + bucket.second 62 | 63 | # how many seconds until bucket reset 64 | # TODO: this logic should be changed to follow update_rate_limit's 65 | # except we can't just call it since we don't use it here, but 66 | # on the gateway side. 67 | reset_after_ts = reset_ts - time.time() 68 | 69 | # reset_after_ts must not be negative 70 | if reset_after_ts < 0: 71 | reset_after_ts = 0 72 | 73 | return jsonify( 74 | { 75 | "url": get_gw(), 76 | "shards": shards, 77 | "session_start_limit": { 78 | "total": bucket.requests, 79 | "remaining": bucket._tokens, 80 | "reset_after": int(reset_after_ts * 1000), 81 | "max_concurrency": 1, 82 | }, 83 | } 84 | ) 85 | -------------------------------------------------------------------------------- /litecord/blueprints/guild/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | 20 | from .roles import bp as guild_roles 21 | from .members import bp as guild_members 22 | from .channels import bp as guild_channels 23 | from .mod import bp as guild_mod 24 | from .emoji import bp as guild_emoji 25 | 26 | __all__ = ["guild_roles", "guild_members", "guild_channels", "guild_mod", "guild_emoji"] 27 | -------------------------------------------------------------------------------- /litecord/blueprints/read_states.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | 20 | 21 | from quart import Blueprint, current_app as app, jsonify, request 22 | from litecord.auth import token_check 23 | from litecord.common.channels import channel_ack 24 | from litecord.blueprints.checks import channel_check, guild_check 25 | from litecord.schemas import validate, BULK_ACK 26 | from litecord.enums import GUILD_CHANS 27 | 28 | 29 | bp = Blueprint("read_states", __name__) 30 | 31 | 32 | @bp.route("/channels//messages//ack", methods=["POST"]) 33 | async def ack_channel(channel_id, message_id): 34 | """Acknowledge a channel.""" 35 | user_id = await token_check() 36 | ctype, guild_id = await channel_check(user_id, channel_id) 37 | 38 | if ctype not in GUILD_CHANS: 39 | guild_id = None 40 | 41 | await channel_ack(user_id, channel_id, guild_id, message_id) 42 | 43 | return jsonify( 44 | { 45 | # token seems to be used for 46 | # data collection activities, 47 | # so we never use it. 48 | "token": None 49 | } 50 | ) 51 | 52 | 53 | @bp.route("/read-states/ack-bulk", methods=["POST"]) 54 | async def bulk_ack(): 55 | """Acknowledge multiple channels in a row""" 56 | user_id = await token_check() 57 | j = validate(await request.get_json(), BULK_ACK) 58 | for ack_request in j: 59 | channel_id, message_id = ack_request["channel_id"], ack_request["message_id"] 60 | ctype, guild_id = await channel_check(user_id, channel_id) 61 | if ctype not in GUILD_CHANS: 62 | guild_id = None 63 | 64 | await channel_ack(user_id, channel_id, guild_id, message_id) 65 | 66 | # TODO: validate if this is the correct response 67 | return "", 204 68 | 69 | 70 | @bp.route("/channels//messages/ack", methods=["DELETE"]) 71 | async def delete_read_state(channel_id): 72 | """Delete the read state of a channel.""" 73 | user_id = await token_check() 74 | try: 75 | await channel_check(user_id, channel_id) 76 | except GuildNotFound: 77 | # ignore when guild isn't found because we're deleting the 78 | # read state regardless. 79 | pass 80 | 81 | await app.db.execute( 82 | """ 83 | DELETE FROM user_read_state 84 | WHERE user_id = $1 AND channel_id = $2 85 | """, 86 | user_id, 87 | channel_id, 88 | ) 89 | 90 | return "", 204 91 | 92 | 93 | @bp.route("/guilds//ack", methods=["POST"]) 94 | async def ack_guild(guild_id): 95 | """ACKnowledge all messages in the guild.""" 96 | user_id = await token_check() 97 | await guild_check(user_id, guild_id) 98 | 99 | chan_ids = await app.storage.get_channel_ids(guild_id) 100 | 101 | for chan_id in chan_ids: 102 | await channel_ack(user_id, chan_id, guild_id) 103 | 104 | return "", 204 105 | -------------------------------------------------------------------------------- /litecord/blueprints/stickers.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | 20 | from quart import Blueprint, jsonify, redirect, request, current_app as app 21 | 22 | from ..errors import NotFound 23 | 24 | bp = Blueprint("stickers", __name__) 25 | 26 | 27 | @bp.route("/sticker-packs", methods=["GET"]) 28 | @bp.route("/users/@me/sticker-packs", methods=["GET"]) 29 | async def sticker_packs(): 30 | """Send static sticker packs""" 31 | return redirect( 32 | f"https://discord.com/api/v9/sticker-packs?{request.query_string.decode()}", 33 | code=308, 34 | ) 35 | 36 | 37 | @bp.route("/sticker-packs/", methods=["GET"]) 38 | async def sticker_pack(sticker_pack): 39 | """Send static sticker pack""" 40 | # This endpoint requires auth for some reason 41 | # return redirect(f"https://discord.com/api/v9/sticker-packs/{sticker_pack}?{request.query_string.decode()}", code=308) 42 | await app.storage.get_default_sticker(None) # Ensure stickers are loaded 43 | pack = app.storage.stickers["packs"].get(sticker_pack) 44 | if not pack: 45 | raise NotFound(10061) 46 | return jsonify(pack) 47 | 48 | 49 | @bp.route("/gifs/select", methods=["POST"]) 50 | async def stub_select(): 51 | """Stub for select telemetry""" 52 | return "", 204 53 | 54 | 55 | @bp.route("/gifs/", methods=["GET", "POST"]) 56 | async def gifs(path): 57 | """Send gifs and stuff""" 58 | return redirect( 59 | f"https://discord.com/api/v9/gifs/{path}?{request.query_string.decode()}", 60 | code=308, 61 | ) 62 | 63 | 64 | @bp.route("/integrations//search", methods=["GET"]) 65 | async def search_gifs(provider): 66 | """Send gifs and stuff""" 67 | return redirect( 68 | f"https://discord.com/api/v9/gifs/search?provider={provider}&media_format={request.args.get('media_format', 'mp4')}&{request.query_string.decode()}", 69 | code=308, 70 | ) 71 | -------------------------------------------------------------------------------- /litecord/blueprints/user/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | 20 | from .billing import bp as user_billing 21 | from .settings import bp as user_settings 22 | from .fake_store import bp as fake_store 23 | 24 | __all__ = ["user_billing", "user_settings", "fake_store"] 25 | -------------------------------------------------------------------------------- /litecord/blueprints/user/billing_job.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | 20 | """ 21 | this file only serves the periodic payment job code. 22 | """ 23 | import datetime 24 | 25 | from asyncio import sleep, CancelledError 26 | from logbook import Logger 27 | from typing import TYPE_CHECKING 28 | 29 | from litecord.blueprints.user.billing import ( 30 | get_subscription, 31 | get_payment_ids, 32 | get_payment, 33 | create_payment, 34 | process_subscription, 35 | ) 36 | 37 | from litecord.types import MINUTES 38 | 39 | if TYPE_CHECKING: 40 | from litecord.typing_hax import app, request 41 | else: 42 | from quart import current_app as app, request 43 | 44 | log = Logger(__name__) 45 | 46 | # how many days until a payment needs 47 | # to be issued 48 | THRESHOLDS = { 49 | "premium_month_tier_1": 30, 50 | "premium_month_tier_2": 30, 51 | "premium_year_tier_1": 365, 52 | "premium_year_tier_2": 365, 53 | } 54 | 55 | 56 | async def _resched(): 57 | log.debug("waiting 30 minutes for job.") 58 | await sleep(30 * MINUTES) 59 | app.sched.spawn(payment_job()) 60 | 61 | 62 | async def _process_user_payments(user_id: int): 63 | payments = await get_payment_ids(user_id) 64 | 65 | if not payments: 66 | log.debug("no payments for uid {}, skipping", user_id) 67 | return 68 | 69 | log.debug("{} payments for uid {}", len(payments), user_id) 70 | 71 | latest_payment = max(payments) 72 | 73 | payment_data = await get_payment(latest_payment) 74 | 75 | # calculate the difference between this payment 76 | # and now. 77 | now = datetime.datetime.now() 78 | payment_tstamp = app.winter_factory.to_datetime(int(payment_data["id"])) 79 | 80 | delta = now - payment_tstamp 81 | 82 | sub_id = int(payment_data["subscription"]["id"]) 83 | subscription = await get_subscription(sub_id) 84 | 85 | # if the max payment is X days old, we create another. 86 | # X is 30 for monthly subscriptions of nitro, 87 | # X is 365 for yearly subscriptions of nitro 88 | threshold = THRESHOLDS[subscription["payment_gateway_plan_id"]] 89 | 90 | log.debug("delta {} delta days {} threshold {}", delta, delta.days, threshold) 91 | 92 | if delta.days > threshold: 93 | log.info("creating payment for sid={}", sub_id) 94 | 95 | # create_payment does not call any Stripe 96 | # or BrainTree APIs at all, since we'll just 97 | # give it as free. 98 | await create_payment(sub_id) 99 | else: 100 | log.debug("sid={}, missing {} days", sub_id, threshold - delta.days) 101 | 102 | 103 | async def payment_job(): 104 | """Main payment job function. 105 | 106 | This function will check through users' payments 107 | and add a new one once a month / year. 108 | """ 109 | log.debug("payment job start!") 110 | 111 | user_ids = await app.db.fetch( 112 | """ 113 | SELECT DISTINCT user_id 114 | FROM user_payments 115 | """ 116 | ) 117 | 118 | log.debug("working {} users", len(user_ids)) 119 | 120 | # go through each user's payments 121 | for row in user_ids: 122 | user_id = row["user_id"] 123 | try: 124 | await _process_user_payments(user_id) 125 | except Exception: 126 | log.exception("error while processing user payments") 127 | 128 | subscribers = await app.db.fetch( 129 | """ 130 | SELECT id 131 | FROM user_subscriptions 132 | """ 133 | ) 134 | 135 | for row in subscribers: 136 | try: 137 | await process_subscription(row["id"]) 138 | except Exception: 139 | log.exception("error while processing subscription") 140 | log.debug("rescheduling..") 141 | try: 142 | await _resched() 143 | except CancelledError: 144 | log.info("cancelled while waiting for resched") 145 | -------------------------------------------------------------------------------- /litecord/blueprints/user/fake_store.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | 20 | """ 21 | fake routes for discord store 22 | """ 23 | from quart import Blueprint, jsonify 24 | 25 | bp = Blueprint("fake_store", __name__) 26 | 27 | 28 | @bp.route("/promotions") 29 | async def _get_promotions(): 30 | return jsonify([]) 31 | 32 | 33 | @bp.route("/users/@me/library") 34 | async def _get_library(): 35 | return jsonify([]) 36 | 37 | 38 | @bp.route("/users/@me/feed/settings") 39 | async def _get_feed_settings(): 40 | return jsonify( 41 | { 42 | "subscribed_games": [], 43 | "subscribed_users": [], 44 | "unsubscribed_users": [], 45 | "unsubscribed_games": [], 46 | } 47 | ) 48 | -------------------------------------------------------------------------------- /litecord/blueprints/voice.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | 20 | from typing import Optional 21 | from collections import Counter 22 | from random import choice 23 | 24 | from quart import Blueprint, jsonify, current_app as app 25 | 26 | from litecord.blueprints.auth import token_check 27 | 28 | bp = Blueprint("voice", __name__) 29 | 30 | 31 | def _majority_region_count(regions: list) -> str: 32 | """Return the first most common element in a given list.""" 33 | counter = Counter(regions) 34 | common = counter.most_common(1) 35 | region, _count = common[0] 36 | 37 | return region 38 | 39 | 40 | async def _choose_random_region() -> Optional[str]: 41 | """Give a random voice region.""" 42 | regions = await app.db.fetch( 43 | """ 44 | SELECT id 45 | FROM voice_regions 46 | """ 47 | ) 48 | 49 | regions = [r["id"] for r in regions] 50 | 51 | if not regions: 52 | return None 53 | 54 | return choice(regions) 55 | 56 | 57 | async def _majority_region_any(user_id) -> Optional[str]: 58 | """Calculate the most likely region to make the user happy, but 59 | this is based on the guilds the user is IN, instead of the guilds 60 | the user owns.""" 61 | guilds = await app.user_storage.get_user_guilds(user_id) 62 | 63 | if not guilds: 64 | return await _choose_random_region() 65 | 66 | res = [] 67 | 68 | for guild_id in guilds: 69 | region = await app.db.fetchval( 70 | """ 71 | SELECT region 72 | FROM guilds 73 | WHERE id = $1 74 | """, 75 | guild_id, 76 | ) 77 | 78 | res.append(region) 79 | 80 | most_common = _majority_region_count(res) 81 | 82 | if most_common is None: 83 | return await _choose_random_region() 84 | 85 | return most_common 86 | 87 | 88 | async def majority_region(user_id: int) -> Optional[str]: 89 | """Given a user ID, give the most likely region for the user to be 90 | happy with.""" 91 | regions = await app.db.fetch( 92 | """ 93 | SELECT region 94 | FROM guilds 95 | WHERE owner_id = $1 96 | """, 97 | user_id, 98 | ) 99 | 100 | if not regions: 101 | return await _majority_region_any(user_id) 102 | 103 | regions = [r["region"] for r in regions] 104 | return _majority_region_count(regions) 105 | 106 | 107 | async def _all_regions(): 108 | user_id = await token_check() 109 | 110 | best_region = await majority_region(user_id) 111 | regions = await app.storage.all_voice_regions() 112 | 113 | for region in regions: 114 | region["optimal"] = region["id"] == best_region 115 | 116 | return jsonify(regions) 117 | 118 | 119 | @bp.route("/regions", methods=["GET"]) 120 | async def voice_regions(): 121 | """Return voice regions.""" 122 | return await _all_regions() 123 | 124 | 125 | @bp.route("/guilds//regions", methods=["GET"]) 126 | async def guild_voice_regions(): 127 | """Return voice regions.""" 128 | # we return the same list as the normal /regions route on purpose. 129 | return await _all_regions() 130 | -------------------------------------------------------------------------------- /litecord/common/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hood-network/patchcord/d0d084c5dfa1c3b5f4af659836fc4ff2c42bad5d/litecord/common/__init__.py -------------------------------------------------------------------------------- /litecord/common/interop.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | from typing import TYPE_CHECKING 20 | 21 | if TYPE_CHECKING: 22 | from litecord.typing_hax import app, request 23 | else: 24 | from quart import current_app as app, request 25 | 26 | def guild_view(guild_data: dict) -> dict: 27 | # Do all the below if applicable 28 | if request.discord_api_version < 8: 29 | if guild_data.get("roles"): 30 | guild_data["roles"] = list(map(role_view, guild_data["roles"])) 31 | if guild_data.get("channels"): 32 | guild_data["channels"] = list(map(channel_view, guild_data["channels"])) 33 | return guild_data 34 | 35 | 36 | def message_view(message_data: dict) -> dict: 37 | # Change message type to 0 for unsupported types 38 | if request.discord_api_version < 8 and message_data["type"] in (19, 20, 23): 39 | message_data["type"] = 0 40 | message_data.pop("member", None) 41 | message_data.pop("guild_id", None) 42 | return message_data 43 | 44 | 45 | def channel_view(channel_data: dict) -> dict: 46 | # Seperate permissions into permissions and permissions_new 47 | if request.discord_api_version < 8 and channel_data.get("permission_overwrites"): 48 | for overwrite in channel_data["permission_overwrites"]: 49 | overwrite["type"] = "role" if overwrite["type"] == 0 else "member" 50 | overwrite["allow_new"] = overwrite.get("allow", "0") 51 | overwrite["allow"] = ( 52 | (int(overwrite["allow"]) & ((2 << 31) - 1)) 53 | if overwrite.get("allow") 54 | else 0 55 | ) 56 | overwrite["deny_new"] = overwrite.get("deny", "0") 57 | overwrite["deny"] = ( 58 | (int(overwrite["deny"]) & ((2 << 31) - 1)) 59 | if overwrite.get("deny") 60 | else 0 61 | ) 62 | return channel_data 63 | 64 | 65 | def role_view(role_data: dict) -> dict: 66 | # Seperate permissions into permissions and permissions_new 67 | if request.discord_api_version < 8: 68 | role_data["permissions_new"] = role_data["permissions"] 69 | role_data["permissions"] = int(role_data["permissions"]) & ((2 << 31) - 1) 70 | return role_data 71 | -------------------------------------------------------------------------------- /litecord/dispatcher.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | 20 | from logbook import Logger 21 | 22 | from .pubsub import ( 23 | GuildDispatcher, 24 | ChannelDispatcher, 25 | FriendDispatcher, 26 | ) 27 | 28 | log = Logger(__name__) 29 | 30 | 31 | class EventDispatcher: 32 | """Pub/Sub routines for litecord. 33 | 34 | EventDispatcher is the middle man between 35 | REST code and gateway event logic. 36 | 37 | It sets up Pub/Sub backends and each of them 38 | have their own ways of dispatching a single event. 39 | 40 | "key" and "identifier" are the "channel" and "subscriber id" 41 | of pub/sub. clients can subscribe to a channel using its backend 42 | and the key inside the backend. 43 | 44 | when dispatching, the backend can do its own logic, given 45 | its subscriber ids. 46 | """ 47 | 48 | def __init__(self): 49 | self.guild: GuildDispatcher = GuildDispatcher() 50 | self.channel = ChannelDispatcher() 51 | self.friend = FriendDispatcher() 52 | -------------------------------------------------------------------------------- /litecord/embed/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | 20 | from .sanitizer import sanitize_embed 21 | 22 | __all__ = ["sanitize_embed"] 23 | -------------------------------------------------------------------------------- /litecord/embed/schemas.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | 20 | """ 21 | litecord.embed.schemas - embed input validators. 22 | """ 23 | import urllib.parse 24 | from litecord.types import Color 25 | 26 | 27 | class EmbedURL: 28 | def __init__(self, url: str): 29 | parsed = urllib.parse.urlparse(url) 30 | 31 | if parsed.scheme not in ("http", "https", "attachment"): 32 | raise ValueError("Invalid URL scheme") 33 | 34 | self.scheme = parsed.scheme 35 | self.raw_url = url 36 | self.parsed = parsed 37 | 38 | @classmethod 39 | def from_parsed(cls, parsed): 40 | """Make an EmbedURL instance out of an already parsed 6-tuple.""" 41 | return cls(parsed.geturl()) 42 | 43 | @property 44 | def url(self) -> str: 45 | """Return the unparsed URL.""" 46 | return urllib.parse.urlunparse(self.parsed) 47 | 48 | @property 49 | def to_json(self) -> str: 50 | """'json' version of the url.""" 51 | return self.url 52 | 53 | @property 54 | def to_md_path(self) -> str: 55 | """Convert the EmbedURL to a mediaproxy path (post img/meta).""" 56 | parsed = self.parsed 57 | return f"{parsed.scheme}/{parsed.netloc}" f"{parsed.path}?{parsed.query}" 58 | 59 | 60 | EMBED_FOOTER = { 61 | "text": {"type": "string", "minlength": 1, "maxlength": 1024, "required": True}, 62 | "icon_url": {"coerce": EmbedURL, "required": False}, 63 | # NOTE: proxy_icon_url set by us 64 | } 65 | 66 | EMBED_IMAGE = { 67 | "url": {"coerce": EmbedURL, "required": True}, 68 | # NOTE: proxy_url, width, height set by us 69 | } 70 | 71 | EMBED_THUMBNAIL = EMBED_IMAGE 72 | 73 | EMBED_AUTHOR = { 74 | "name": {"type": "string", "minlength": 1, "maxlength": 256, "required": False}, 75 | "url": {"coerce": EmbedURL, "required": False}, 76 | "icon_url": {"coerce": EmbedURL, "required": False} 77 | # NOTE: proxy_icon_url set by us 78 | } 79 | 80 | EMBED_FIELD = { 81 | "name": {"type": "string", "minlength": 1, "maxlength": 256, "required": True}, 82 | "value": {"type": "string", "minlength": 1, "maxlength": 1024, "required": True}, 83 | "inline": {"type": "boolean", "required": False, "default": True}, 84 | } 85 | 86 | EMBED_OBJECT = { 87 | "title": {"type": "string", "minlength": 1, "maxlength": 256, "required": False}, 88 | # NOTE: type set by us 89 | "description": { 90 | "type": "string", 91 | "minlength": 1, 92 | "maxlength": 4096, 93 | "required": False, 94 | }, 95 | "url": {"coerce": EmbedURL, "required": False}, 96 | "timestamp": { 97 | # TODO: an ISO 8601 type 98 | # TODO: maybe replace the default in here with now().isoformat? 99 | "type": "string", 100 | "required": False, 101 | }, 102 | "color": {"coerce": Color, "required": False}, 103 | "footer": {"type": "dict", "schema": EMBED_FOOTER, "required": False}, 104 | "image": {"type": "dict", "schema": EMBED_IMAGE, "required": False}, 105 | "thumbnail": {"type": "dict", "schema": EMBED_THUMBNAIL, "required": False}, 106 | # NOTE: 'video' set by us 107 | # NOTE: 'provider' set by us 108 | "author": {"type": "dict", "schema": EMBED_AUTHOR, "required": False}, 109 | "fields": { 110 | "type": "list", 111 | "schema": {"type": "dict", "schema": EMBED_FIELD}, 112 | "required": False, 113 | }, 114 | } 115 | -------------------------------------------------------------------------------- /litecord/gateway/encoding.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | 20 | import json 21 | import earl 22 | 23 | from litecord.json import LitecordJSONEncoder 24 | 25 | 26 | def encode_json(payload) -> str: 27 | """Encode a given payload to JSON.""" 28 | return json.dumps(payload, separators=(",", ":"), cls=LitecordJSONEncoder) 29 | 30 | 31 | def decode_json(data: str): 32 | """Decode from JSON.""" 33 | return json.loads(data) 34 | 35 | 36 | def encode_etf(payload) -> str: 37 | """Encode a payload to ETF (External Term Format). 38 | 39 | This gives a JSON pass on the given payload (via calling encode_json and 40 | then decode_json) because we may want to encode objects that can only be 41 | encoded by LitecordJSONEncoder. 42 | 43 | Earl-ETF does not give the same interface for extensibility, hence why we 44 | do the pass. 45 | """ 46 | sanitized = encode_json(payload) 47 | sanitized = decode_json(sanitized) 48 | return earl.pack(sanitized) 49 | 50 | 51 | def _etf_decode_dict(data): 52 | """Decode a given dictionary.""" 53 | # NOTE: this is very slow. 54 | 55 | if isinstance(data, bytes): 56 | return data.decode() 57 | 58 | if not isinstance(data, dict): 59 | return data 60 | 61 | _copy = dict(data) 62 | result = {} 63 | 64 | for key in _copy.keys(): 65 | # assuming key is bytes rn. 66 | new_k = key.decode() 67 | 68 | # maybe nested dicts, so... 69 | result[new_k] = _etf_decode_dict(data[key]) 70 | 71 | return result 72 | 73 | 74 | def decode_etf(data: bytes): 75 | """Decode data in ETF to any.""" 76 | res = earl.unpack(data) 77 | 78 | if isinstance(res, bytes): 79 | return data.decode() 80 | 81 | if isinstance(res, dict): 82 | return _etf_decode_dict(res) 83 | 84 | return res 85 | -------------------------------------------------------------------------------- /litecord/gateway/errors.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | 20 | from litecord.errors import WebsocketClose 21 | 22 | 23 | class GatewayError(WebsocketClose): 24 | def __init__(self, *args, **kwargs): 25 | super().__init__(4000, *args, **kwargs) 26 | 27 | 28 | class UnknownOPCode(WebsocketClose): 29 | def __init__(self, *args, **kwargs): 30 | super().__init__(4001, *args, **kwargs) 31 | 32 | 33 | class DecodeError(WebsocketClose): 34 | def __init__(self, *args, **kwargs): 35 | super().__init__(4002, *args, **kwargs) 36 | 37 | 38 | class InvalidShard(WebsocketClose): 39 | def __init__(self, *args, **kwargs): 40 | super().__init__(4010, *args, **kwargs) 41 | 42 | 43 | class ShardingRequired(WebsocketClose): 44 | def __init__(self, *args, **kwargs): 45 | super().__init__(4011, *args, **kwargs) 46 | -------------------------------------------------------------------------------- /litecord/gateway/gateway.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | 20 | import urllib.parse 21 | from typing import Optional 22 | from litecord.gateway.websocket import GatewayWebsocket 23 | 24 | 25 | async def websocket_handler(app, ws, url): 26 | """Main websocket handler, checks query arguments when connecting to 27 | the gateway and spawns a GatewayWebsocket instance for the connection.""" 28 | args = urllib.parse.parse_qs(urllib.parse.urlparse(url).query) 29 | 30 | # pull a dict.get but in a really bad way. 31 | try: 32 | gw_version = args["v"][0] 33 | except (KeyError, IndexError): 34 | gw_version = "5" 35 | 36 | try: 37 | gw_encoding = args["encoding"][0] 38 | except (KeyError, IndexError): 39 | gw_encoding = "json" 40 | 41 | if gw_version not in ("5", "6", "7", "8", "9", "10"): 42 | return await ws.close(4012, "Invalid gateway version.") 43 | 44 | if gw_encoding not in ("json", "etf"): 45 | gw_encoding = "json" 46 | 47 | try: 48 | gw_compress: Optional[str] = args["compress"][0] 49 | except (KeyError, IndexError): 50 | gw_compress = None 51 | 52 | if gw_compress and gw_compress not in ("zlib-stream", "zstd-stream"): 53 | gw_compress = None 54 | 55 | async with app.app_context(): 56 | gws = GatewayWebsocket( 57 | ws, 58 | version=int(gw_version), 59 | encoding=gw_encoding, 60 | compress=gw_compress, 61 | ) 62 | 63 | # this can be run with a single await since this whole coroutine 64 | # is already running in the background. 65 | await gws.run() 66 | -------------------------------------------------------------------------------- /litecord/gateway/opcodes.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | 20 | 21 | class OP: 22 | """Gateway OP codes.""" 23 | 24 | DISPATCH = 0 25 | HEARTBEAT = 1 26 | IDENTIFY = 2 27 | STATUS_UPDATE = 3 28 | 29 | # voice connection / disconnection 30 | VOICE_UPDATE = 4 31 | VOICE_PING = 5 32 | 33 | RESUME = 6 34 | RECONNECT = 7 35 | REQ_GUILD_MEMBERS = 8 36 | INVALID_SESSION = 9 37 | 38 | HELLO = 10 39 | HEARTBEAT_ACK = 11 40 | 41 | # request member / presence information 42 | GUILD_SYNC = 12 43 | 44 | # request to sync up call dm / group dm 45 | CALL_SYNC = 13 46 | 47 | # request for lazy guilds 48 | LAZY_REQUEST = 14 49 | 50 | # unimplemented 51 | LOBBY_CONNECT = 15 52 | LOBBY_DISCONNECT = 16 53 | LOBBY_VOICE_STATES_UPDATE = 17 54 | STREAM_CREATE = 18 55 | STREAM_DELETE = 19 56 | STREAM_WATCH = 20 57 | STREAM_PING = 21 58 | STREAM_SET_PAUSED = 22 59 | 60 | # related to Slash Commands 61 | QUERY_APPLICATION_COMMANDS = 24 62 | -------------------------------------------------------------------------------- /litecord/gateway/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | 20 | import asyncio 21 | 22 | 23 | class WebsocketFileHandler: 24 | """A handler around a websocket that wraps normal I/O calls into 25 | the websocket's respective asyncio calls via asyncio.ensure_future.""" 26 | 27 | def __init__(self, ws): 28 | self.ws = ws 29 | 30 | def write(self, data): 31 | """Write data into the websocket""" 32 | asyncio.ensure_future(self.ws.send(data)) 33 | -------------------------------------------------------------------------------- /litecord/guild_memory_store.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | 20 | 21 | class GuildMemoryStore: 22 | """Store in-memory properties about guilds. 23 | 24 | I could have just used Redis... probably too overkill to add 25 | aioredis to the already long depedency list, plus, I don't need 26 | """ 27 | 28 | def __init__(self): 29 | self._store = {} 30 | 31 | def get(self, guild_id: int, attribute: str, default=None): 32 | """get a key""" 33 | return self._store.get(f"{guild_id}:{attribute}", default) 34 | 35 | def set(self, guild_id: int, attribute: str, value): 36 | """set a key""" 37 | self._store[f"{guild_id}:{attribute}"] = value 38 | -------------------------------------------------------------------------------- /litecord/jobs.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | 20 | import asyncio 21 | from typing import Any 22 | 23 | from logbook import Logger 24 | 25 | log = Logger(__name__) 26 | 27 | 28 | class EmptyContext: 29 | async def __aenter__(self): 30 | pass 31 | 32 | async def __aexit__(self, _typ, _value, _traceback): 33 | pass 34 | 35 | 36 | class JobManager: 37 | """Background job manager. 38 | 39 | Handles closing all existing jobs when going on a shutdown. This does not 40 | use helpers such as asyncio.gather and asyncio.Task.all_tasks. It only uses 41 | its own internal list of jobs. 42 | """ 43 | 44 | def __init__(self, *, loop=None, context_func=None): 45 | self.loop = loop or asyncio.get_event_loop() 46 | self.context_function = context_func or EmptyContext 47 | self.jobs = [] 48 | 49 | async def _wrapper(self, coro): 50 | """Wrapper coroutine for other coroutines. This adds a simple 51 | try/except for general exceptions to be logged. 52 | """ 53 | try: 54 | await coro 55 | except Exception: 56 | log.exception("Error while running job") 57 | 58 | def spawn(self, coro): 59 | """Spawn a given future or coroutine in the background.""" 60 | 61 | async def _ctx_wrapper_bg() -> Any: 62 | async with self.context_function(): 63 | return await coro 64 | 65 | task = self.loop.create_task(self._wrapper(_ctx_wrapper_bg())) 66 | self.jobs.append(task) 67 | return task 68 | 69 | def close(self): 70 | """Close the job manager, cancelling all existing jobs. 71 | 72 | It is the job's responsibility to handle the given CancelledError 73 | and release any acquired resources. 74 | """ 75 | for job in self.jobs: 76 | job.cancel() 77 | -------------------------------------------------------------------------------- /litecord/json.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | 20 | import json 21 | from typing import Any 22 | from decimal import Decimal 23 | from uuid import UUID 24 | from dataclasses import asdict, is_dataclass 25 | 26 | import quart.json.provider 27 | 28 | 29 | class LitecordJSONEncoder(json.JSONEncoder): 30 | """Custom JSON encoder for Litecord. Useful for json.dumps""" 31 | 32 | def default(self, value: Any): 33 | if isinstance(value, (Decimal, UUID)): 34 | return str(value) 35 | 36 | if is_dataclass(value): 37 | return asdict(value) 38 | 39 | if hasattr(value, "to_json"): 40 | return value.to_json 41 | 42 | return super().default(value) 43 | 44 | 45 | class LitecordJSONProvider(quart.json.provider.DefaultJSONProvider): 46 | """Custom JSON provider for Quart.""" 47 | 48 | def __init__(self, *args, **kwargs): 49 | self.encoder = LitecordJSONEncoder(**kwargs) 50 | 51 | def default(self, value: Any): 52 | self.encoder.default(value) 53 | 54 | 55 | async def pg_set_json(con): 56 | """Set JSON and JSONB codecs for an asyncpg connection.""" 57 | await con.set_type_codec( 58 | "json", 59 | encoder=lambda v: json.dumps(v, cls=LitecordJSONEncoder), 60 | decoder=json.loads, 61 | schema="pg_catalog", 62 | ) 63 | 64 | await con.set_type_codec( 65 | "jsonb", 66 | encoder=lambda v: json.dumps(v, cls=LitecordJSONEncoder), 67 | decoder=json.loads, 68 | schema="pg_catalog", 69 | ) 70 | -------------------------------------------------------------------------------- /litecord/pubsub/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | 20 | from .guild import GuildDispatcher 21 | from .member import dispatch_member 22 | from .user import dispatch_user 23 | from .channel import ChannelDispatcher 24 | from .friend import FriendDispatcher 25 | 26 | __all__ = [ 27 | "GuildDispatcher", 28 | "dispatch_member", 29 | "dispatch_user", 30 | "ChannelDispatcher", 31 | "FriendDispatcher", 32 | ] 33 | -------------------------------------------------------------------------------- /litecord/pubsub/channel.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | 20 | from typing import List, TYPE_CHECKING 21 | 22 | import asyncio 23 | from logbook import Logger 24 | 25 | from litecord.enums import EVENTS_TO_INTENTS 26 | from .dispatcher import DispatcherWithState, GatewayEvent 27 | 28 | if TYPE_CHECKING: 29 | from litecord.typing_hax import app 30 | else: 31 | from quart import current_app as app 32 | 33 | log = Logger(__name__) 34 | 35 | 36 | def can_dispatch(event_type, event_data, state) -> bool: 37 | # If the return value is a tuple, it depends on `guild_id` being present 38 | wanted_intent = EVENTS_TO_INTENTS.get(event_type) 39 | if isinstance(wanted_intent, tuple): 40 | wanted_intent = wanted_intent[bool(event_data.get("guild_id"))] 41 | 42 | if wanted_intent is not None: 43 | return (state.intents & wanted_intent) == wanted_intent 44 | return True 45 | 46 | 47 | class ChannelDispatcher(DispatcherWithState[int, str, GatewayEvent, List[str]]): 48 | """Main channel Pub/Sub logic. Handles both Guild, DM, and Group DM channels.""" 49 | 50 | async def dispatch(self, channel_id: int, event: GatewayEvent) -> List[str]: 51 | """Dispatch an event to a channel.""" 52 | session_ids = set(self.state[channel_id]) 53 | sessions: List[str] = [] 54 | 55 | event_type, event_data = event 56 | 57 | async def _dispatch(session_id: str) -> None: 58 | try: 59 | state = app.state_manager.fetch_raw(session_id) 60 | except KeyError: 61 | await self.unsub(channel_id, session_id) 62 | return 63 | 64 | if not can_dispatch(event_type, event_data, state): 65 | return 66 | 67 | await state.dispatch(*event) 68 | sessions.append(session_id) 69 | 70 | await asyncio.gather(*(_dispatch(sid) for sid in session_ids)) 71 | 72 | log.info( 73 | "Dispatched chan={} {!r} to {} states", channel_id, event[0], len(sessions) 74 | ) 75 | 76 | return sessions 77 | -------------------------------------------------------------------------------- /litecord/pubsub/dispatcher.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | 20 | from typing import ( 21 | List, 22 | Generic, 23 | TypeVar, 24 | Any, 25 | Callable, 26 | Dict, 27 | Set, 28 | Mapping, 29 | Iterable, 30 | Tuple, 31 | ) 32 | from collections import defaultdict 33 | import asyncio 34 | 35 | from logbook import Logger 36 | 37 | log = Logger(__name__) 38 | 39 | 40 | K = TypeVar("K") 41 | V = TypeVar("V") 42 | F = TypeVar("F") 43 | EventType = TypeVar("EventType") 44 | DispatchType = TypeVar("DispatchType") 45 | F_Map = Mapping[V, F] 46 | 47 | GatewayEvent = Tuple[str, Any] 48 | 49 | __all__ = ["Dispatcher", "DispatcherWithState", "GatewayEvent"] 50 | 51 | 52 | class Dispatcher(Generic[K, V, EventType, DispatchType]): 53 | """Pub/Sub backend dispatcher. 54 | 55 | Classes must implement this protocol. 56 | """ 57 | 58 | async def sub(self, key: K, identifier: V) -> None: 59 | """Subscribe a given identifier to a given key.""" 60 | ... 61 | 62 | async def sub_many(self, key: K, identifier_list: Iterable[V]) -> None: 63 | for identifier in identifier_list: 64 | await self.sub(key, identifier) 65 | 66 | async def unsub(self, key: K, identifier: V) -> None: 67 | """Unsubscribe a given identifier to a given key.""" 68 | ... 69 | 70 | async def dispatch(self, key: K, event: EventType) -> DispatchType: 71 | ... 72 | 73 | async def dispatch_many(self, keys: List[K], *args: Any, **kwargs: Any) -> None: 74 | log.info("MULTI DISPATCH in {!r}, {} keys", self, len(keys)) 75 | await asyncio.gather(*(self.dispatch(key, *args, **kwargs) for key in keys)) 76 | 77 | async def drop(self, key: K) -> None: 78 | """Drop a key.""" 79 | ... 80 | 81 | async def clear(self, key: K) -> None: 82 | """Clear a key from the backend.""" 83 | ... 84 | 85 | async def dispatch_filter( 86 | self, key: K, filter_function: Callable[[K], bool], event: EventType 87 | ) -> List[str]: 88 | """Selectively dispatch to the list of subscribers. 89 | 90 | Function must return a list of separate identifiers for composability. 91 | """ 92 | ... 93 | 94 | 95 | class DispatcherWithState(Dispatcher[K, V, EventType, DispatchType]): 96 | """Pub/Sub backend with a state dictionary. 97 | 98 | This class was made to decrease the amount 99 | of boilerplate code on Pub/Sub backends 100 | that have that dictionary. 101 | """ 102 | 103 | def __init__(self): 104 | super().__init__() 105 | 106 | #: the default dict is to a set 107 | # so we make sure someone calling sub() 108 | # twice won't get 2x the events for the 109 | # same channel. 110 | self.state: Dict[K, Set[V]] = defaultdict(set) 111 | 112 | async def sub(self, key: K, identifier: V): 113 | self.state[key].add(identifier) 114 | 115 | async def unsub(self, key: K, identifier: V): 116 | self.state[key].discard(identifier) 117 | 118 | async def reset(self, key: K): 119 | self.state[key] = set() 120 | 121 | async def drop(self, key: K): 122 | self.state.pop(key, None) 123 | -------------------------------------------------------------------------------- /litecord/pubsub/friend.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | 20 | import asyncio 21 | from typing import List, Set 22 | from logbook import Logger 23 | 24 | from .dispatcher import DispatcherWithState, GatewayEvent 25 | from .user import dispatch_user_filter 26 | 27 | log = Logger(__name__) 28 | 29 | 30 | class FriendDispatcher(DispatcherWithState[int, int, GatewayEvent, List[str]]): 31 | """Friend Pub/Sub logic. 32 | 33 | When connecting, a client will subscribe to all their friends 34 | channels. If that friend updates their presence, it will be 35 | broadcasted through that channel to basically all their friends. 36 | """ 37 | 38 | async def dispatch_filter(self, user_id: int, filter_function, event: GatewayEvent): 39 | """Dispatch an event to all of a users' friends.""" 40 | peer_ids: Set[int] = self.state[user_id] 41 | sessions: List[str] = [] 42 | 43 | async def dispatch(peer_id: int) -> None: 44 | # dispatch to the user instead of the "shards tied to a guild" 45 | # since relationships broadcast to all shards. 46 | sessions.extend(await dispatch_user_filter(peer_id, filter_function, event)) 47 | 48 | asyncio.gather(*[dispatch(peer_id) for peer_id in peer_ids]) 49 | 50 | log.info("dispatched uid={} {!r} to {} states", user_id, event, len(sessions)) 51 | return sessions 52 | 53 | async def dispatch(self, user_id: int, event: GatewayEvent): 54 | return await self.dispatch_filter(user_id, None, event) 55 | -------------------------------------------------------------------------------- /litecord/pubsub/member.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | 20 | from typing import List, TYPE_CHECKING 21 | from .dispatcher import GatewayEvent 22 | from .utils import send_event_to_states 23 | 24 | if TYPE_CHECKING: 25 | from litecord.typing_hax import app, request 26 | else: 27 | from quart import current_app as app, request 28 | 29 | async def dispatch_member( 30 | guild_id: int, user_id: int, event: GatewayEvent 31 | ) -> List[str]: 32 | states = app.state_manager.fetch_states(user_id, guild_id) 33 | 34 | # if no states were found, we should unsub the user from the guild 35 | if not states: 36 | await app.dispatcher.guild.unsub(guild_id, user_id) 37 | return [] 38 | 39 | return await send_event_to_states(states, event) 40 | -------------------------------------------------------------------------------- /litecord/pubsub/user.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | 20 | from typing import Callable, List, Optional, TYPE_CHECKING 21 | 22 | from .dispatcher import GatewayEvent 23 | from .utils import send_event_to_states 24 | 25 | if TYPE_CHECKING: 26 | from litecord.typing_hax import app 27 | else: 28 | from quart import current_app as app 29 | 30 | async def dispatch_user_filter( 31 | user_id: int, filter_func: Optional[Callable[[str], bool]], event_data: GatewayEvent 32 | ) -> List[str]: 33 | """Dispatch to a given user's states, but only for states 34 | where filter_func returns true.""" 35 | states = list( 36 | filter( 37 | lambda state: filter_func(state.session_id) if filter_func else True, 38 | app.state_manager.user_states(user_id), 39 | ) 40 | ) 41 | 42 | return await send_event_to_states(states, event_data) 43 | 44 | 45 | async def dispatch_user(user_id: int, event_data: GatewayEvent) -> List[str]: 46 | return await dispatch_user_filter(user_id, None, event_data) 47 | -------------------------------------------------------------------------------- /litecord/pubsub/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | 20 | import logging 21 | from typing import List, Tuple, Any 22 | from ..gateway.state import GatewayState 23 | 24 | log = logging.getLogger(__name__) 25 | 26 | 27 | async def send_event_to_states( 28 | states: List[GatewayState], event_data: Tuple[str, Any] 29 | ) -> List[str]: 30 | """Dispatch an event to a list of states.""" 31 | res = [] 32 | 33 | event, data = event_data 34 | for state in states: 35 | try: 36 | await state.dispatch(event, data) 37 | res.append(state.session_id) 38 | except Exception: 39 | log.exception("error while dispatching") 40 | 41 | return res 42 | -------------------------------------------------------------------------------- /litecord/ratelimits/bucket.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | 20 | """ 21 | main litecord ratelimiting code 22 | 23 | This code was copied from elixire's ratelimiting, 24 | which in turn is a work on top of discord.py's ratelimiting. 25 | """ 26 | import time 27 | 28 | 29 | class RatelimitBucket: 30 | """Main ratelimit bucket class.""" 31 | 32 | def __init__(self, tokens, second): 33 | self.requests = tokens 34 | self.second = second 35 | 36 | self._window = 0.0 37 | self._tokens = self.requests 38 | self.retries = 0 39 | self._last = 0.0 40 | 41 | def get_tokens(self, current): 42 | """Get the current amount of available tokens.""" 43 | if not current: 44 | current = time.time() 45 | 46 | # by default, use _tokens 47 | tokens = self._tokens 48 | 49 | # if current timestamp is above _window + seconds 50 | # reset tokens to self.requests (default) 51 | if current > self._window + self.second: 52 | tokens = self.requests 53 | 54 | return tokens 55 | 56 | def update_rate_limit(self): 57 | """Update current ratelimit state.""" 58 | current = time.time() 59 | self._last = current 60 | self._tokens = self.get_tokens(current) 61 | 62 | # we are using the ratelimit for the first time 63 | # so set current ratelimit window to right now 64 | if self._tokens == self.requests: 65 | self._window = current 66 | 67 | # Are we currently ratelimited? 68 | if self._tokens == 0: 69 | self.retries += 1 70 | return self.second - (current - self._window) 71 | 72 | # if not ratelimited, remove a token 73 | self.retries = 0 74 | self._tokens -= 1 75 | 76 | # if we got ratelimited after that token removal, 77 | # set window to now 78 | if self._tokens == 0: 79 | self._window = current 80 | 81 | def reset(self): 82 | """Reset current ratelimit to default state.""" 83 | self._tokens = self.requests 84 | self._last = 0.0 85 | self.retries = 0 86 | 87 | def copy(self): 88 | """Create a copy of this ratelimit. 89 | 90 | Used to manage multiple ratelimits to users. 91 | """ 92 | return RatelimitBucket(self.requests, self.second) 93 | 94 | def __repr__(self): 95 | return ( 96 | f"" 99 | ) 100 | 101 | 102 | class Ratelimit: 103 | """Manages buckets.""" 104 | 105 | def __init__(self, tokens, second, keys=None): 106 | self._cache = {} 107 | if keys is None: 108 | keys = tuple() 109 | self.keys = keys 110 | self._cooldown = RatelimitBucket(tokens, second) 111 | 112 | def __repr__(self): 113 | return f"" 114 | 115 | def _verify_cache(self): 116 | current = time.time() 117 | dead_keys = [k for k, v in self._cache.items() if current > v._last + v.second] 118 | 119 | for k in dead_keys: 120 | del self._cache[k] 121 | 122 | def get_bucket(self, key) -> RatelimitBucket: 123 | if not self._cooldown: 124 | return None 125 | 126 | self._verify_cache() 127 | 128 | if key not in self._cache: 129 | bucket = self._cooldown.copy() 130 | self._cache[key] = bucket 131 | else: 132 | bucket = self._cache[key] 133 | 134 | return bucket 135 | -------------------------------------------------------------------------------- /litecord/ratelimits/handler.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | 20 | from typing import TYPE_CHECKING 21 | 22 | from litecord.errors import Ratelimited 23 | from litecord.auth import token_check, Unauthorized 24 | 25 | if TYPE_CHECKING: 26 | from litecord.typing_hax import app, request 27 | else: 28 | from quart import current_app as app, request 29 | 30 | async def _check_bucket(bucket): 31 | retry_after = bucket.update_rate_limit() 32 | 33 | request.bucket = bucket 34 | 35 | if retry_after: 36 | request.retry_after = retry_after 37 | 38 | raise Ratelimited( 39 | **{"retry_after": int(retry_after * 1000), "global": request.bucket_global} 40 | ) 41 | 42 | 43 | async def _handle_global(ratelimit): 44 | """Global ratelimit is per-user.""" 45 | try: 46 | user_id = await token_check() 47 | except Unauthorized: 48 | user_id = request.remote_addr 49 | 50 | request.bucket_global = True 51 | bucket = ratelimit.get_bucket(user_id) 52 | await _check_bucket(bucket) 53 | 54 | 55 | async def _handle_specific(ratelimit): 56 | try: 57 | user_id = await token_check() 58 | except Unauthorized: 59 | user_id = request.remote_addr 60 | 61 | # construct the key based on the ratelimit.keys 62 | keys = ratelimit.keys 63 | 64 | # base key is the user id 65 | key_components = [f"user_id:{user_id}"] 66 | 67 | for key in keys: 68 | val = request.view_args[key] 69 | key_components.append(f"{key}:{val}") 70 | 71 | bucket_key = ":".join(key_components) 72 | bucket = ratelimit.get_bucket(bucket_key) 73 | await _check_bucket(bucket) 74 | 75 | 76 | async def ratelimit_handler(): 77 | """Main ratelimit handler. 78 | 79 | Decides on which ratelimit to use. 80 | """ 81 | rule = request.url_rule 82 | 83 | if rule is None: 84 | return 85 | 86 | # rule.endpoint is composed of '.' 87 | # and so we can use that to make routes with different 88 | # methods have different ratelimits 89 | rule_path = rule.endpoint 90 | 91 | # some request ratelimit context. 92 | # TODO: maybe put those in a namedtuple or contextvar of sorts? 93 | request.bucket = None 94 | request.retry_after = None 95 | request.bucket_global = False 96 | 97 | if rule.rule.startswith("/api"): 98 | try: 99 | ratelimit = app.ratelimiter.get_ratelimit(rule_path) 100 | await _handle_specific(ratelimit) 101 | except KeyError: 102 | await _handle_global(app.ratelimiter.global_bucket) 103 | -------------------------------------------------------------------------------- /litecord/ratelimits/main.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | 20 | from litecord.ratelimits.bucket import Ratelimit 21 | 22 | """ 23 | REST: 24 | POST Message | 5/5s | per-channel 25 | DELETE Message | 5/1s | per-channel 26 | PUT/DELETE Reaction | 1/0.25s | per-channel 27 | PATCH Member | 10/10s | per-guild 28 | PATCH Member Nick | 1/1s | per-guild 29 | PATCH Username | 2/3600s | per-account 30 | |All Requests| | 50/1s | per-account 31 | WS: 32 | Gateway Connect | 2/5s | per-account 33 | Presence Update | 5/60s | per-session 34 | |All Sent Messages| | 120/60s | per-session 35 | """ 36 | 37 | REACTION_BUCKET = Ratelimit(1, 0.25, ("channel_id")) 38 | 39 | RATELIMITS = { 40 | "channel_messages.create_message": Ratelimit(5, 5, ("channel_id")), 41 | "channel_messages.delete_message": Ratelimit(5, 1, ("channel_id")), 42 | # all of those share the same bucket. 43 | "channel_reactions.add_reaction": REACTION_BUCKET, 44 | "channel_reactions.remove_own_reaction": REACTION_BUCKET, 45 | "channel_reactions.remove_user_reaction": REACTION_BUCKET, 46 | "guild_members.modify_guild_member": Ratelimit(10, 10, ("guild_id")), 47 | "guild_members.update_nickname": Ratelimit(1, 1, ("guild_id")), 48 | # this only applies to username. 49 | # 'users.patch_me': Ratelimit(2, 3600), 50 | "_ws.connect": Ratelimit(2, 5), 51 | "_ws.presence": Ratelimit(5, 60), 52 | "_ws.messages": Ratelimit(120, 60), 53 | # 1000 / 4h for new session issuing 54 | "_ws.session": Ratelimit(1000, 14400), 55 | } 56 | 57 | 58 | class RatelimitManager: 59 | """Manager for the bucket managers""" 60 | 61 | def __init__(self, testing_flag=False): 62 | self._ratelimiters = {} 63 | self._test = testing_flag 64 | self.global_bucket = Ratelimit(50, 1) 65 | self._fill_rtl() 66 | 67 | def _fill_rtl(self): 68 | for path, rtl in RATELIMITS.items(): 69 | # overwrite rtl with a 10/1 for _ws.connect 70 | # if we're in testing mode. 71 | 72 | # NOTE: this is a bad way to do it, but 73 | # we only need to change that one for now. 74 | rtl = Ratelimit(10, 1) if self._test and path == "_ws.connect" else rtl 75 | 76 | self._ratelimiters[path] = rtl 77 | 78 | def get_ratelimit(self, key: str) -> Ratelimit: 79 | """Get the :class:`Ratelimit` instance for a given path.""" 80 | return self._ratelimiters.get(key, self.global_bucket) 81 | -------------------------------------------------------------------------------- /litecord/snowflake.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | 20 | """ 21 | snowflake.py - snowflake helper functions 22 | 23 | These functions generate discord-like snowflakes. 24 | File brought in from 25 | litecord-reference(https://github.com/lnmds/litecord-reference) 26 | """ 27 | import time 28 | import datetime 29 | 30 | # encoded in ms 31 | EPOCH = 1420070400000 32 | 33 | # internal state 34 | _generated_ids = 0 35 | PROCESS_ID = 1 36 | WORKER_ID = 1 37 | 38 | Snowflake = int 39 | 40 | 41 | def _snowflake(timestamp: int) -> Snowflake: 42 | """Get a snowflake from a specific timestamp 43 | 44 | This function relies on modifying internal variables 45 | to generate unique snowflakes. Because of that every call 46 | to this function will generate a different snowflake, 47 | even with the same timestamp. 48 | 49 | Arguments 50 | --------- 51 | timestamp: int 52 | Timestamp to be feed in to the snowflake algorithm. 53 | This timestamp has to be an UNIX timestamp 54 | with millisecond precision. 55 | """ 56 | # Yes, using global variables aren't the best idea 57 | # Maybe we could distribute the work of snowflake generation 58 | # to actually separated servers? :thinking: 59 | global _generated_ids 60 | 61 | # bits 0-12 encode _generated_ids (size 12) 62 | 63 | # modulo'd to prevent overflows 64 | genid_b = "{0:012b}".format(_generated_ids % 4096) 65 | 66 | # bits 12-17 encode PROCESS_ID (size 5) 67 | procid_b = "{0:05b}".format(PROCESS_ID) 68 | 69 | # bits 17-22 encode WORKER_ID (size 5) 70 | workid_b = "{0:05b}".format(WORKER_ID) 71 | 72 | # bits 22-64 encode (timestamp - EPOCH) (size 42) 73 | epochized = timestamp - EPOCH 74 | epoch_b = "{0:042b}".format(epochized) 75 | 76 | snowflake_b = f"{epoch_b}{workid_b}{procid_b}{genid_b}" 77 | _generated_ids += 1 78 | 79 | return int(snowflake_b, 2) 80 | 81 | 82 | def snowflake_time(snowflake: Snowflake) -> float: 83 | """Get the UNIX timestamp(with millisecond precision, as a float) 84 | from a specific snowflake. 85 | """ 86 | 87 | # the total size for a snowflake is 64 bits, 88 | # considering it is a string, position 0 to 42 will give us 89 | # the `epochized` variable 90 | snowflake_b = "{0:064b}".format(snowflake) 91 | epochized_b = snowflake_b[:42] 92 | epochized = int(epochized_b, 2) 93 | 94 | # since epochized is the time *since* the EPOCH 95 | # the unix timestamp will be the time *plus* the EPOCH 96 | timestamp = epochized + EPOCH 97 | 98 | # convert it to seconds 99 | # since we don't want to break the entire 100 | # snowflake interface 101 | return timestamp / 1000 102 | 103 | 104 | def snowflake_datetime(snowflake: Snowflake) -> datetime.datetime: 105 | """Return a datetime object representing the snowflake.""" 106 | unix_ts = snowflake_time(snowflake) 107 | return datetime.datetime.fromtimestamp(unix_ts) 108 | 109 | 110 | def get_snowflake(): 111 | """Generate a snowflake""" 112 | return _snowflake(int(time.time() * 1000)) 113 | -------------------------------------------------------------------------------- /litecord/types.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | 20 | from datetime import timezone 21 | from typing import Optional 22 | 23 | # size units 24 | KILOBYTES = 1024 25 | 26 | # time units 27 | MINUTES = 60 28 | HOURS = 60 * MINUTES 29 | 30 | 31 | class Color: 32 | """Custom color class""" 33 | 34 | def __init__(self, val: int): 35 | self.blue = val & 255 36 | self.green = (val >> 8) & 255 37 | self.red = (val >> 16) & 255 38 | 39 | @property 40 | def value(self): 41 | """Give the actual RGB integer encoding this color.""" 42 | return int("%02x%02x%02x" % (self.red, self.green, self.blue), 16) 43 | 44 | @property 45 | def to_json(self): 46 | return self.value 47 | 48 | def __int__(self): 49 | return self.value 50 | 51 | 52 | def timestamp_(dt) -> Optional[str]: 53 | """safer version for dt.isoformat()""" 54 | return dt.astimezone(timezone.utc).isoformat() if dt else None 55 | -------------------------------------------------------------------------------- /litecord/typing_hax.py: -------------------------------------------------------------------------------- 1 | from aiohttp import ClientSession 2 | from asyncio import AbstractEventLoop, get_event_loop 3 | from asyncpg import Pool 4 | from quart import current_app, Quart, Request as _Request, request 5 | from typing import cast, Any, Optional 6 | from winter import SnowflakeFactory 7 | import config 8 | 9 | from .ratelimits.bucket import RatelimitBucket 10 | from .ratelimits.main import RatelimitManager 11 | from .gateway.state_manager import StateManager 12 | from .storage import Storage 13 | from .user_storage import UserStorage 14 | from .images import IconManager 15 | from .dispatcher import EventDispatcher 16 | from .presence import PresenceManager 17 | from .guild_memory_store import GuildMemoryStore 18 | from .pubsub.lazy_guild import LazyGuildManager 19 | from .voice.manager import VoiceManager 20 | from .jobs import JobManager 21 | from .errors import BadRequest 22 | 23 | class Request(_Request): 24 | 25 | discord_api_version: int 26 | bucket: Optional[RatelimitBucket] 27 | bucket_global: RatelimitBucket 28 | retry_after: Optional[int] 29 | user_id: Optional[int] 30 | 31 | def on_json_loading_failed(self, error: Exception) -> Any: 32 | raise BadRequest(50109) 33 | 34 | 35 | class LitecordApp(Quart): 36 | request_class: Request 37 | session: ClientSession 38 | db: Pool 39 | sched: JobManager 40 | 41 | winter_factory: SnowflakeFactory 42 | loop: AbstractEventLoop 43 | ratelimiter: RatelimitManager 44 | state_manager: StateManager 45 | storage: Storage 46 | user_storage: UserStorage 47 | icons: IconManager 48 | dispatcher: EventDispatcher 49 | presence: PresenceManager 50 | guild_store: GuildMemoryStore 51 | lazy_guild: LazyGuildManager 52 | voice: VoiceManager 53 | 54 | def __init__( 55 | self, 56 | import_name: str, 57 | config_path: str = f"config.{config.MODE}", 58 | ) -> None: 59 | super().__init__( 60 | import_name, 61 | ) 62 | self.config.from_object(config_path) 63 | self.config["MAX_CONTENT_LENGTH"] = 500 * 1024 * 1024 # 500 MB 64 | 65 | def init_managers(self): 66 | # Init singleton classes 67 | self.session = ClientSession() 68 | self.winter_factory = SnowflakeFactory() 69 | self.loop = get_event_loop() 70 | self.ratelimiter = RatelimitManager(self.config.get("_testing", False)) 71 | self.state_manager = StateManager() 72 | self.storage = Storage(self) 73 | self.user_storage = UserStorage(self.storage) 74 | self.icons = IconManager(self) 75 | self.dispatcher = EventDispatcher() 76 | self.presence = PresenceManager(self) 77 | self.storage.presence = self.presence 78 | self.guild_store = GuildMemoryStore() 79 | self.lazy_guild = LazyGuildManager() 80 | self.voice = VoiceManager(self) 81 | @property 82 | def is_debug(self) -> bool: 83 | return self.config.get("DEBUG", False) 84 | 85 | 86 | app = cast(LitecordApp, current_app) 87 | request = cast(Request, request) 88 | -------------------------------------------------------------------------------- /litecord/voice/lvsp_opcodes.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | 20 | 21 | class OPCodes: 22 | """LVSP OP codes.""" 23 | 24 | hello = 0 25 | identify = 1 26 | resume = 2 27 | ready = 3 28 | heartbeat = 4 29 | heartbeat_ack = 5 30 | info = 6 31 | 32 | 33 | InfoTable = { 34 | "CHANNEL_REQ": 0, 35 | "CHANNEL_ASSIGN": 1, 36 | "CHANNEL_UPDATE": 2, 37 | "CHANNEL_DESTROY": 3, 38 | "VST_CREATE": 4, 39 | "VST_UPDATE": 5, 40 | "VST_LEAVE": 6, 41 | } 42 | 43 | InfoReverse = {v: k for k, v in InfoTable.items()} 44 | -------------------------------------------------------------------------------- /litecord/voice/state.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | 20 | from dataclasses import dataclass, asdict 21 | 22 | 23 | @dataclass 24 | class VoiceState: 25 | """Represents a voice state.""" 26 | 27 | guild_id: int 28 | channel_id: int 29 | user_id: int 30 | session_id: str 31 | deaf: bool 32 | mute: bool 33 | self_deaf: bool 34 | self_mute: bool 35 | suppressed_by: int 36 | 37 | @property 38 | def key(self): 39 | """Get the second part of a key identifying a state.""" 40 | return self.channel_id if self.guild_id is None else self.guild_id 41 | 42 | @property 43 | def as_json(self): 44 | """Return JSON-serializable dict.""" 45 | return asdict(self) 46 | 47 | def as_json_for(self, user_id: int): 48 | """Generate JSON-serializable version, given a user ID.""" 49 | self_dict = asdict(self) 50 | 51 | if user_id is None: 52 | return self_dict 53 | 54 | # state.suppress is defined by the user 55 | # that is currently viewing the state. 56 | 57 | # a better approach would be actually using 58 | # the suppressed_by field for backend efficiency. 59 | self_dict["suppress"] = user_id == self.suppressed_by 60 | self_dict.pop("suppressed_by") 61 | 62 | return self_dict 63 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | 4 | Litecord 5 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 6 | 7 | This program is free software: you can redistribute it and/or modify 8 | it under the terms of the GNU General Public License as published by 9 | the Free Software Foundation, version 3 of the License. 10 | 11 | This program is distributed in the hope that it will be useful, 12 | but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | GNU General Public License for more details. 15 | 16 | You should have received a copy of the GNU General Public License 17 | along with this program. If not, see . 18 | 19 | """ 20 | 21 | import logging 22 | import sys 23 | 24 | from manage.main import main 25 | 26 | import config 27 | 28 | logging.basicConfig(level=logging.DEBUG) 29 | 30 | if __name__ == "__main__": 31 | sys.exit(main(config)) 32 | -------------------------------------------------------------------------------- /manage/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | -------------------------------------------------------------------------------- /manage/cmd/invites.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | 20 | import datetime 21 | import string 22 | from random import choice 23 | 24 | ALPHABET = string.ascii_lowercase + string.ascii_uppercase + string.digits 25 | 26 | 27 | async def _gen_inv() -> str: 28 | """Generate an invite code""" 29 | return "".join(choice(ALPHABET) for _ in range(6)) 30 | 31 | 32 | async def gen_inv(ctx) -> str: 33 | """Generate an invite.""" 34 | for _ in range(10): 35 | possible_inv = await _gen_inv() 36 | 37 | created_at = await ctx.db.fetchval( 38 | """ 39 | SELECT created_at 40 | FROM instance_invites 41 | WHERE code = $1 42 | """, 43 | possible_inv, 44 | ) 45 | 46 | if created_at is None: 47 | return possible_inv 48 | 49 | return None 50 | 51 | 52 | async def make_inv(ctx, args): 53 | code = await gen_inv(ctx) 54 | 55 | max_uses = args.max_uses 56 | 57 | await ctx.db.execute( 58 | """ 59 | INSERT INTO instance_invites (code, max_uses) 60 | VALUES ($1, $2) 61 | """, 62 | code, 63 | max_uses, 64 | ) 65 | 66 | print(f"invite created with {max_uses} max uses", code) 67 | 68 | 69 | async def list_invs(ctx, args): 70 | rows = await ctx.db.fetch( 71 | """ 72 | SELECT code, created_at, uses, max_uses 73 | FROM instance_invites 74 | """ 75 | ) 76 | 77 | print(len(rows), "invites") 78 | 79 | for row in rows: 80 | max_uses = row["max_uses"] 81 | delta = datetime.datetime.utcnow() - row["created_at"] 82 | usage = "infinite uses" if max_uses == -1 else f'{row["uses"]} / {max_uses}' 83 | 84 | print(f'\t{row["code"]}, {usage}, made {delta} ago') 85 | 86 | 87 | async def delete_inv(ctx, args): 88 | inv = args.invite_code 89 | 90 | res = await ctx.db.execute( 91 | """ 92 | DELETE FROM instance_invites 93 | WHERE code = $1 94 | """, 95 | inv, 96 | ) 97 | 98 | if res == "DELETE 0": 99 | print("NOT FOUND") 100 | return 101 | 102 | print("OK") 103 | 104 | 105 | def setup(subparser): 106 | makeinv_parser = subparser.add_parser("makeinv", help="create an invite") 107 | 108 | makeinv_parser.add_argument( 109 | "max_uses", 110 | nargs="?", 111 | type=int, 112 | default=-1, 113 | help="Maximum amount of uses before the invite is unavailable", 114 | ) 115 | 116 | makeinv_parser.set_defaults(func=make_inv) 117 | 118 | listinv_parser = subparser.add_parser("listinv", help="list all invites") 119 | listinv_parser.set_defaults(func=list_invs) 120 | 121 | delinv_parser = subparser.add_parser("delinv", help="delete an invite") 122 | delinv_parser.add_argument("invite_code") 123 | delinv_parser.set_defaults(func=delete_inv) 124 | -------------------------------------------------------------------------------- /manage/cmd/migration/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | 20 | from .command import setup as migration 21 | 22 | __all__ = ["migration"] 23 | -------------------------------------------------------------------------------- /manage/cmd/migration/scripts/10_permissions.sql: -------------------------------------------------------------------------------- 1 | -- channel_overwrites table already has allow and deny as bigints. 2 | alter table roles 3 | alter column permissions type bigint; 4 | -------------------------------------------------------------------------------- /manage/cmd/migration/scripts/11_user_bio_and_accent_color.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE users 2 | ADD COLUMN accent_color INT DEFAULT NULL, 3 | ADD COLUMN bio TEXT DEFAULT '' NOT NULL; 4 | -------------------------------------------------------------------------------- /manage/cmd/migration/scripts/12_inline_replies.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE messages 2 | ADD COLUMN message_reference jsonb DEFAULT null, 3 | ADD COLUMN allowed_mentions jsonb DEFAULT null; 4 | -------------------------------------------------------------------------------- /manage/cmd/migration/scripts/13_fix_member_foreign_key.sql: -------------------------------------------------------------------------------- 1 | BEGIN TRANSACTION; 2 | 3 | DELETE 4 | FROM guild_settings 5 | WHERE NOT EXISTS( 6 | SELECT members.user_id 7 | FROM members 8 | WHERE members.user_id = guild_settings.user_id 9 | AND members.guild_id = guild_settings.guild_id 10 | ); 11 | 12 | ALTER TABLE guild_settings 13 | DROP CONSTRAINT IF EXISTS guild_settings_user_id_fkey, 14 | DROP CONSTRAINT IF EXISTS guild_settings_guild_id_fkey, 15 | ADD CONSTRAINT guild_settings_user_id_guild_id_fkey 16 | FOREIGN KEY (user_id, guild_id) 17 | REFERENCES members (user_id, guild_id) 18 | ON DELETE CASCADE; 19 | 20 | DELETE 21 | FROM member_roles 22 | WHERE NOT EXISTS( 23 | SELECT members.user_id 24 | FROM members 25 | WHERE members.user_id = member_roles.user_id 26 | AND members.guild_id = member_roles.guild_id 27 | ); 28 | 29 | ALTER TABLE member_roles 30 | DROP CONSTRAINT IF EXISTS member_roles_user_id_fkey, 31 | DROP CONSTRAINT IF EXISTS member_roles_guild_id_fkey, 32 | ADD CONSTRAINT member_roles_user_id_guild_id_fkey 33 | FOREIGN KEY (user_id, guild_id) 34 | REFERENCES members (user_id, guild_id) 35 | ON DELETE CASCADE; 36 | 37 | -- To make channel_overwrites aware of guilds, we need to backfill the column 38 | -- with data from the guild_channels table. after that, we can make a proper 39 | -- foreign key to the members table! 40 | 41 | ALTER TABLE channel_overwrites 42 | ADD COLUMN guild_id bigint DEFAULT NULL; 43 | 44 | UPDATE channel_overwrites 45 | SET guild_id = guild_channels.guild_id 46 | FROM guild_channels 47 | WHERE guild_channels.id = channel_overwrites.channel_id; 48 | 49 | ALTER TABLE channel_overwrites 50 | ALTER COLUMN guild_id DROP DEFAULT, 51 | ALTER COLUMN guild_id SET NOT NULL; 52 | 53 | ALTER TABLE channel_overwrites 54 | DROP CONSTRAINT IF EXISTS channel_overwrites_target_user_fkey, 55 | ADD CONSTRAINT channel_overwrites_target_user_guild_id_fkey 56 | FOREIGN KEY (target_user, guild_id) 57 | REFERENCES members (user_id, guild_id) 58 | ON DELETE CASCADE; 59 | 60 | COMMIT; 61 | -------------------------------------------------------------------------------- /manage/cmd/migration/scripts/14_add_user_system.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE users 2 | ADD COLUMN system BOOL DEFAULT FALSE NOT NULL; 3 | -------------------------------------------------------------------------------- /manage/cmd/migration/scripts/15_remove_guild_region.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE guilds 2 | DROP CONSTRAINT guilds_region_fkey; -------------------------------------------------------------------------------- /manage/cmd/migration/scripts/16_add_guild_progress_bar.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE guilds ADD COLUMN premium_progress_bar_enabled boolean NOT NULL DEFAULT false; -------------------------------------------------------------------------------- /manage/cmd/migration/scripts/17_add_banners_member_stuff.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE users 2 | ADD COLUMN banner text REFERENCES icons (hash) DEFAULT NULL; 3 | ALTER TABLE members 4 | ADD COLUMN avatar text REFERENCES icons (hash) DEFAULT NULL, 5 | ADD COLUMN banner text REFERENCES icons (hash) DEFAULT NULL; 6 | -------------------------------------------------------------------------------- /manage/cmd/migration/scripts/18_refactor_images.sql: -------------------------------------------------------------------------------- 1 | UPDATE icons 2 | SET scope = 'user_avatar' 3 | WHERE scope = 'user'; 4 | 5 | UPDATE icons 6 | SET scope = 'guild_icon' 7 | WHERE scope = 'guild'; 8 | 9 | UPDATE icons 10 | SET scope = 'guild_splash' 11 | WHERE scope = 'splash'; 12 | 13 | UPDATE icons 14 | SET scope = 'guild_discovery_splash' 15 | WHERE scope = 'discovery_splash'; 16 | 17 | UPDATE icons 18 | SET scope = 'guild_banner' 19 | WHERE scope = 'banner'; 20 | 21 | UPDATE icons 22 | SET scope = 'channel_icon' 23 | WHERE scope = 'channel-icons'; 24 | -------------------------------------------------------------------------------- /manage/cmd/migration/scripts/19_member_bio.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE members 2 | ADD COLUMN bio TEXT DEFAULT '' NOT NULL; 3 | -------------------------------------------------------------------------------- /manage/cmd/migration/scripts/1_webhook_avatars.sql: -------------------------------------------------------------------------------- 1 | -- webhook_avatars table. check issue 46. 2 | CREATE TABLE IF NOT EXISTS webhook_avatars ( 3 | webhook_id bigint, 4 | 5 | -- this is e.g a sha256 hash of EmbedURL.to_md_url 6 | hash text, 7 | 8 | -- we don't hardcode the mediaproxy url here for obvious reasons. 9 | -- the output of EmbedURL.to_md_url goes here. 10 | md_url_redir text, 11 | 12 | PRIMARY KEY (webhook_id, hash) 13 | ); 14 | -------------------------------------------------------------------------------- /manage/cmd/migration/scripts/20_fuck_icons.sql: -------------------------------------------------------------------------------- 1 | ALTER table users 2 | DROP CONSTRAINT users_avatar_fkey, 3 | DROP CONSTRAINT users_banner_fkey, 4 | ADD CONSTRAINT users_avatar_fkey 5 | FOREIGN KEY (avatar) REFERENCES icons (hash) 6 | ON DELETE SET NULL ON UPDATE CASCADE, 7 | ADD CONSTRAINT users_banner_fkey 8 | FOREIGN KEY (banner) REFERENCES icons (hash) 9 | ON DELETE SET NULL ON UPDATE CASCADE; 10 | 11 | ALTER table members 12 | DROP CONSTRAINT members_avatar_fkey, 13 | DROP CONSTRAINT members_banner_fkey, 14 | ADD CONSTRAINT members_avatar_fkey 15 | FOREIGN KEY (avatar) REFERENCES icons (hash) 16 | ON DELETE SET NULL ON UPDATE CASCADE, 17 | ADD CONSTRAINT members_banner_fkey 18 | FOREIGN KEY (banner) REFERENCES icons (hash) 19 | ON DELETE SET NULL ON UPDATE CASCADE; 20 | 21 | ALTER table group_dm_channels 22 | DROP CONSTRAINT group_dm_channels_icon_fkey, 23 | ADD CONSTRAINT group_dm_channels_icon_fkey 24 | FOREIGN KEY (icon) REFERENCES icons (hash) 25 | ON DELETE SET NULL ON UPDATE CASCADE; 26 | 27 | ALTER table guild_emoji 28 | DROP CONSTRAINT guild_emoji_image_fkey, 29 | ADD CONSTRAINT guild_emoji_image_fkey 30 | FOREIGN KEY (image) REFERENCES icons (hash) 31 | ON DELETE CASCADE ON UPDATE CASCADE; 32 | 33 | ALTER table guilds 34 | ADD CONSTRAINT guilds_icon_fkey 35 | FOREIGN KEY (icon) REFERENCES icons (hash) 36 | ON DELETE SET NULL ON UPDATE CASCADE, 37 | ADD CONSTRAINT guilds_splash_fkey 38 | FOREIGN KEY (splash) REFERENCES icons (hash) 39 | ON DELETE SET NULL ON UPDATE CASCADE, 40 | ADD CONSTRAINT guilds_discovery_splash_fkey 41 | FOREIGN KEY (discovery_splash) REFERENCES icons (hash) 42 | ON DELETE SET NULL ON UPDATE CASCADE, 43 | ADD CONSTRAINT guilds_banner_fkey 44 | FOREIGN KEY (banner) REFERENCES icons (hash) 45 | ON DELETE SET NULL ON UPDATE CASCADE; 46 | 47 | UPDATE icons 48 | SET hash = '#' || hash::text 49 | WHERE hash = hash; 50 | -------------------------------------------------------------------------------- /manage/cmd/migration/scripts/21_fuck_icons_2.sql: -------------------------------------------------------------------------------- 1 | UPDATE icons 2 | SET hash = 'a_78f36f55ba85d65b.' || REPLACE (hash, '#a_', '') 3 | WHERE hash LIKE '#a_%'; 4 | 5 | UPDATE icons 6 | SET hash = '78f36f55ba85d65b.' || REPLACE (hash, '#', '') 7 | WHERE hash NOT LIKE '%a_%'; 8 | -------------------------------------------------------------------------------- /manage/cmd/migration/scripts/22_add_channel_banner.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE guild_channels 2 | ADD COLUMN banner text REFERENCES icons (hash) ON DELETE SET NULL ON UPDATE CASCADE DEFAULT NULL; 3 | -------------------------------------------------------------------------------- /manage/cmd/migration/scripts/23_add_user_pronouns.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE users 2 | ADD COLUMN pronouns text DEFAULT '' NOT NULL; 3 | -------------------------------------------------------------------------------- /manage/cmd/migration/scripts/24_add_avatar_decos.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE users 2 | ADD COLUMN avatar_decoration text REFERENCES icons (hash) ON DELETE SET NULL ON UPDATE CASCADE DEFAULT NULL; 3 | -------------------------------------------------------------------------------- /manage/cmd/migration/scripts/25_add_webhook_type.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE webhooks 2 | ADD COLUMN type int NOT NULL DEFAULT 1, 3 | ADD COLUMN source_id bigint DEFAULT NULL; 4 | 5 | ALTER TABLE messages 6 | ADD COLUMN sticker_ids jsonb DEFAULT '[]'; 7 | -------------------------------------------------------------------------------- /manage/cmd/migration/scripts/26_make_nonce_nullable.sql: -------------------------------------------------------------------------------- 1 | UPDATE messages 2 | SET nonce = NULL 3 | WHERE nonce = 0; 4 | 5 | ALTER TABLE messages 6 | ALTER COLUMN nonce TYPE text; 7 | 8 | ALTER TABLE messages 9 | ALTER COLUMN nonce set DEFAULT NULL; 10 | -------------------------------------------------------------------------------- /manage/cmd/migration/scripts/27_add_more_profile_stuff.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE members 2 | ADD COLUMN pronouns text DEFAULT '' NOT NULL; 3 | 4 | ALTER TABLE users 5 | ADD COLUMN theme_colors integer[] DEFAULT NULL; 6 | -------------------------------------------------------------------------------- /manage/cmd/migration/scripts/28_nsfw_level.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE guilds 2 | ADD COLUMN nsfw_level int DEFAULT 0; 3 | 4 | ALTER TABLE users 5 | ADD COLUMN date_of_birth timestamp without time zone DEFAULT NULL; 6 | -------------------------------------------------------------------------------- /manage/cmd/migration/scripts/29_friend_nicknames.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE relationships 2 | ADD COLUMN nickname TEXT DEFAULT NULL; 3 | -------------------------------------------------------------------------------- /manage/cmd/migration/scripts/2_fix_chan_overwrites_constraint.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE channel_overwrites 2 | DROP CONSTRAINT IF EXISTS channel_overwrites_uniq; 3 | 4 | ALTER TABLE channel_overwrites 5 | ADD CONSTRAINT channel_overwrites_target_role_uniq 6 | UNIQUE (channel_id, target_role); 7 | 8 | ALTER TABLE channel_overwrites 9 | ADD CONSTRAINT channel_overwrites_target_user_uniq 10 | UNIQUE (channel_id, target_user); 11 | -------------------------------------------------------------------------------- /manage/cmd/migration/scripts/30_proper_replies_type.sql: -------------------------------------------------------------------------------- 1 | UPDATE messages 2 | SET message_type = 19 3 | WHERE message_type = 0 and not message_reference::text = '{}'; 4 | -------------------------------------------------------------------------------- /manage/cmd/migration/scripts/31_allowed_mentions_proper.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE messages 2 | DROP COLUMN allowed_mentions; 3 | 4 | ALTER TABLE messages 5 | ADD COLUMN mentions bigint[] NOT NULL DEFAULT array[]::bigint[]; 6 | 7 | ALTER TABLE messages 8 | ADD COLUMN mention_roles bigint[] NOT NULL DEFAULT array[]::bigint[]; 9 | -------------------------------------------------------------------------------- /manage/cmd/migration/scripts/3_add_message_flags.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE messages 2 | ADD COLUMN flags bigint DEFAULT 0; 3 | -------------------------------------------------------------------------------- /manage/cmd/migration/scripts/5_add_rules_channel_id.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE guilds ADD COLUMN rules_channel_id bigint; -------------------------------------------------------------------------------- /manage/cmd/migration/scripts/6_add_public_updates_channel_id.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE guilds ADD COLUMN public_updates_channel_id bigint; -------------------------------------------------------------------------------- /manage/cmd/migration/scripts/7_add_prefered_locale.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE guilds ADD COLUMN preferred_locale text; -------------------------------------------------------------------------------- /manage/cmd/migration/scripts/8_add_discovery_splash.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE guilds ADD COLUMN discovery_splash text; -------------------------------------------------------------------------------- /manage/cmd/migration/scripts/9_add_custom_status_settings.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE user_settings ADD COLUMN custom_status jsonb DEFAULT NULL; 2 | -------------------------------------------------------------------------------- /manage/main.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | 20 | import asyncio 21 | import argparse 22 | import inspect 23 | from sys import argv 24 | from dataclasses import dataclass 25 | 26 | from quart import Quart 27 | from logbook import Logger 28 | 29 | from run import init_app_managers, init_app_db 30 | from manage.cmd.migration import migration 31 | from manage.cmd import users, invites 32 | 33 | log = Logger(__name__) 34 | 35 | 36 | @dataclass 37 | class FakeApp: 38 | """Fake app instance.""" 39 | 40 | config: dict 41 | db = None 42 | loop: asyncio.BaseEventLoop = None 43 | ratelimiter = None 44 | state_manager = None 45 | storage = None 46 | user_storage = None 47 | icons = None 48 | dispatcher = None 49 | presence = None 50 | voice = None 51 | guild_store = None 52 | 53 | def make_app(self) -> Quart: 54 | app = Quart(__name__) 55 | app.config.from_object(self.config) 56 | fields = [ 57 | field 58 | for (field, _val) in inspect.getmembers(self) 59 | if not field.startswith("__") 60 | ] 61 | 62 | for field in fields: 63 | setattr(app, field, getattr(self, field)) 64 | 65 | return app 66 | 67 | 68 | def init_parser(): 69 | parser = argparse.ArgumentParser() 70 | subparser = parser.add_subparsers(help="operations") 71 | 72 | migration(subparser) 73 | users.setup(subparser) 74 | invites.setup(subparser) 75 | 76 | return parser 77 | 78 | 79 | def main(config): 80 | """Start the script""" 81 | loop = asyncio.get_event_loop() 82 | 83 | # by doing this we can "import" quart's default config keys, 84 | # like SERVER_NAME, required for app_context to work. 85 | quart_app = Quart(__name__) 86 | quart_app.config.from_object(f"config.{config.MODE}") 87 | 88 | app = FakeApp(quart_app.config) 89 | parser = init_parser() 90 | 91 | loop.run_until_complete(init_app_db(app)) 92 | 93 | async def _ctx_wrapper(fake_app, args): 94 | app = fake_app.make_app() 95 | async with app.app_context(): 96 | return await args.func(fake_app, args) 97 | 98 | try: 99 | if len(argv) < 2: 100 | parser.print_help() 101 | return 102 | 103 | # only init app managers when we aren't migrating 104 | # as the managers require it 105 | # and the migrate command also sets the db up 106 | if argv[1] != "migrate": 107 | init_app_managers(app, init_voice=False) 108 | 109 | args = parser.parse_args() 110 | return loop.run_until_complete(_ctx_wrapper(app, args)) 111 | except Exception: 112 | log.exception("error while running command") 113 | return 1 114 | finally: 115 | loop.run_until_complete(app.db.close()) 116 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | check_untyped_defs = True 3 | no_implicit_optional = True 4 | [mypy-logbook] 5 | ignore_missing_imports = True 6 | [mypy-quart] 7 | ignore_missing_imports = True 8 | [mypy-winter] 9 | ignore_missing_imports = True 10 | [mypy-asyncpg] 11 | ignore_missing_imports = True 12 | -------------------------------------------------------------------------------- /nginx.example.conf: -------------------------------------------------------------------------------- 1 | # litecord nginx file 2 | 3 | # this file is not considering any https happening, 4 | # those are manual and up to the instance owner. 5 | 6 | server { 7 | server_name example.tld; 8 | 9 | location / { 10 | proxy_pass http://localhost:8000; 11 | } 12 | 13 | # if you're hosting a custom index page while keeping 14 | # litecord on /api, be sure to pass /.well-known and /nodeinfo to 15 | # it too. 16 | 17 | # location /api { 18 | # proxy_pass http://localhost:8000; 19 | # } 20 | # 21 | # location /.well-known { 22 | # proxy_pass http://localhost:8000; 23 | # } 24 | # 25 | # location /nodeinfo { 26 | # proxy_pass http://localhost:8000; 27 | # } 28 | 29 | # if you don't want to keep the gateway 30 | # domain as the main domain, you can 31 | # keep a separate server block 32 | location /ws { 33 | proxy_pass http://localhost:5001; 34 | 35 | # those options are required for websockets 36 | proxy_http_version 1.1; 37 | proxy_set_header Upgrade $http_upgrade; 38 | proxy_set_header Connection "upgrade"; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "litecord" 3 | version = "0.1.0" 4 | description = "Clean-Room implementation of the Discord Backend" 5 | authors = ["Luna "] 6 | license = "GPLv3-only" 7 | 8 | [tool.poetry.dependencies] 9 | python = "^3.9" 10 | bcrypt = "^3.2.2" 11 | itsdangerous = "^2.1.2" 12 | asyncpg = "^0.26.0" 13 | websockets = "^10.3" 14 | Earl-ETF = "^2.1.2" 15 | logbook = "^1.5.3" 16 | Cerberus = "^1.3.4" 17 | quart = "^0.18.0" 18 | pillow = "^9.2.0" 19 | aiohttp = "^3.8.1" 20 | zstandard = "^0.18.0" 21 | winter = {git = "https://gitlab.com/elixire/winter"} 22 | wsproto = "^1.1.0" 23 | aiofile = "^3.8.1" 24 | emoji = "<3.0.0" 25 | 26 | 27 | [tool.poetry.dev-dependencies] 28 | 29 | [build-system] 30 | requires = ["poetry-core>=1.0.0"] 31 | build-backend = "poetry.core.masonry.api" 32 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | 20 | from setuptools import setup 21 | 22 | setup( 23 | name="litecord", 24 | version="0.0.1", 25 | description="Implementation of the Discord API", 26 | url="https://litecord.top", 27 | author="Luna Mendes", 28 | python_requires=">=3.7", 29 | ) 30 | -------------------------------------------------------------------------------- /static/css/invite_register.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | background-color: #23272a; 4 | color: white; 5 | font: 16px/1 sans-serif; 6 | 7 | padding: 0; 8 | margin: 0; 9 | 10 | width: 100%; 11 | height: 100%; 12 | 13 | display: flex; 14 | justify-content: center; 15 | align-items: center; 16 | flex-flow: column nowrap; 17 | } 18 | 19 | *, 20 | *:before, 21 | *:after { 22 | box-sizing: border-box; 23 | } 24 | 25 | #register { 26 | width: 30rem; 27 | border-radius: 0.5rem; 28 | background-color: #2c2f33; 29 | padding: 2rem; 30 | } 31 | 32 | #register h1 { 33 | margin: 0 0 2rem; 34 | } 35 | 36 | .form-group { 37 | margin: 2rem 0; 38 | } 39 | 40 | .form-group label { 41 | display: block; 42 | margin-bottom: 0.75rem; 43 | } 44 | 45 | .form-group input { 46 | font: inherit; 47 | color: inherit; 48 | 49 | border: none; 50 | border-radius: 0.25rem; 51 | padding: 0.5rem; 52 | display: block; 53 | width: 100%; 54 | background-color: rgba(255, 255, 255, 0.08); 55 | } 56 | 57 | .form-group input:focus { 58 | outline: none; 59 | box-shadow: 0 0 0 0.15rem #7289da; 60 | } 61 | 62 | input[name='invcode'] { 63 | font-family: monospace; 64 | } 65 | 66 | input[type='submit'] { 67 | font: inherit; 68 | color: inherit; 69 | cursor: pointer; 70 | 71 | border: none; 72 | border-radius: 0.25rem; 73 | display: block; 74 | width: 100%; 75 | background-color: #7289da; 76 | padding: 0.75rem 0; 77 | } 78 | 79 | @media (max-width: 600px) { 80 | #register { 81 | width: 100% !important; 82 | background: none !important; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /static/invite_register.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | litecord: register 6 | 7 | 8 | 9 | 10 |
11 |

Invite Register

12 |
13 |
14 | 15 | 16 |
17 | 18 |
19 | 20 | 21 |
22 | 23 |
24 | 25 | 26 |
27 | 28 |
29 | 30 | 31 |
32 | 33 | 34 |
35 |
36 | 37 | 38 | -------------------------------------------------------------------------------- /static/logo/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hood-network/patchcord/d0d084c5dfa1c3b5f4af659836fc4ff2c42bad5d/static/logo/logo.png -------------------------------------------------------------------------------- /static/logo/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /static/logo/logo@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hood-network/patchcord/d0d084c5dfa1c3b5f4af659836fc4ff2c42bad5d/static/logo/logo@2x.png -------------------------------------------------------------------------------- /templates/2016.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | Discord 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /templates/2018.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | Discord 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /templates/2020.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | Discord 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /templates/build_override.html: -------------------------------------------------------------------------------- 1 | Working... 2 | 3 | Just a second... 4 | 5 | 24 | -------------------------------------------------------------------------------- /tests/test_admin_api/test_guilds.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | 20 | import secrets 21 | 22 | import pytest 23 | 24 | from litecord.errors import GuildNotFound 25 | 26 | 27 | async def _fetch_guild(test_cli_staff, guild_id: str, *, return_early: bool = False): 28 | resp = await test_cli_staff.get(f"/api/v6/admin/guilds/{guild_id}") 29 | 30 | if return_early: 31 | return resp 32 | 33 | assert resp.status_code == 200 34 | rjson = await resp.json 35 | assert isinstance(rjson, dict) 36 | assert rjson["id"] == guild_id 37 | 38 | return rjson 39 | 40 | 41 | @pytest.mark.asyncio 42 | async def test_guild_fetch(test_cli_staff): 43 | """Test the creation and fetching of a guild via the Admin API.""" 44 | guild = await test_cli_staff.create_guild() 45 | await _fetch_guild(test_cli_staff, str(guild.id)) 46 | 47 | 48 | @pytest.mark.asyncio 49 | async def test_guild_update(test_cli_staff): 50 | """Test the update of a guild via the Admin API.""" 51 | guild = await test_cli_staff.create_guild() 52 | guild_id = str(guild.id) 53 | 54 | # I believe setting up an entire gateway client registered to the guild 55 | # would be overkill to test the side-effects, so... I'm not 56 | # testing them. Yes, I know its a bad idea, but if someone has an easier 57 | # way to write that, do send an MR. 58 | resp = await test_cli_staff.patch( 59 | f"/api/v6/admin/guilds/{guild_id}", json={"unavailable": True} 60 | ) 61 | 62 | assert resp.status_code == 200 63 | rjson = await resp.json 64 | assert isinstance(rjson, dict) 65 | assert rjson["id"] == guild_id 66 | assert rjson["unavailable"] 67 | 68 | rjson = await _fetch_guild(test_cli_staff, guild_id) 69 | assert rjson["id"] == guild_id 70 | assert rjson["unavailable"] 71 | 72 | 73 | @pytest.mark.asyncio 74 | async def test_guild_delete(test_cli_staff): 75 | """Test the update of a guild via the Admin API.""" 76 | guild = await test_cli_staff.create_guild() 77 | guild_id = str(guild.id) 78 | 79 | resp = await test_cli_staff.delete(f"/api/v6/admin/guilds/{guild_id}") 80 | assert resp.status_code == 204 81 | 82 | resp = await _fetch_guild(test_cli_staff, guild_id, return_early=True) 83 | assert resp.status_code == 404 84 | 85 | rjson = await resp.json 86 | assert isinstance(rjson, dict) 87 | assert rjson["error"] 88 | assert rjson["code"] == GuildNotFound.error_code 89 | 90 | 91 | @pytest.mark.asyncio 92 | async def test_guild_create_voice(test_cli_staff): 93 | region_id = secrets.token_hex(6) 94 | region_name = secrets.token_hex(6) 95 | resp = await test_cli_staff.put( 96 | "/api/v6/admin/voice/regions", json={"id": region_id, "name": region_name} 97 | ) 98 | assert resp.status_code == 200 99 | rjson = await resp.json 100 | assert isinstance(rjson, list) 101 | assert region_id in [r["id"] for r in rjson] 102 | 103 | # This test is basically creating the guild with a self-selected region 104 | # then deleting the guild afterwards on test resource cleanup 105 | try: 106 | await test_cli_staff.create_guild(region=region_id) 107 | finally: 108 | await test_cli_staff.app.db.execute( 109 | """ 110 | DELETE FROM voice_regions 111 | WHERE id = $1 112 | """, 113 | region_id, 114 | ) 115 | -------------------------------------------------------------------------------- /tests/test_admin_api/test_instance_invites.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | 20 | import pytest 21 | 22 | 23 | async def _get_invs(test_cli): 24 | resp = await test_cli.get("/api/v6/admin/instance/invites") 25 | 26 | assert resp.status_code == 200 27 | rjson = await resp.json 28 | assert isinstance(rjson, list) 29 | return rjson 30 | 31 | 32 | @pytest.mark.asyncio 33 | async def test_get_invites(test_cli_staff): 34 | """Test the listing of instance invites.""" 35 | await _get_invs(test_cli_staff) 36 | 37 | 38 | @pytest.mark.asyncio 39 | async def test_inv_delete_invalid(test_cli_staff): 40 | """Test errors happen when trying to delete a 41 | non-existing instance invite.""" 42 | resp = await test_cli_staff.delete("/api/v6/admin/instance/invites/aaaaaa") 43 | 44 | assert resp.status_code == 404 45 | 46 | 47 | @pytest.mark.asyncio 48 | async def test_create_invite(test_cli_staff): 49 | """Test the creation of an instance invite, then listing it, 50 | then deleting it.""" 51 | resp = await test_cli_staff.put( 52 | "/api/v6/admin/instance/invites", json={"max_uses": 1} 53 | ) 54 | 55 | assert resp.status_code == 200 56 | rjson = await resp.json 57 | assert isinstance(rjson, dict) 58 | code = rjson["code"] 59 | 60 | # assert that the invite is in the list 61 | invites = await _get_invs(test_cli_staff) 62 | assert any(inv["code"] == code for inv in invites) 63 | 64 | # delete it, and assert it worked 65 | resp = await test_cli_staff.delete(f"/api/v6/admin/instance/invites/{code}") 66 | 67 | assert resp.status_code == 204 68 | -------------------------------------------------------------------------------- /tests/test_admin_api/test_users.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | 20 | import secrets 21 | 22 | import pytest 23 | 24 | from tests.common import email 25 | from litecord.enums import UserFlags 26 | 27 | 28 | async def _search(test_cli, *, username="", discrim=""): 29 | query_string = {"username": username, "discriminator": discrim} 30 | 31 | return await test_cli.get("/api/v6/admin/users", query_string=query_string) 32 | 33 | 34 | @pytest.mark.asyncio 35 | async def test_list_users(test_cli_staff): 36 | """Try to list as many users as possible.""" 37 | resp = await _search(test_cli_staff, username=test_cli_staff.user["username"]) 38 | 39 | assert resp.status_code == 200 40 | rjson = await resp.json 41 | assert isinstance(rjson, list) 42 | assert rjson 43 | 44 | 45 | @pytest.mark.asyncio 46 | async def test_find_single_user(test_cli_staff): 47 | user = await test_cli_staff.create_user( 48 | username="test_user" + secrets.token_hex(2), email=email() 49 | ) 50 | resp = await _search(test_cli_staff, username=user.name) 51 | 52 | assert resp.status_code == 200 53 | rjson = await resp.json 54 | assert isinstance(rjson, list) 55 | fetched_user = rjson[0] 56 | assert fetched_user["id"] == str(user.id) 57 | 58 | 59 | async def _setup_user(test_cli) -> dict: 60 | genned = secrets.token_hex(7) 61 | 62 | resp = await test_cli.post( 63 | "/api/v6/admin/users", 64 | json={ 65 | "username": genned, 66 | "email": f"{genned}@{genned}.com", 67 | "password": genned, 68 | }, 69 | ) 70 | 71 | assert resp.status_code == 200 72 | rjson = await resp.json 73 | assert isinstance(rjson, dict) 74 | assert rjson["username"] == genned 75 | 76 | return rjson 77 | 78 | 79 | async def _del_user(test_cli, user_id): 80 | """Delete a user.""" 81 | resp = await test_cli.delete(f"/api/v6/admin/users/{user_id}") 82 | 83 | assert resp.status_code == 200 84 | rjson = await resp.json 85 | assert isinstance(rjson, dict) 86 | assert rjson["new"]["id"] == user_id 87 | assert rjson["old"]["id"] == rjson["new"]["id"] 88 | 89 | # delete the original record since the DELETE endpoint will just 90 | # replace the user by a "Deleted User ", and we don't want 91 | # to have obsolete users filling up our db every time we run tests 92 | await test_cli.app.db.execute( 93 | """ 94 | DELETE FROM users WHERE id = $1 95 | """, 96 | int(user_id), 97 | ) 98 | 99 | 100 | @pytest.mark.asyncio 101 | async def test_create_delete(test_cli_staff): 102 | """Create a user. Then delete them.""" 103 | rjson = await _setup_user(test_cli_staff) 104 | 105 | genned = rjson["username"] 106 | genned_uid = rjson["id"] 107 | 108 | try: 109 | # check if side-effects went through with a search 110 | resp = await _search(test_cli_staff, username=genned) 111 | 112 | assert resp.status_code == 200 113 | rjson = await resp.json 114 | assert isinstance(rjson, list) 115 | assert rjson[0]["id"] == genned_uid 116 | finally: 117 | await _del_user(test_cli_staff, genned_uid) 118 | 119 | 120 | @pytest.mark.asyncio 121 | async def test_user_update(test_cli_staff): 122 | """Test user update.""" 123 | user = await test_cli_staff.create_user() 124 | 125 | # set them as partner flag 126 | resp = await test_cli_staff.patch( 127 | f"/api/v6/admin/users/{user.id}", json={"flags": UserFlags.partner} 128 | ) 129 | 130 | assert resp.status_code == 200 131 | rjson = await resp.json 132 | assert rjson["id"] == str(user.id) 133 | assert rjson["flags"] == UserFlags.partner 134 | 135 | refetched = await user.refetch() 136 | assert refetched.flags == UserFlags.partner 137 | -------------------------------------------------------------------------------- /tests/test_embeds.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | 20 | from litecord.schemas import validate 21 | from litecord.embed.schemas import EMBED_OBJECT 22 | from litecord.embed.sanitizer import path_exists 23 | 24 | 25 | def validate_embed(embed): 26 | return validate(embed, EMBED_OBJECT) 27 | 28 | 29 | def valid(embed: dict): 30 | try: 31 | validate_embed(embed) 32 | return True 33 | except Exception: 34 | return False 35 | 36 | 37 | def invalid(embed): 38 | return not valid(embed) 39 | 40 | 41 | def test_empty_embed(): 42 | assert valid({}) 43 | 44 | 45 | def test_basic_embed(): 46 | assert valid( 47 | { 48 | "title": "test", 49 | "description": "acab", 50 | "url": "https://www.w3.org", 51 | "color": 123, 52 | } 53 | ) 54 | 55 | 56 | def test_footer_embed(): 57 | assert invalid({"footer": {}}) 58 | 59 | assert valid({"title": "test", "footer": {"text": "abcdef"}}) 60 | 61 | 62 | def test_image(): 63 | assert invalid({"image": {}}) 64 | 65 | assert valid({"image": {"url": "https://www.w3.org"}}) 66 | 67 | 68 | def test_author(): 69 | assert invalid({"author": {"name": ""}}) 70 | 71 | assert valid({"author": {"name": "abcdef"}}) 72 | 73 | 74 | def test_fields(): 75 | assert valid( 76 | { 77 | "fields": [ 78 | {"name": "a", "value": "b"}, 79 | {"name": "c", "value": "d", "inline": False}, 80 | ] 81 | } 82 | ) 83 | 84 | assert invalid({"fields": [{"name": "a"}]}) 85 | 86 | 87 | def test_path_exists(): 88 | """Test the path_exists() function for embed sanitization.""" 89 | assert path_exists({"a": {"b": 2}}, "a.b") 90 | assert path_exists({"a": "b"}, "a") 91 | assert not path_exists({"a": "b"}, "a.b") 92 | -------------------------------------------------------------------------------- /tests/test_gateway.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | 20 | import sys 21 | import os 22 | 23 | sys.path.append(os.getcwd()) 24 | 25 | import pytest 26 | 27 | 28 | @pytest.mark.asyncio 29 | async def test_gw(test_cli): 30 | """Test if the gateway route works.""" 31 | resp = await test_cli.get("/api/v6/gateway") 32 | assert resp.status_code == 200 33 | rjson = await resp.json 34 | assert isinstance(rjson, dict) 35 | assert "url" in rjson 36 | assert isinstance(rjson["url"], str) 37 | 38 | 39 | @pytest.mark.asyncio 40 | async def test_gw_bot(test_cli_user): 41 | """Test the Get Bot Gateway route""" 42 | resp = await test_cli_user.get("/api/v6/gateway/bot") 43 | 44 | assert resp.status_code == 200 45 | rjson = await resp.json 46 | 47 | assert isinstance(rjson, dict) 48 | assert isinstance(rjson["url"], str) 49 | assert isinstance(rjson["shards"], int) 50 | assert "session_start_limit" in rjson 51 | 52 | ssl = rjson["session_start_limit"] 53 | assert isinstance(ssl["total"], int) 54 | assert isinstance(ssl["remaining"], int) 55 | assert isinstance(ssl["reset_after"], int) 56 | -------------------------------------------------------------------------------- /tests/test_guild.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | import secrets 20 | 21 | import pytest 22 | 23 | 24 | @pytest.mark.asyncio 25 | async def test_guild_create(test_cli_user): 26 | """Test the creation of a guild, in three stages: 27 | - creating it 28 | - checking the list 29 | - deleting it 30 | """ 31 | g_name = secrets.token_hex(5) 32 | 33 | # stage 1: create 34 | resp = await test_cli_user.post( 35 | "/api/v6/guilds", json={"name": g_name, "region": None} 36 | ) 37 | 38 | assert resp.status_code == 200 39 | rjson = await resp.json 40 | 41 | # we won't assert a full guild object. 42 | assert isinstance(rjson["id"], str) 43 | assert isinstance(rjson["owner_id"], str) 44 | assert isinstance(rjson["name"], str) 45 | assert rjson["name"] == g_name 46 | 47 | created = rjson 48 | guild_id = created["id"] 49 | 50 | # stage 2: test 51 | resp = await test_cli_user.get("/api/v6/users/@me/guilds") 52 | 53 | assert resp.status_code == 200 54 | rjson = await resp.json 55 | 56 | assert isinstance(rjson, list) 57 | 58 | # it MUST be 1 as we'll delete the guild later on. 59 | # plus the test user never starts with any guild. 60 | assert len(rjson) == 1 61 | 62 | for guild in rjson: 63 | assert isinstance(guild, dict) 64 | assert isinstance(guild["id"], str) 65 | assert isinstance(guild["name"], str) 66 | assert isinstance(guild["owner"], bool) 67 | assert guild["icon"] is None or isinstance(guild["icon"], str) 68 | 69 | try: 70 | our_guild = next(filter(lambda guild: guild["id"] == guild_id, rjson)) 71 | except StopIteration: 72 | raise Exception("created guild not found in user guild list") 73 | 74 | assert our_guild["id"] == created["id"] 75 | assert our_guild["name"] == created["name"] 76 | 77 | # stage 3: deletion 78 | resp = await test_cli_user.delete(f"/api/v6/guilds/{guild_id}") 79 | 80 | assert resp.status_code == 204 81 | -------------------------------------------------------------------------------- /tests/test_main.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | 20 | import pytest 21 | 22 | 23 | @pytest.mark.asyncio 24 | async def test_index(test_cli): 25 | """Test if the main index page works.""" 26 | resp = await test_cli.get("/") 27 | assert resp.status_code == 200 28 | -------------------------------------------------------------------------------- /tests/test_messages.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | 20 | import pytest 21 | 22 | pytestmark = pytest.mark.asyncio 23 | 24 | 25 | async def test_message_listing(test_cli_user): 26 | guild = await test_cli_user.create_guild() 27 | channel = await test_cli_user.create_guild_channel(guild_id=guild.id) 28 | messages = [] 29 | for _ in range(10): 30 | messages.append( 31 | await test_cli_user.create_message(guild_id=guild.id, channel_id=channel.id) 32 | ) 33 | 34 | # assert all messages we just created can be refetched if we give the 35 | # middle message to the 'around' parameter 36 | middle_message_id = messages[5].id 37 | 38 | resp = await test_cli_user.get( 39 | f"/api/v6/channels/{channel.id}/messages", 40 | query_string={"around": middle_message_id}, 41 | ) 42 | assert resp.status_code == 200 43 | rjson = await resp.json 44 | 45 | fetched_ids = [m["id"] for m in rjson] 46 | for message in messages: 47 | assert str(message.id) in fetched_ids 48 | 49 | # assert all messages are below given id if its on 'before' param 50 | 51 | resp = await test_cli_user.get( 52 | f"/api/v6/channels/{channel.id}/messages", 53 | query_string={"before": middle_message_id}, 54 | ) 55 | assert resp.status_code == 200 56 | rjson = await resp.json 57 | 58 | for message_json in rjson: 59 | assert int(message_json["id"]) <= middle_message_id 60 | 61 | # assert all message are above given id if its on 'after' param 62 | resp = await test_cli_user.get( 63 | f"/api/v6/channels/{channel.id}/messages", 64 | query_string={"after": middle_message_id}, 65 | ) 66 | assert resp.status_code == 200 67 | rjson = await resp.json 68 | 69 | for message_json in rjson: 70 | assert int(message_json["id"]) >= middle_message_id 71 | 72 | 73 | async def test_message_update(test_cli_user): 74 | guild = await test_cli_user.create_guild() 75 | channel = await test_cli_user.create_guild_channel(guild_id=guild.id) 76 | message = await test_cli_user.create_message( 77 | guild_id=guild.id, channel_id=channel.id 78 | ) 79 | 80 | resp = await test_cli_user.patch( 81 | f"/api/v6/channels/{channel.id}/messages/{message.id}", 82 | json={"content": "awooga"}, 83 | ) 84 | assert resp.status_code == 200 85 | rjson = await resp.json 86 | 87 | assert rjson["id"] == str(message.id) 88 | assert rjson["content"] == "awooga" 89 | 90 | refetched = await message.refetch() 91 | assert refetched.content == "awooga" 92 | 93 | 94 | async def test_message_pinning(test_cli_user): 95 | guild = await test_cli_user.create_guild() 96 | channel = await test_cli_user.create_guild_channel(guild_id=guild.id) 97 | message = await test_cli_user.create_message( 98 | guild_id=guild.id, channel_id=channel.id 99 | ) 100 | 101 | resp = await test_cli_user.put(f"/api/v6/channels/{channel.id}/pins/{message.id}") 102 | assert resp.status_code == 204 103 | 104 | resp = await test_cli_user.get(f"/api/v6/channels/{channel.id}/pins") 105 | assert resp.status_code == 200 106 | rjson = await resp.json 107 | assert len(rjson) == 1 108 | assert rjson[0]["id"] == str(message.id) 109 | 110 | resp = await test_cli_user.delete( 111 | f"/api/v6/channels/{channel.id}/pins/{message.id}" 112 | ) 113 | assert resp.status_code == 204 114 | 115 | resp = await test_cli_user.get(f"/api/v6/channels/{channel.id}/pins") 116 | assert resp.status_code == 200 117 | rjson = await resp.json 118 | assert len(rjson) == 0 119 | -------------------------------------------------------------------------------- /tests/test_no_tracking.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | 20 | import pytest 21 | 22 | 23 | @pytest.mark.asyncio 24 | async def test_science_empty(test_cli): 25 | """Test that the science route gives nothing.""" 26 | resp = await test_cli.post("/api/v6/science") 27 | assert resp.status_code == 204 28 | 29 | 30 | @pytest.mark.asyncio 31 | async def test_harvest_empty(test_cli): 32 | """test that the harvest route is empty""" 33 | resp = await test_cli.get("/api/v6/users/@me/harvest") 34 | assert resp.status_code == 204 35 | 36 | 37 | @pytest.mark.asyncio 38 | async def test_consent_non_consenting(test_cli_user): 39 | """Test the consent route to see if we're still on 40 | a non-consent status regarding data collection.""" 41 | resp = await test_cli_user.get("/api/v6/users/@me/consent") 42 | assert resp.status_code == 200 43 | 44 | rjson = await resp.json 45 | assert isinstance(rjson, dict) 46 | 47 | # assert that we did not consent to those 48 | assert not rjson["usage_statistics"]["consented"] 49 | assert not rjson["personalization"]["consented"] 50 | -------------------------------------------------------------------------------- /tests/test_ratelimits.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | 20 | import sys 21 | import os 22 | 23 | sys.path.append(os.getcwd()) 24 | 25 | import pytest 26 | 27 | from litecord.ratelimits.bucket import Ratelimit 28 | 29 | 30 | def test_ratelimit(): 31 | """Test basic ratelimiting""" 32 | r = Ratelimit(0, 10) 33 | bucket = r.get_bucket(0) 34 | retry_after = bucket.update_rate_limit() 35 | assert isinstance(retry_after, float) 36 | assert retry_after <= 10 37 | 38 | 39 | @pytest.mark.asyncio 40 | async def test_ratelimit_headers(test_cli): 41 | """Test if the basic ratelimit headers are sent.""" 42 | resp = await test_cli.get("/api/v6/gateway") 43 | assert resp.status_code == 200 44 | hdrs = resp.headers 45 | assert "X-RateLimit-Limit" in hdrs 46 | assert "X-RateLimit-Remaining" in hdrs 47 | assert "X-RateLimit-Reset" in hdrs 48 | assert "X-RateLimit-Global" in hdrs 49 | -------------------------------------------------------------------------------- /tests/test_reactions.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | 20 | import pytest 21 | import urllib.parse 22 | 23 | pytestmark = pytest.mark.asyncio 24 | 25 | 26 | async def test_reaction_flow(test_cli_user): 27 | guild = await test_cli_user.create_guild() 28 | channel = await test_cli_user.create_guild_channel(guild_id=guild.id) 29 | message = await test_cli_user.create_message( 30 | guild_id=guild.id, channel_id=channel.id 31 | ) 32 | 33 | reaction = urllib.parse.quote("\N{THINKING FACE}") 34 | 35 | resp = await test_cli_user.put( 36 | f"/api/v6/channels/{channel.id}/messages/{message.id}/reactions/{reaction}/@me" 37 | ) 38 | assert resp.status_code == 204 39 | 40 | resp = await test_cli_user.get( 41 | f"/api/v6/channels/{channel.id}/messages/{message.id}/reactions/{reaction}" 42 | ) 43 | assert resp.status_code == 200 44 | rjson = await resp.json 45 | assert len(rjson) == 1 46 | -------------------------------------------------------------------------------- /tests/test_user.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | 20 | import pytest 21 | import secrets 22 | 23 | 24 | @pytest.mark.asyncio 25 | async def test_get_me(test_cli_user): 26 | resp = await test_cli_user.get("/api/v6/users/@me") 27 | 28 | assert resp.status_code == 200 29 | rjson = await resp.json 30 | assert isinstance(rjson, dict) 31 | 32 | # incomplete user assertions, but should be enough 33 | assert isinstance(rjson["id"], str) 34 | assert isinstance(rjson["username"], str) 35 | assert isinstance(rjson["discriminator"], str) 36 | assert rjson["avatar"] is None or isinstance(rjson["avatar"], str) 37 | assert isinstance(rjson["flags"], int) 38 | assert isinstance(rjson["bot"], bool) 39 | 40 | 41 | @pytest.mark.asyncio 42 | async def test_get_me_guilds(test_cli_user): 43 | resp = await test_cli_user.get("/api/v6/users/@me/guilds") 44 | 45 | assert resp.status_code == 200 46 | rjson = await resp.json 47 | assert isinstance(rjson, list) 48 | 49 | 50 | @pytest.mark.asyncio 51 | async def test_get_profile_self(test_cli_user): 52 | user_id = test_cli_user.user["id"] 53 | resp = await test_cli_user.get(f"/api/v6/users/{user_id}/profile") 54 | 55 | assert resp.status_code == 200 56 | rjson = await resp.json 57 | assert isinstance(rjson, dict) 58 | assert isinstance(rjson["user"], dict) 59 | assert isinstance(rjson["connected_accounts"], list) 60 | assert rjson["premium_since"] is None or isinstance(rjson["premium_since"], str) 61 | assert isinstance(rjson["mutual_guilds"], list) 62 | 63 | 64 | @pytest.mark.asyncio 65 | async def test_create_user(test_cli): 66 | """Test the creation and deletion of a user.""" 67 | username = secrets.token_hex(4) 68 | _email = secrets.token_hex(5) 69 | email = f"{_email}@{_email}.com" 70 | password = secrets.token_hex(6) 71 | 72 | resp = await test_cli.post( 73 | "/api/v6/auth/register", 74 | json={"username": username, "email": email, "password": password}, 75 | ) 76 | 77 | assert resp.status_code == 200 78 | rjson = await resp.json 79 | 80 | assert isinstance(rjson, dict) 81 | token = rjson["token"] 82 | assert isinstance(token, str) 83 | 84 | resp = await test_cli.get("/api/v6/users/@me", headers={"Authorization": token}) 85 | 86 | assert resp.status_code == 200 87 | rjson = await resp.json 88 | assert rjson["username"] == username 89 | assert rjson["email"] == email 90 | 91 | resp = await test_cli.post( 92 | "/api/v6/users/@me/delete", 93 | headers={"Authorization": token}, 94 | json={"password": password}, 95 | ) 96 | 97 | assert resp.status_code == 204 98 | 99 | await test_cli.app.db.execute( 100 | """ 101 | DELETE FROM users WHERE id = $1 102 | """, 103 | int(rjson["id"]), 104 | ) 105 | 106 | 107 | WANTED_BIO = "hello world!" 108 | WANTED_ACCENT_COLOR = 0x424242 109 | 110 | 111 | @pytest.mark.asyncio 112 | async def test_patch_me_bio_accent_color(test_cli_user): 113 | resp = await test_cli_user.patch( 114 | "/api/v6/users/@me", 115 | json={"bio": WANTED_BIO, "accent_color": WANTED_ACCENT_COLOR}, 116 | ) 117 | 118 | assert resp.status_code == 200 119 | rjson = await resp.json 120 | assert isinstance(rjson, dict) 121 | assert rjson["bio"] == WANTED_BIO 122 | assert rjson["accent_color"] == WANTED_ACCENT_COLOR 123 | -------------------------------------------------------------------------------- /tests/test_webhooks.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Litecord 4 | Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, version 3 of the License. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | 18 | """ 19 | 20 | import pytest 21 | 22 | pytestmark = pytest.mark.asyncio 23 | 24 | 25 | async def test_webhook_flow(test_cli_user): 26 | guild = await test_cli_user.create_guild() 27 | channel = await test_cli_user.create_guild_channel(guild_id=guild.id) 28 | 29 | resp = await test_cli_user.post( 30 | f"/api/v6/channels/{channel.id}/webhooks", json={"name": "awooga"} 31 | ) 32 | assert resp.status_code == 200 33 | rjson = await resp.json 34 | assert rjson["channel_id"] == str(channel.id) 35 | assert rjson["guild_id"] == str(guild.id) 36 | assert rjson["name"] == "awooga" 37 | 38 | webhook_id = rjson["id"] 39 | webhook_token = rjson["token"] 40 | 41 | resp = await test_cli_user.post( 42 | f"/api/v6/webhooks/{webhook_id}/{webhook_token}", 43 | json={"content": "test_message"}, 44 | headers={"authorization": ""}, 45 | ) 46 | assert resp.status_code == 204 47 | 48 | refetched_channel = await channel.refetch() 49 | message = await test_cli_user.app.storage.get_message( 50 | refetched_channel.last_message_id 51 | ) 52 | assert message["author"]["id"] == webhook_id 53 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py3.9 3 | isolated_build = true 4 | 5 | [testenv] 6 | ignore_errors = true 7 | deps = 8 | pytest==6.2.5 9 | pytest-asyncio==0.15.1 10 | pytest-cov==2.12.1 11 | flake8==3.9.2 12 | black==21.6b0 13 | mypy==0.910 14 | pytest-instafail==0.4.2 15 | commands = 16 | python3 ./manage.py migrate 17 | black --check litecord run.py tests manage 18 | flake8 litecord run.py tests manage 19 | pytest {posargs:tests} 20 | 21 | [flake8] 22 | max-line-length = 88 23 | ignore = E501,W503,E203,E402 24 | --------------------------------------------------------------------------------