Merge branch 'guild-features' into 'master'

Guild features

Closes #37 and #36

See merge request litecord/litecord!25
This commit is contained in:
Luna 2019-03-15 19:37:26 +00:00
commit 681dee4306
23 changed files with 619 additions and 89 deletions

View File

@ -2,7 +2,9 @@
the base path is `/api/v6/admin`.
## GET `/voice/regions/<region>`
## Voice
### GET `/voice/regions/<region>`
Return a list of voice server objects for the region.
@ -13,7 +15,7 @@ Returns empty list if the region does not exist.
| hostname | string | the hostname of the voice server |
| last\_health | float | the health of the voice server |
## PUT `/voice/regions`
### PUT `/voice/regions`
Create a voice region.
@ -27,7 +29,7 @@ Receives JSON body as input, returns a list of voice region objects as output.
| deprecated | Optional[bool] | if voice region is deprecated, default false |
| custom | Optional[bool] | if voice region is custom-only, default false |
## PUT `/voice/regions/<region>/server`
### PUT `/voice/regions/<region>/server`
Create a voice server for a region.
@ -37,9 +39,49 @@ Returns empty body with 204 status code on success.
| --: | :-- | :-- |
| hostname | string | the hostname of the voice server |
## PUT `/voice/regions/<region>/deprecate`
### PUT `/voice/regions/<region>/deprecate`
Mark a voice region as deprecated. Disables any voice actions on guilds that are
using the voice region.
Returns empty body with 204 status code on success.
## Guilds
### GET `/guilds/<guild_id>`
Returns a partial guild object.
## Guild features
The currently supported features are:
- `INVITE_SPLASH`, allows custom images to be put for invites.
- `VIP_REGIONS`, allows a guild to use voice regions marked as VIP.
- `VANITY_URL`, allows a custom invite URL to be used.
- `MORE_EMOJI`, bumps the emoji limit from 50 to 200 (applies to static and
animated emoji).
- `VERIFIED`, adds a verified badge and a guild banner being shown on the
top of the channel list.
Features that are not planned to be implemented:
- `COMMERCE`
- `NEWS`
### PATCH `/guilds/<guild_id>/features`
Patch the entire features list. Returns the new feature list following the same
structure as the input.
| field | type | description |
| --: | :-- | :-- |
| features | List[string] | new list of features |
### PUT `/guilds/<guild_id>/features`
Insert features. Receives and returns the same structure as
PATCH `/guilds/<guild_id>/features`.
### DELETE `/guilds/<guild_id>/features`
Remove features. Receives and returns the same structure as
PATCH `/guilds/<guild_id>/features`.

View File

@ -17,6 +17,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
from litecord.enums import Feature
VOICE_SERVER = {
'hostname': {'type': 'string', 'maxlength': 255, 'required': True}
}
@ -29,3 +31,12 @@ VOICE_REGION = {
'deprecated': {'type': 'boolean', 'default': False},
'custom': {'type': 'boolean', 'default': False},
}
FEATURES = {
'features': {
'type': 'list', 'required': True,
# using Feature doesn't seem to work with a "not callable" error.
'schema': {'coerce': lambda x: Feature(x)}
}
}

View File

@ -18,5 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
from .voice import bp as voice
from .features import bp as features
from .guilds import bp as guilds
__all__ = ['voice']
__all__ = ['voice', 'features', 'guilds']

View File

