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,