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
This commit is contained in:
Luna Mendes 2018-10-02 03:43:57 -03:00
parent b091bd5c49
commit c7923da124
8 changed files with 281 additions and 13 deletions

View File

@ -7,3 +7,4 @@ from .webhooks import bp as webhooks
from .science import bp as science from .science import bp as science
from .voice import bp as voice from .voice import bp as voice
from .invites import bp as invites from .invites import bp as invites
from .relationships import bp as relationships

View File

@ -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/<int:peer_id>', 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/<int:peer_id>', 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

View File

@ -77,3 +77,10 @@ class ExplicitFilter(EasyEnum):
EDGE = 0 EDGE = 0
FRIENDS = 1 FRIENDS = 1
SAFE = 2 SAFE = 2
class RelationshipType(EasyEnum):
FRIEND = 1
BLOCK = 2
INCOMING = 3
OUTGOING = 4

View File

@ -206,11 +206,9 @@ class GatewayWebsocket:
return { return {
'user_settings': await self.storage.get_user_settings(user_id), 'user_settings': await self.storage.get_user_settings(user_id),
'notes': await self.storage.fetch_notes(user_id), 'notes': await self.storage.fetch_notes(user_id),
'relationships': await self.storage.get_relationships(user_id),
'friend_suggestion_count': 0, 'friend_suggestion_count': 0,
# TODO
'relationships': [],
# TODO # TODO
'user_guild_settings': [], 'user_guild_settings': [],

View File

@ -4,7 +4,7 @@ from cerberus import Validator
from logbook import Logger from logbook import Logger
from .errors import BadRequest from .errors import BadRequest
from .enums import ActivityType, StatusType, ExplicitFilter from .enums import ActivityType, StatusType, ExplicitFilter, RelationshipType
log = Logger(__name__) log = Logger(__name__)
@ -57,20 +57,29 @@ class LitecordValidator(Validator):
return val in ExplicitFilter.values() 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): def validate(reqjson, schema, raise_err: bool = True):
validator = LitecordValidator(schema) validator = LitecordValidator(schema)
if not validator.validate(reqjson): if not validator.validate(reqjson):
errs = validator.errors errs = validator.errors
log.warning('Error validating doc: {!r}', errs) log.warning('Error validating doc {!r}: {!r}', reqjson, errs)
if raise_err: if raise_err:
raise BadRequest('bad payload', errs) raise BadRequest('bad payload', errs)
return None return None
return reqjson return validator.document
GUILD_UPDATE = { GUILD_UPDATE = {
@ -262,3 +271,11 @@ USER_SETTINGS = {
'timezone_offset': {'type': 'number', 'required': False}, 'timezone_offset': {'type': 'number', 'required': False},
} }
RELATIONSHIP = {
'type': {
'type': 'rel_type',
'required': False,
'default': RelationshipType.FRIEND.value
}
}

View File

@ -3,7 +3,7 @@ from typing import List, Dict, Any
from logbook import Logger from logbook import Logger
from .enums import ChannelType from .enums import ChannelType, RelationshipType
from .schemas import USER_MENTION, ROLE_MENTION from .schemas import USER_MENTION, ROLE_MENTION
@ -61,6 +61,7 @@ class Storage:
"""Get a single user payload.""" """Get a single user payload."""
user_id = int(user_id) user_id = int(user_id)
# TODO: query less instead of popping when secure=True
user_row = await self.db.fetchrow(""" user_row = await self.db.fetchrow("""
SELECT id::text, username, discriminator, avatar, email, SELECT id::text, username, discriminator, avatar, email,
flags, bot, mfa_enabled, verified, premium flags, bot, mfa_enabled, verified, premium
@ -463,7 +464,7 @@ class Storage:
SELECT guild_id SELECT guild_id
FROM guild_channels FROM guild_channels
WHERE guild_channels.id = $1 WHERE guild_channels.id = $1
""", res['channel_id']) """, int(res['channel_id']))
# only insert when the channel # only insert when the channel
# is actually from a guild. # is actually from a guild.
@ -581,3 +582,96 @@ class Storage:
drow = dict(row) drow = dict(row)
drow.pop('id') drow.pop('id')
return drow 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

5
run.py
View File

@ -11,7 +11,7 @@ from logbook.compat import redirect_logging
import config import config
from litecord.blueprints import gateway, auth, users, guilds, channels, \ 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.gateway import websocket_handler
from litecord.errors import LitecordError from litecord.errors import LitecordError
from litecord.gateway.state_manager import StateManager from litecord.gateway.state_manager import StateManager
@ -49,12 +49,13 @@ bps = {
gateway: None, gateway: None,
auth: '/auth', auth: '/auth',
users: '/users', users: '/users',
relationships: '/users',
guilds: '/guilds', guilds: '/guilds',
channels: '/channels', channels: '/channels',
webhooks: None, webhooks: None,
science: None, science: None,
voice: '/voice', voice: '/voice',
invites: None invites: None,
} }
for bp, suffix in bps.items(): for bp, suffix in bps.items():

View File

@ -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 ( CREATE TABLE IF NOT EXISTS notes (
user_id bigint REFERENCES users (id), user_id bigint REFERENCES users (id),
target_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, channel_id bigint REFERENCES channels (id) ON DELETE CASCADE,
inviter bigint REFERENCES users (id), 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, uses bigint DEFAULT 0,
-- -1 means infinite here -- -1 means infinite here
@ -333,7 +348,7 @@ CREATE TABLE IF NOT EXISTS members (
user_id bigint REFERENCES users (id) ON DELETE CASCADE, user_id bigint REFERENCES users (id) ON DELETE CASCADE,
guild_id bigint REFERENCES guilds (id) ON DELETE CASCADE, guild_id bigint REFERENCES guilds (id) ON DELETE CASCADE,
nickname text DEFAULT NULL, 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, deafened boolean DEFAULT false,
muted boolean DEFAULT false, muted boolean DEFAULT false,
PRIMARY KEY (user_id, guild_id) PRIMARY KEY (user_id, guild_id)
@ -404,7 +419,7 @@ CREATE TABLE IF NOT EXISTS messages (
content text, 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, edited_at timestamp without time zone default NULL,
tts bool default false, tts bool default false,