@ -0,0 +1,111 @@
"""
Litecord
Copyright (C) 2018-2019 Luna Mendes
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, version 3 of the License.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
from typing import List
from quart import Blueprint, current_app as app, jsonify, request
from litecord.auth import admin_check
from litecord.errors import BadRequest
from litecord.schemas import validate
from litecord.admin_schemas import FEATURES
from litecord.blueprints.guilds import vanity_invite
bp = Blueprint('features_admin', __name__)
async def _features_from_req() -> List[str]:
j = validate(await request.get_json(), FEATURES)
return [feature.value for feature in j['features']]
async def _features(guild_id: int):
return jsonify({
'features': await app.storage.guild_features(guild_id)
})
async def _update_features(guild_id: int, features: list):
if 'VANITY_URL' not in features:
existing_inv = await vanity_invite(guild_id)
await app.db.execute("""
DELETE FROM vanity_invites
WHERE guild_id = $1
""", guild_id)
await app.db.execute("""
DELETE FROM invites
WHERE code = $1
""", existing_inv)
await app.db.execute("""
UPDATE guilds
SET features = $1
WHERE id = $2
""", features, guild_id)
guild = await app.storage.get_guild_full(guild_id)
await app.dispatcher.dispatch('guild', guild_id, 'GUILD_UPDATE', guild)
@bp.route('/<int:guild_id>/features', methods=['PATCH'])
async def replace_features(guild_id: int):
"""Replace the feature list in a guild"""
await admin_check()
features = await _features_from_req()
# yes, we need to pass it to a set and then to a list before
# doing anything, since the api client might just
# shove 200 repeated features to us.
await _update_features(guild_id, list(set(features)))
return await _features(guild_id)
@bp.route('/<int:guild_id>/features', methods=['PUT'])
async def insert_features(guild_id: int):
"""Insert a feature on a guild."""
await admin_check()
to_add = await _features_from_req()
features = await app.storage.guild_features(guild_id)
features = set(features)
# i'm assuming set.add is mostly safe
for feature in to_add:
features.add(feature)
await _update_features(guild_id, list(features))
return await _features(guild_id)
@bp.route('/<int:guild_id>/features', methods=['DELETE'])
async def remove_features(guild_id: int):
"""Remove a feature from a guild"""
await admin_check()
to_remove = await _features_from_req()
features = await app.storage.guild_features(guild_id)
for feature in to_remove:
try:
features.remove(feature)
except ValueError:
raise BadRequest('Trying to remove already removed feature.')
await _update_features(guild_id, features)
return await _features(guild_id)

View File

@ -0,0 +1,33 @@
"""
Litecord
Copyright (C) 2018-2019 Luna Mendes
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, version 3 of the License.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
from quart import Blueprint, jsonify, current_app as app
from litecord.auth import admin_check
bp = Blueprint('guilds_admin', __name__)
@bp.route('/<int:guild_id>', methods=['GET'])
async def get_guild(guild_id: int):
"""Get a basic guild payload."""
await admin_check()
return jsonify(
await app.storage.get_guild(guild_id)
)

View File

