From c7923da1245edd1fde7d71168d5e34519849347f Mon Sep 17 00:00:00 2001 From: Luna Mendes Date: Tue, 2 Oct 2018 03:43:57 -0300 Subject: [PATCH] relationship support! friendships and blocks are possible, however presence code isn't ready to handle presences of people who are friends. SQL for instances, this is going to fix bad timestamps on the messages: ```sql ALTER TABLE ONLY members ALTER COLUMN joined_at SET DEFAULT (now() at time zone 'utc'); ALTER TABLE ONLY messages ALTER COLUMN created_at SET DEFAULT (now() at time zone 'utc'); ALTER TABLE ONLY invites ALTER COLUMN created_at SET DEFAULT (now() at time zone 'utc'); ``` After that, rerun the schema.sql file to have the new relationships table. - blueprints: add relationships blueprint - enums: add RelationshipType - storage: add get_relationships - storage: fix bug on lazy guild changes and messages - schemas: return validator.document instead of reqjson - gateway.websocket: use Storage.get_relationships --- litecord/blueprints/__init__.py | 1 + litecord/blueprints/relationships.py | 135 +++++++++++++++++++++++++++ litecord/enums.py | 7 ++ litecord/gateway/websocket.py | 4 +- litecord/schemas.py | 23 ++++- litecord/storage.py | 98 ++++++++++++++++++- run.py | 5 +- schema.sql | 21 ++++- 8 files changed, 281 insertions(+), 13 deletions(-) create mode 100644 litecord/blueprints/relationships.py diff --git a/litecord/blueprints/__init__.py b/litecord/blueprints/__init__.py index 557f326..10a104b 100644 --- a/litecord/blueprints/__init__.py +++ b/litecord/blueprints/__init__.py @@ -7,3 +7,4 @@ from .webhooks import bp as webhooks from .science import bp as science from .voice import bp as voice from .invites import bp as invites +from .relationships import bp as relationships diff --git a/litecord/blueprints/relationships.py b/litecord/blueprints/relationships.py new file mode 100644 index 0000000..befc702 --- /dev/null +++ b/litecord/blueprints/relationships.py @@ -0,0 +1,135 @@ +from quart import Blueprint, jsonify, request, current_app as app +from asyncpg import UniqueViolationError + +from ..auth import token_check +from ..schemas import validate, RELATIONSHIP +from ..enums import RelationshipType + + +bp = Blueprint('relationship', __name__) + + +@bp.route('/@me/relationships', methods=['GET']) +async def get_me_relationships(): + user_id = await token_check() + return jsonify(await app.storage.get_relationships(user_id)) + + +@bp.route('/@me/relationships/', methods=['PUT']) +async def add_relationship(peer_id: int): + """Add a relationship to the peer.""" + user_id = await token_check() + payload = validate(await request.get_json(), RELATIONSHIP) + rel_type = payload['type'] + _friend = RelationshipType.FRIEND.value + + await app.db.execute(""" + INSERT INTO relationships (user_id, peer_id, rel_type) + VALUES ($1, $2, $3) + """, user_id, peer_id, rel_type) + + # check if this is an acceptance + # of a friend request + existing = await app.db.fetchrow(""" + SELECT user_id, peer_id + FROM relationships + WHERE user_id = $1 AND peer_id = $2 AND rel_type = $3 + """, peer_id, user_id, _friend) + + _dispatch = app.dispatcher.dispatch_user + + if existing: + # accepted a friend request, dispatch respective + # relationship events + await _dispatch(user_id, 'RELATIONSHIP_REMOVE', { + 'type': RelationshipType.INCOMING.value, + 'id': str(peer_id) + }) + + await _dispatch(user_id, 'RELATIONSHIP_ADD', { + 'type': _friend, + 'id': str(peer_id), + 'user': await app.storage.get_user(peer_id) + }) + + await _dispatch(peer_id, 'RELATIONSHIP_ADD', { + 'type': _friend, + 'id': str(user_id), + 'user': await app.storage.get_user(user_id) + }) + + return '', 204 + + # is a blocky! + await app.dispatcher.dispatch_user(user_id, 'RELATIONSHIP_ADD', { + 'id': str(peer_id), + 'type': RelationshipType.BLOCK.value, + 'user': await app.storage.get_user(peer_id) + }) + + return '', 204 + + +@bp.route('/@me/relationships/', methods=['DELETE']) +async def remove_relationship(peer_id: int): + """Remove an existing relationship""" + user_id = await token_check() + _friend = RelationshipType.FRIEND.value + _block = RelationshipType.BLOCK.value + _dispatch = app.dispatcher.dispatch_user + + rel_type = await app.db.fetchval(""" + SELECT rel_type + FROM relationships + WHERE user_id = $1 AND peer_id = $2 + """, user_id, peer_id) + + incoming_rel_type = await app.db.fetchval(""" + SELECT rel_type + FROM relationships + WHERE user_id = $1 AND peer_id = $2 + """, peer_id, user_id) + + if rel_type == _friend: + # closing the friendship, have to delete both rows + await app.db.execute(""" + DELETE FROM relationships + WHERE ( + (user_id = $1 AND peer_id = $2) OR + (user_id = $2 AND peer_id = $1) + ) AND rel_type = $3 + """, user_id, peer_id, _friend) + + # if there wasnt any mutual friendship before, + # assume they were requests of INCOMING + # and OUTGOING. + user_del_type = RelationshipType.OUTGOING.value if \ + incoming_rel_type != _friend else _friend + + await _dispatch(user_id, 'RELATIONSHIP_REMOVE', { + 'id': str(peer_id), + 'type': user_del_type, + }) + + peer_del_type = RelationshipType.INCOMING.value if \ + incoming_rel_type != _friend else _friend + + await _dispatch(peer_id, 'RELATIONSHIP_REMOVE', { + 'id': str(user_id), + 'type': peer_del_type, + }) + + return '', 204 + + # was a block! + await app.db.execute(""" + DELETE FROM relationships + WHERE user_id = $1 AND peer_id = $2 AND rel_type = $3 + """, user_id, peer_id, _block) + + await _dispatch(user_id, 'RELATIONSHIP_REMOVE', { + 'id': str(peer_id), + 'type': _block, + }) + + return '', 204 diff --git a/litecord/enums.py b/litecord/enums.py index 1fdf584..2b26467 100644 --- a/litecord/enums.py +++ b/litecord/enums.py @@ -77,3 +77,10 @@ class ExplicitFilter(EasyEnum): EDGE = 0 FRIENDS = 1 SAFE = 2 + + +class RelationshipType(EasyEnum): + FRIEND = 1 + BLOCK = 2 + INCOMING = 3 + OUTGOING = 4 diff --git a/litecord/gateway/websocket.py b/litecord/gateway/websocket.py index 9ffa57f..339964b 100644 --- a/litecord/gateway/websocket.py +++ b/litecord/gateway/websocket.py @@ -206,11 +206,9 @@ class GatewayWebsocket: return { 'user_settings': await self.storage.get_user_settings(user_id), 'notes': await self.storage.fetch_notes(user_id), + 'relationships': await self.storage.get_relationships(user_id), 'friend_suggestion_count': 0, - # TODO - 'relationships': [], - # TODO 'user_guild_settings': [], diff --git a/litecord/schemas.py b/litecord/schemas.py index 1d54ee4..cd89dca 100644 --- a/litecord/schemas.py +++ b/litecord/schemas.py @@ -4,7 +4,7 @@ from cerberus import Validator from logbook import Logger from .errors import BadRequest -from .enums import ActivityType, StatusType, ExplicitFilter +from .enums import ActivityType, StatusType, ExplicitFilter, RelationshipType log = Logger(__name__) @@ -57,20 +57,29 @@ class LitecordValidator(Validator): return val in ExplicitFilter.values() + def _validate_type_rel_type(self, value: str) -> bool: + try: + val = int(value) + except (TypeError, ValueError): + return False + + return val in (RelationshipType.FRIEND.value, + RelationshipType.BLOCK.value) + def validate(reqjson, schema, raise_err: bool = True): validator = LitecordValidator(schema) if not validator.validate(reqjson): errs = validator.errors - log.warning('Error validating doc: {!r}', errs) + log.warning('Error validating doc {!r}: {!r}', reqjson, errs) if raise_err: raise BadRequest('bad payload', errs) return None - return reqjson + return validator.document GUILD_UPDATE = { @@ -262,3 +271,11 @@ USER_SETTINGS = { 'timezone_offset': {'type': 'number', 'required': False}, } + +RELATIONSHIP = { + 'type': { + 'type': 'rel_type', + 'required': False, + 'default': RelationshipType.FRIEND.value + } +} diff --git a/litecord/storage.py b/litecord/storage.py index 648ddc1..d1b975c 100644 --- a/litecord/storage.py +++ b/litecord/storage.py @@ -3,7 +3,7 @@ from typing import List, Dict, Any from logbook import Logger -from .enums import ChannelType +from .enums import ChannelType, RelationshipType from .schemas import USER_MENTION, ROLE_MENTION @@ -61,6 +61,7 @@ class Storage: """Get a single user payload.""" user_id = int(user_id) + # TODO: query less instead of popping when secure=True user_row = await self.db.fetchrow(""" SELECT id::text, username, discriminator, avatar, email, flags, bot, mfa_enabled, verified, premium @@ -463,7 +464,7 @@ class Storage: SELECT guild_id FROM guild_channels WHERE guild_channels.id = $1 - """, res['channel_id']) + """, int(res['channel_id'])) # only insert when the channel # is actually from a guild. @@ -581,3 +582,96 @@ class Storage: drow = dict(row) drow.pop('id') return drow + + async def get_relationships(self, user_id: int) -> List[Dict[str, Any]]: + """Get all relationships for a user.""" + # first, fetch all friendships outgoing + # from the user + _friend = RelationshipType.FRIEND.value + _block = RelationshipType.BLOCK.value + _incoming = RelationshipType.INCOMING.value + _outgoing = RelationshipType.OUTGOING.value + + # check all outgoing friends + friends = await self.db.fetch(""" + SELECT user_id, peer_id, rel_type + FROM relationships + WHERE user_id = $1 AND rel_type = $2 + """, user_id, _friend) + friends = list(map(dict, friends)) + + # mutuals is a list of ints + # of people who are actually friends + # and accepted the friend request + mutuals = [] + + # for each outgoing, find if theres an outgoing from them + for row in friends: + is_friend = await self.db.fetchrow( + """ + SELECT user_id, peer_id + FROM relationships + WHERE user_id = $1 AND peer_id = $2 AND rel_type = $3 + """, row['peer_id'], row['user_id'], + _friend) + + if is_friend is not None: + mutuals.append(row['peer_id']) + + # fetch friend requests directed at us + incoming_friends = await self.db.fetch(""" + SELECT user_id, peer_id + FROM relationships + WHERE peer_id = $1 AND rel_type = $2 + """, user_id, _friend) + + # only need their ids + incoming_friends = [r['user_id'] for r in incoming_friends + if r['user_id'] not in mutuals] + + # only fetch blocks we did, + # not fetching the ones people did to us + blocks = await self.db.fetch(""" + SELECT user_id, peer_id + FROM relationships + WHERE user_id = $1 AND rel_type = $2 + """, user_id, _block) + blocks = list(map(dict, blocks)) + + res = [] + + for drow in friends: + drow['type'] = drow['rel_type'] + drow.pop('rel_type') + + # check if the receiver is a mutual + # if it isnt, its still on a friend request stage + if drow['peer_id'] not in mutuals: + drow['id'] = str(drow['peer_id']) + drow['type'] = _outgoing + + drow['user'] = await self.get_user(drow['peer_id']) + + drow.pop('user_id') + drow.pop('peer_id') + res.append(drow) + + for peer_id in incoming_friends: + res.append({ + 'id': str(peer_id), + 'user': await self.get_user(peer_id), + 'type': _incoming, + }) + + for drow in blocks: + drow['type'] = drow['rel_type'] + drow.pop('rel_type') + + drow['id'] = str(drow['peer_id']) + drow['user'] = await self.get_user(drow['peer_id']) + + drow.pop('user_id') + drow.pop('peer_id') + res.append(drow) + + return res diff --git a/run.py b/run.py index f3d09a3..45209bb 100644 --- a/run.py +++ b/run.py @@ -11,7 +11,7 @@ from logbook.compat import redirect_logging import config from litecord.blueprints import gateway, auth, users, guilds, channels, \ - webhooks, science, voice, invites + webhooks, science, voice, invites, relationships from litecord.gateway import websocket_handler from litecord.errors import LitecordError from litecord.gateway.state_manager import StateManager @@ -49,12 +49,13 @@ bps = { gateway: None, auth: '/auth', users: '/users', + relationships: '/users', guilds: '/guilds', channels: '/channels', webhooks: None, science: None, voice: '/voice', - invites: None + invites: None, } for bp, suffix in bps.items(): diff --git a/schema.sql b/schema.sql index 68a9273..6ae2f67 100644 --- a/schema.sql +++ b/schema.sql @@ -143,6 +143,21 @@ CREATE TABLE IF NOT EXISTS user_settings ( ); +-- main user relationships +CREATE TABLE IF NOT EXISTS relationships ( + -- the id of who made the relationship + user_id bigint REFERENCES users (id), + + -- the id of the peer who got a friendship + -- request or a block. + peer_id bigint REFERENCES users (id), + + rel_type SMALLINT, + + PRIMARY KEY (user_id, peer_id) +); + + CREATE TABLE IF NOT EXISTS notes ( user_id bigint REFERENCES users (id), target_id bigint REFERENCES users (id), @@ -299,7 +314,7 @@ CREATE TABLE IF NOT EXISTS invites ( channel_id bigint REFERENCES channels (id) ON DELETE CASCADE, inviter bigint REFERENCES users (id), - created_at timestamp without time zone default now(), + created_at timestamp without time zone default (now() at time zone 'utc'), uses bigint DEFAULT 0, -- -1 means infinite here @@ -333,7 +348,7 @@ CREATE TABLE IF NOT EXISTS members ( user_id bigint REFERENCES users (id) ON DELETE CASCADE, guild_id bigint REFERENCES guilds (id) ON DELETE CASCADE, nickname text DEFAULT NULL, - joined_at timestamp without time zone default now(), + joined_at timestamp without time zone default (now() at time zone 'utc'), deafened boolean DEFAULT false, muted boolean DEFAULT false, PRIMARY KEY (user_id, guild_id) @@ -404,7 +419,7 @@ CREATE TABLE IF NOT EXISTS messages ( content text, - created_at timestamp without time zone default now(), + created_at timestamp without time zone default (now() at time zone 'utc'), edited_at timestamp without time zone default NULL, tts bool default false,