@ -23,6 +23,7 @@ import secrets
import itsdangerous
import bcrypt
from quart import Blueprint, jsonify, request, current_app as app
from logbook import Logger
from litecord.auth import token_check, create_user
from litecord.schemas import validate, REGISTER, REGISTER_WITH_INVITE
@ -30,7 +31,7 @@ from litecord.errors import BadRequest
from litecord.snowflake import get_snowflake
from .invites import use_invite
log = Logger(__name__)
bp = Blueprint('auth', __name__)
@ -64,10 +65,17 @@ async def register():
j = await request.get_json()
if not 'password' in j:
j['password'] = 'default_password' # we need some password to make a token
# we need a password to generate a token.
# passwords are optional, so
j['password'] = 'default_password'
j = validate(j, REGISTER)
email, password, username, invite = j['email'] if 'email' in j else None, j['password'], j['username'], j['invite']
# they're optional
email = j.get('email')
invite = j.get('invite')
username, password = j['username'], j['password']
new_id, pwd_hash = await create_user(
username, email, password, app.db
@ -76,9 +84,9 @@ async def register():
if invite:
try:
await use_invite(new_id, invite)
except Exception as e:
print(e)
pass # do nothing
except Exception:
log.exception('failed to use invite for register {} {!r}',
new_id, invite)
return jsonify({
'token': make_token(new_id, pwd_hash)

View File

@ -24,6 +24,8 @@ from litecord.blueprints.checks import guild_check, guild_perm_check
from litecord.schemas import validate, NEW_EMOJI, PATCH_EMOJI
from litecord.snowflake import get_snowflake
from litecord.types import KILOBYTES
from litecord.images import parse_data_uri
from litecord.errors import BadRequest
bp = Blueprint('guild.emoji', __name__)
@ -54,6 +56,24 @@ async def _get_guild_emoji_one(guild_id, emoji_id):
)
async def _guild_emoji_size_check(guild_id: int, mime: str):
limit = 50
if await app.storage.has_feature(guild_id, 'MORE_EMOJI'):
limit = 200
# NOTE: I'm assuming you can have 200 animated emojis.
select_animated = mime == 'image/gif'
total_emoji = await app.db.fetchval("""
SELECT COUNT(*) FROM guild_emoji
WHERE guild_id = $1 AND animated = $2
""", guild_id, select_animated)
if total_emoji >= limit:
# TODO: really return a BadRequest? needs more looking.
raise BadRequest(f'too many emoji ({limit})')
@bp.route('/<int:guild_id>/emojis', methods=['POST'])
async def _put_emoji(guild_id):
user_id = await token_check()
@ -63,6 +83,11 @@ async def _put_emoji(guild_id):
j = validate(await request.get_json(), NEW_EMOJI)
# we have to parse it before passing on so that we know which
# size to check.
mime, _ = parse_data_uri(j['image'])
await _guild_emoji_size_check(guild_id, mime)
emoji_id = get_snowflake()
icon = await app.icons.put(
@ -75,6 +100,8 @@ async def _put_emoji(guild_id):
if not icon:
return '', 400
# TODO: better way to detect animated emoji rather than just gifs,
# maybe a list perhaps?
await app.db.execute(
"""
INSERT INTO guild_emoji
@ -85,7 +112,8 @@ async def _put_emoji(guild_id):
emoji_id, guild_id, user_id,
j['name'],
icon.icon_hash,
icon.mime == 'image/gif')
icon.mime == 'image/gif'
)
await _dispatch_emojis(guild_id)

View File

@ -17,6 +17,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
from typing import Optional
from quart import Blueprint, request, current_app as app, jsonify
from litecord.blueprints.guild.channels import create_guild_channel
@ -28,10 +30,12 @@ from ..auth import token_check
from ..snowflake import get_snowflake
from ..enums import ChannelType
from ..schemas import (
validate, GUILD_CREATE, GUILD_UPDATE, SEARCH_CHANNEL
validate, GUILD_CREATE, GUILD_UPDATE, SEARCH_CHANNEL,
VANITY_URL_PATCH
)
from .channels import channel_ack
from .checks import guild_check, guild_owner_check, guild_perm_check
from litecord.utils import to_update
from litecord.errors import BadRequest
@ -105,17 +109,39 @@ async def guild_create_channels_prep(guild_id: int, channels: list):
await create_guild_channel(guild_id, channel_id, ctype)
async def put_guild_icon(guild_id: int, icon: str):
"""Insert a guild icon on the icon database."""
def sanitize_icon(icon: Optional[str]) -> Optional[str]:
"""Return sanitized version of the given icon.
Defaults to a jpeg icon when the header isn't given.
"""
if icon and icon.startswith('data'):
encoded = icon
else:
encoded = (f'data:image/jpeg;base64,{icon}'
if icon
else None)
return icon
return (f'data:image/jpeg;base64,{icon}'
if icon
else None)
async def _general_guild_icon(scope: str, guild_id: int,
icon: str, **kwargs):
encoded = sanitize_icon(icon)
icon_kwargs = {
'always_icon': True
}
if 'size' in kwargs:
icon_kwargs['size'] = kwargs['size']
return await app.icons.put(
'guild', guild_id, encoded, size=(128, 128), always_icon=True)
scope, guild_id, encoded,
**icon_kwargs
)
async def put_guild_icon(guild_id: int, icon: Optional[str]):
"""Insert a guild icon on the icon database."""
return await _general_guild_icon('guild', guild_id, icon, size=(128, 128))
@bp.route('', methods=['POST'])
@ -195,6 +221,39 @@ async def get_guild(guild_id):
)
async def _guild_update_icon(scope: str, guild_id: int,
icon: Optional[str], **kwargs):
"""Update icon."""
new_icon = await app.icons.update(
scope, guild_id, icon, always_icon=True, **kwargs
)
table = {
'guild': 'icon',
}.get(scope, scope)
await app.db.execute(f"""
UPDATE guilds
SET {table} = $1
WHERE id = $2
""", new_icon.icon_hash, guild_id)
async def _guild_update_region(guild_id, region):
is_vip = region.vip
can_vip = await app.storage.has_feature(guild_id, 'VIP_REGIONS')
if is_vip and not can_vip:
raise BadRequest('can not assign guild to vip-only region')
await app.db.execute("""
UPDATE guilds
SET region = $1
WHERE id = $2
""", region.id, guild_id)
@bp.route('/<int:guild_id>', methods=['PATCH'])
async def _update_guild(guild_id):
user_id = await token_check()
@ -220,26 +279,32 @@ async def _update_guild(guild_id):
""", j['name'], guild_id)
if 'region' in j:
await app.db.execute("""
UPDATE guilds
SET region = $1
WHERE id = $2
""", j['region'], guild_id)
region = app.voice.lvsp.region(j['region'])
if region is not None:
await _guild_update_region(guild_id, region)
if 'icon' in j:
# delete old
new_icon = await app.icons.update(
'guild', guild_id, j['icon'], always_icon=True
)
await _guild_update_icon(
'guild', guild_id, j['icon'], size=(128, 128))
await app.db.execute("""
UPDATE guilds
SET icon = $1
WHERE id = $2
""", new_icon.icon_hash, guild_id)
# small guild to work with to_update()
guild = await app.storage.get_guild(guild_id)
if to_update(j, guild, 'splash'):
if not await app.storage.has_feature(guild_id, 'INVITE_SPLASH'):
raise BadRequest('guild does not have INVITE_SPLASH feature')
await _guild_update_icon('splash', guild_id, j['splash'])
if to_update(j, guild, 'banner'):
if not await app.storage.has_feature(guild_id, 'VERIFIED'):
raise BadRequest('guild is not verified')
await _guild_update_icon('banner', guild_id, j['banner'])
fields = ['verification_level', 'default_message_notifications',
'explicit_content_filter', 'afk_timeout']
'explicit_content_filter', 'afk_timeout', 'description']
for field in [f for f in fields if f in j]:
await app.db.execute(f"""
@ -371,3 +436,95 @@ async def ack_guild(guild_id):
await channel_ack(user_id, guild_id, chan_id)
return '', 204
async def vanity_invite(guild_id: int) -> Optional[str]:
"""Get the vanity invite for a guild."""
return await app.db.fetchval("""
SELECT code FROM vanity_invites
WHERE guild_id = $1
""", guild_id)
@bp.route('/<int:guild_id>/vanity-url', methods=['GET'])
async def get_vanity_url(guild_id: int):
"""Get the vanity url of a guild."""
user_id = await token_check()
await guild_perm_check(user_id, guild_id, 'manage_guild')
inv_code = await vanity_invite(guild_id)
if inv_code is None:
return jsonify({'code': None})
return jsonify(
await app.storage.get_invite(inv_code)
)
@bp.route('/<int:guild_id>/vanity-url', methods=['PATCH'])
async def change_vanity_url(guild_id: int):
"""Get the vanity url of a guild."""
user_id = await token_check()
if not await app.storage.has_feature(guild_id, 'VANITY_URL'):
# TODO: is this the right error
raise BadRequest('guild has no vanity url support')
await guild_perm_check(user_id, guild_id, 'manage_guild')
j = validate(await request.get_json(), VANITY_URL_PATCH)
inv_code = j['code']
# store old vanity in a variable to delete it from
# invites table
old_vanity = await vanity_invite(guild_id)
if old_vanity == inv_code:
raise BadRequest('can not change to same invite')
# this is sad because we don't really use the things
# sql gives us, but i havent really found a way to put
# multiple ON CONFLICT clauses so we could UPDATE when
# guild_id_fkey fails but INSERT when code_fkey fails..
inv = await app.storage.get_invite(inv_code)
if inv:
raise BadRequest('invite already exists')
# TODO: this is bad, what if a guild has no channels?
# we should probably choose the first channel that has
# @everyone read messages
channels = await app.storage.get_channel_data(guild_id)
channel_id = int(channels[0]['id'])
# delete the old invite, insert new one
await app.db.execute("""
DELETE FROM invites
WHERE code = $1
""", old_vanity)
await app.db.execute(
"""
INSERT INTO invites
(code, guild_id, channel_id, inviter, max_uses,
max_age, temporary)
VALUES ($1, $2, $3, $4, $5, $6, $7)
""",
inv_code, guild_id, channel_id, user_id,
# sane defaults for vanity urls.
0, 0, False,
)
await app.db.execute("""
INSERT INTO vanity_invites (guild_id, code)
VALUES ($1, $2)
ON CONFLICT ON CONSTRAINT vanity_invites_pkey DO
UPDATE
SET code = $2
WHERE vanity_invites.guild_id = $1
""", guild_id, inv_code)
return jsonify(
await app.storage.get_invite(inv_code)
)

View File

@ -18,7 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
from os.path import splitext
from quart import Blueprint, current_app as app, send_file, request
from quart import Blueprint, current_app as app, send_file
bp = Blueprint('images', __name__)
@ -53,11 +53,6 @@ async def _get_guild_icon(guild_id: int, icon_file: str):
return await send_icon('guild', guild_id, icon_hash, ext=ext)
@bp.route('/splashes/<int:guild_id>/<icon_hash>.<ext>', methods=['GET'])
async def _get_guild_splash(guild_id: int, splash_hash: str, ext: str):
pass
@bp.route('/embed/avatars/<int:discrim>.png')
async def _get_default_user_avatar(discrim: int):
pass
@ -65,11 +60,8 @@ async def _get_default_user_avatar(discrim: int):
@bp.route('/avatars/<int:user_id>/<avatar_file>')
async def _get_user_avatar(user_id, avatar_file):
size_int = int(request.args.get('size', '1024'))
print('user request size', size_int)
avatar_hash, ext = splitext_(avatar_file)
return await send_icon(
'user', user_id, avatar_hash, ext=ext)
return await send_icon('user', user_id, avatar_hash, ext=ext)
# @bp.route('/app-icons/<int:application_id>/<icon_hash>.<ext>')
@ -78,6 +70,18 @@ async def get_app_icon(application_id, icon_hash, ext):
@bp.route('/channel-icons/<int:channel_id>/<icon_file>', methods=['GET'])
async def _get_gdm_icon(guild_id: int, icon_file: str):
async def _get_gdm_icon(channel_id: int, icon_file: str):
icon_hash, ext = splitext_(icon_file)
return await send_icon('channel-icons', guild_id, icon_hash, ext=ext)
return await send_icon('channel-icons', channel_id, icon_hash, ext=ext)
@bp.route('/splashes/<int:guild_id>/<icon_file>', methods=['GET'])
async def _get_guild_splash(guild_id: int, icon_file: str):
icon_hash, ext = splitext_(icon_file)
return await send_icon('splash', guild_id, icon_hash, ext=ext)
@bp.route('/banners/<int:guild_id>/<icon_file>', methods=['GET'])
async def _get_guild_banner(guild_id: int, icon_file: str):
icon_hash, ext = splitext_(icon_file)
return await send_icon('banner', guild_id, icon_hash, ext=ext)

View File

@ -36,6 +36,7 @@ from litecord.images import parse_data_uri
from litecord.permissions import base_permissions
from litecord.blueprints.auth import check_password
from litecord.utils import to_update
bp = Blueprint('user', __name__)
log = Logger(__name__)
@ -185,10 +186,6 @@ async def _try_discrim_patch(user_id, new_discrim: str):
})
def to_update(j: dict, user: dict, field: str):
return field in j and j[field] and j[field] != user[field]
async def _check_pass(j, user):
# Do not do password checks on unclaimed accounts
if user['email'] is None:

View File

@ -18,12 +18,16 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
import inspect
from typing import List, Any
from enum import Enum, IntEnum
class EasyEnum(Enum):
"""Wrapper around the enum class for convenience."""
@classmethod
def values(cls):
def values(cls) -> List[Any]:
"""Return list of values for the given enum."""
return [v.value for v in cls.__members__.values()]
@ -197,12 +201,27 @@ class RelationshipType(EasyEnum):
class MessageNotifications(EasyEnum):
"""Message notifications"""
ALL = 0
MENTIONS = 1
NOTHING = 2
class PremiumType:
"""Premium (Nitro) type."""
TIER_1 = 1
TIER_2 = 2
NONE = None
class Feature(EasyEnum):
"""Guild features."""
invite_splash = 'INVITE_SPLASH'
vip = 'VIP_REGIONS'
vanity = 'VANITY_URL'
emoji = 'MORE_EMOJI'
verified = 'VERIFIED'
# unknown
commerce = 'COMMERCE'
news = 'NEWS'

View File

@ -74,7 +74,16 @@ def decode_json(data: str):
def encode_etf(payload) -> str:
return earl.pack(payload)
# The thing with encoding ETF is that with json we have LitecordJSONEncoder
# which takes care of converting e.g datetime objects to their ISO
# representation.
# so, to keep things working, i'll to a json pass on the payload, then send
# the decoded payload back to earl.
sanitized = encode_json(payload)
sanitized = decode_json(sanitized)
return earl.pack(sanitized)
def _etf_decode_dict(data):

View File

@ -46,6 +46,7 @@ EXTENSIONS = {
MIMES = {
'jpg': 'image/jpeg',
'jpe': 'image/jpeg',
'jpeg': 'image/jpeg',
'webp': 'image/webp',
}
@ -171,15 +172,23 @@ def parse_data_uri(string) -> tuple:
def _gen_update_sql(scope: str) -> str:
# match a scope to (table, field)
field = {
'user': 'avatar',
'guild': 'icon',
'splash': 'splash',
'banner': 'banner',
'channel-icons': 'icon',
}[scope]
table = {
'user': 'users',
'guild': 'guilds',
'splash': 'guilds',
'banner': 'guilds',
'channel-icons': 'group_dm_channels'
}[scope]
@ -269,6 +278,8 @@ class IconManager:
self.storage = app.storage
async def _convert_ext(self, icon: Icon, target: str):
target = 'jpeg' if target == 'jpg' else target
target_mime = get_mime(target)
log.info('converting from {} to {}', icon.mime, target_mime)
@ -279,6 +290,10 @@ class IconManager:
image = Image.open(icon.as_path)
target_fd = target_path.open('wb')
if target == 'jpeg':
image = image.convert('RGB')
image.save(target_fd, format=target)
target_fd.close()
@ -386,6 +401,9 @@ class IconManager:
if scope == 'user' and mime == 'image/gif':
icon_hash = f'a_{icon_hash}'
log.debug('PUT icon {!r} {!r} {!r} {!r}',
scope, key, icon_hash, mime)
await self.storage.db.execute("""
INSERT INTO icons (scope, key, hash, mime)
VALUES ($1, $2, $3, $4)
@ -406,6 +424,9 @@ class IconManager:
if not icon:
return
log.debug('DEL {}',
icon)
# dereference
await self.storage.db.execute("""
UPDATE users
@ -430,6 +451,18 @@ class IconManager:
WHERE icon = $1
""", icon.icon_hash)
await self.storage.db.execute("""
UPDATE guilds
SET splash = NULL
WHERE splash = $1
""", icon.icon_hash)
await self.storage.db.execute("""
UPDATE guilds
SET banner = NULL
WHERE banner = $1
""", icon.icon_hash)
await self.storage.db.execute("""
UPDATE group_dm_channels
SET icon = NULL
@ -455,7 +488,11 @@ class IconManager:
old_icon_hash = await self.storage.db.fetchval(
_gen_update_sql(scope), key)
# converting key to str only here since from here onwards
# its operations on the icons table (or a dereference with
# the delete() method but that will work regardless)
key = str(key)
old_icon = await self.generic_get(scope, key, old_icon_hash)
await self.delete(old_icon)

View File

@ -175,7 +175,12 @@ REGISTER = {
'username': {'type': 'username', 'required': True},
'email': {'type': 'email', 'required': False},
'password': {'type': 'string', 'minlength': 5, 'required': False},
'invite': {'type': 'string', 'required': False, 'nullable': True}, # optional
# invite stands for a guild invite, not an instance invite (that's on
# the register_with_invite handler).
'invite': {'type': 'string', 'required': False, 'nullable': True},
# following fields only sent by official client
'fingerprint': {'type': 'string', 'required': False, 'nullable': True}, # these are sent by official client
'captcha_key': {'type': 'string', 'required': False, 'nullable': True},
'consent': {'type': 'boolean'},
@ -273,8 +278,19 @@ GUILD_UPDATE = {
'required': False
},
'region': {'type': 'voice_region', 'required': False, 'nullable': True},
'icon': {'type': 'b64_icon', 'required': False, 'nullable': True},
'splash': {'type': 'b64_icon', 'required': False, 'nullable': True},
# TODO: does splash also respect when its just a string pointing to the
# hash, just like in USER_UPDATE.avatar?
'banner': {'type': 'string', 'required': False, 'nullable': True},
'splash': {'type': 'string', 'required': False, 'nullable': True},
'description': {
'type': 'string', 'required': False,
'minlength': 1, 'maxlength': 120,
'nullable': True
},
'verification_level': {
'type': 'verification_level', 'required': False},
@ -655,3 +671,9 @@ GET_MENTIONS = {
'everyone': {'coerce': bool, 'default': True},
'guild_id': {'coerce': int, 'required': False}
}
VANITY_URL_PATCH = {
# TODO: put proper values in maybe an invite data type
'code': {'type': 'string', 'minlength': 5, 'maxlength': 30}
}

View File

@ -146,6 +146,13 @@ class Storage:
WHERE username = $1 AND discriminator = $2
""", username, discriminator)
async def guild_features(self, guild_id: int) -> Optional[List[str]]:
"""Get a list of guild features for the given guild."""
return await self.db.fetchval("""
SELECT features FROM guilds
WHERE id = $1
""", guild_id)
async def get_guild(self, guild_id: int, user_id=None) -> Optional[Dict]:
"""Get gulid payload."""
row = await self.db.fetchrow("""
@ -155,7 +162,8 @@ class Storage:
explicit_content_filter, mfa_level,
embed_enabled, embed_channel_id::text,
widget_enabled, widget_channel_id::text,
system_channel_id::text
system_channel_id::text, features,
banner, description
FROM guilds
WHERE guilds.id = $1
""", guild_id)
@ -626,7 +634,7 @@ class Storage:
'voice_states': await self.guild_voice_states(guild_id),
}}
async def get_guild_full(self, guild_id: int, user_id: int,
async def get_guild_full(self, guild_id: int, user_id: Optional[int] = None,
large_count: int = 250) -> Optional[Dict]:
"""Get full information on a guild.
@ -911,16 +919,14 @@ class Storage:
# fetch some guild info
guild = await self.db.fetchrow("""
SELECT id::text, name, splash, icon, verification_level
SELECT id::text, name, icon, splash, banner, features,
verification_level, description
FROM guilds
WHERE id = $1
""", invite['guild_id'])
if guild:
dinv['guild'] = dict(guild)
# TODO: query actual guild features
dinv['guild']['features'] = []
else:
dinv['guild'] = {}
@ -1072,3 +1078,12 @@ class Storage:
""")
return list(map(dict, rows))
async def has_feature(self, guild_id: int, feature: str) -> bool:
"""Return if a certain guild has a certain feature."""
features = await self.db.fetchval("""
SELECT features FROM guilds
WHERE id = $1
""", guild_id)
return feature.upper() in features

View File

@ -17,6 +17,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
from typing import Optional
# size units
KILOBYTES = 1024
@ -45,5 +47,6 @@ class Color:
return self.value
def timestamp_(dt):
def timestamp_(dt) -> Optional[str]:
"""safer version for dt.isoformat()"""
return f'{dt.isoformat()}+00:00' if dt else None

View File

@ -174,3 +174,8 @@ def yield_chunks(input_list: Sequence[Any], chunk_size: int):
# make the chunks
for idx in range(0, len(input_list), chunk_size):
yield input_list[idx:idx + chunk_size]
def to_update(j: dict, orig: dict, field: str) -> bool:
"""Compare values to check if j[field] is actually updating
the value in orig[field]. Useful for icon checks."""
return field in j and j[field] and j[field] != orig[field]

View File

@ -19,6 +19,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
from typing import Optional
from collections import defaultdict
from dataclasses import dataclass
from logbook import Logger
@ -26,6 +27,14 @@ from litecord.voice.lvsp_conn import LVSPConnection
log = Logger(__name__)
@dataclass
class Region:
"""Voice region data."""
id: str
vip: bool
class LVSPManager:
"""Manager class for Litecord Voice Server Protocol (LVSP) connections.
@ -44,46 +53,52 @@ class LVSPManager:
# maps Union[GuildID, DMId, GroupDMId] to server hostnames
self.assign = {}
# quick storage for Region dataclass instances.
self._regions = {}
self.app.loop.create_task(self._spawn())
async def _spawn(self):
"""Spawn LVSPConnection for each region."""
regions = await self.app.db.fetch("""
SELECT id
SELECT id, vip
FROM voice_regions
WHERE deprecated = false
""")
regions = [r['id'] for r in regions]
regions = [Region(r['id'], r['vip']) for r in regions]
if not regions:
log.warning('no regions are setup')
return
for region in regions:
# store it locally for region() function
self._regions[region.id] = region
self.app.loop.create_task(
self._spawn_region(region)
)
async def _spawn_region(self, region: str):
async def _spawn_region(self, region: Region):
"""Spawn a region. Involves fetching all the hostnames
for the regions and spawning a LVSPConnection for each."""
servers = await self.app.db.fetch("""
SELECT hostname
FROM voice_servers
WHERE region_id = $1
""", region)
""", region.id)
if not servers:
log.warning('region {} does not have servers', region)
return
servers = [r['hostname'] for r in servers]
self.servers[region] = servers
self.servers[region.id] = servers
for hostname in servers:
conn = LVSPConnection(self, region, hostname)
conn = LVSPConnection(self, region.id, hostname)
self.conns[hostname] = conn
self.app.loop.create_task(
@ -144,3 +159,7 @@ class LVSPManager:
async def assign_conn(self, key: int, hostname: str):
"""Assign a connection to a given key in the assign map"""
self.assign[key] = hostname
def region(self, region_id: str) -> Optional[Region]:
"""Get a :class:`Region` instance:wq:wq"""
return self._regions.get(region_id)

View File

@ -0,0 +1,5 @@
DROP TABLE guild_features;
DROP TABLE features;
-- this should do the trick
ALTER TABLE guilds ADD COLUMN features text[] NOT NULL DEFAULT '{}';

View File

@ -0,0 +1,5 @@
-- vanity url table, the mapping is 1-1 for guilds and vanity urls
CREATE TABLE IF NOT EXISTS vanity_invites (
guild_id bigint REFERENCES guilds (id) PRIMARY KEY,
code text REFERENCES invites (code) ON DELETE CASCADE
);

View File

@ -0,0 +1,2 @@
ALTER TABLE guilds ADD COLUMN description text DEFAULT NULL;
ALTER TABLE guilds ADD COLUMN banner text DEFAULT NULL;

7
run.py
View File

@ -57,7 +57,8 @@ from litecord.blueprints.user import (
from litecord.blueprints.user.billing_job import payment_job
from litecord.blueprints.admin_api import (
voice as voice_admin
voice as voice_admin, features as features_admin,
guilds as guilds_admin
)
from litecord.blueprints.admin_api.voice import guild_region_check
@ -143,7 +144,9 @@ def set_blueprints(app_):
nodeinfo: -1,
static: -1,
voice_admin: '/admin/voice'
voice_admin: '/admin/voice',
features_admin: '/admin/guilds',
guilds_admin: '/admin/guilds'
}
for bp, suffix in bps.items():

View File

@ -362,12 +362,13 @@ CREATE TABLE IF NOT EXISTS guilds (
region text NOT NULL REFERENCES voice_regions (id),
/* default no afk channel
afk channel is voice-only.
*/
features text[],
-- default no afk channel
-- afk channel is voice-only.
afk_channel_id bigint REFERENCES channels (id) DEFAULT NULL,
/* default 5 minutes */
-- default 5 minutes
afk_timeout int DEFAULT 300,
-- from 0 to 4
@ -482,20 +483,6 @@ CREATE TABLE IF NOT EXISTS group_dm_members (
);
CREATE TABLE IF NOT EXISTS features (
id serial PRIMARY KEY,
feature text NOT NULL
);
CREATE TABLE IF NOT EXISTS guild_features (
guild_id bigint REFERENCES guilds (id) ON DELETE CASCADE,
feature integer REFERENCES features (id),
PRIMARY KEY (guild_id, feature)
);
CREATE TABLE IF NOT EXISTS guild_integrations (
guild_id bigint REFERENCES guilds (id) ON DELETE CASCADE,
user_id bigint REFERENCES users (id) ON DELETE CASCADE,
@ -540,6 +527,12 @@ CREATE TABLE IF NOT EXISTS invites (
revoked bool DEFAULT false
);
-- vanity url table, the mapping is 1-1 for guilds and vanity urls
CREATE TABLE IF NOT EXISTS vanity_invites (
guild_id bigint REFERENCES guilds (id) PRIMARY KEY,
code text REFERENCES invites (code) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS webhooks (
id bigint PRIMARY KEY,