Merge pull request #3051 from lbryio/comments-v2

Comments v2 with Reactions
This commit is contained in:
Lex Berezhny 2020-09-28 14:44:54 -04:00 committed by GitHub
commit 43c2e8d8e9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 363 additions and 34 deletions

View file

@ -611,7 +611,7 @@ class Config(CLIConfig):
('lbrynet4.lbry.com', 4444) # ASIA ('lbrynet4.lbry.com', 4444) # ASIA
]) ])
comment_server = String("Comment server API URL", "https://comments.lbry.com/api") comment_server = String("Comment server API URL", "https://comments.lbry.com/api/v2")
# blockchain # blockchain
blockchain_name = String("Blockchain name - lbrycrd_main, lbrycrd_regtest, or lbrycrd_testnet", 'lbrycrd_main') blockchain_name = String("Blockchain name - lbrycrd_main, lbrycrd_regtest, or lbrycrd_testnet", 'lbrycrd_main')

View file

@ -52,11 +52,21 @@ def sign_comment(comment: dict, channel: Output, abandon=False):
'signing_ts': timestamp 'signing_ts': timestamp
}) })
def sign_reaction(reaction: dict, channel: Output):
timestamp = str(int(time.time()))
signing_field = reaction['channel_name']
pieces = [timestamp.encode(), channel.claim_hash, signing_field.encode()]
digest = sha256(b''.join(pieces))
signature = channel.private_key.sign_digest_deterministic(digest, hashfunc=hashlib.sha256)
reaction.update({
'signature': binascii.hexlify(signature).decode(),
'signing_ts': timestamp
})
async def jsonrpc_post(url: str, method: str, params: dict = None, **kwargs) -> any: async def jsonrpc_post(url: str, method: str, params: dict = None, **kwargs) -> any:
params = params or {} params = params or {}
params.update(kwargs) params.update(kwargs)
json_body = {'jsonrpc': '2.0', 'id': None, 'method': method, 'params': params} json_body = {'jsonrpc': '2.0', 'id': 1, 'method': method, 'params': params}
async with utils.aiohttp_request('POST', url, json=json_body) as response: async with utils.aiohttp_request('POST', url, json=json_body) as response:
try: try:
result = await response.json() result = await response.json()

View file

@ -5048,8 +5048,9 @@ class Daemon(metaclass=JSONRPCServerType):
if hidden ^ visible: if hidden ^ visible:
result = await comment_client.jsonrpc_post( result = await comment_client.jsonrpc_post(
self.conf.comment_server, self.conf.comment_server,
'get_claim_hidden_comments', 'comment.List',
claim_id=claim_id, claim_id=claim_id,
visible=visible,
hidden=hidden, hidden=hidden,
page=page, page=page,
page_size=page_size page_size=page_size
@ -5057,7 +5058,7 @@ class Daemon(metaclass=JSONRPCServerType):
else: else:
result = await comment_client.jsonrpc_post( result = await comment_client.jsonrpc_post(
self.conf.comment_server, self.conf.comment_server,
'get_claim_comments', 'comment.List',
claim_id=claim_id, claim_id=claim_id,
parent_id=parent_id, parent_id=parent_id,
page=page, page=page,
@ -5132,7 +5133,7 @@ class Daemon(metaclass=JSONRPCServerType):
} }
comment_client.sign_comment(comment_body, channel) comment_client.sign_comment(comment_body, channel)
response = await comment_client.jsonrpc_post(self.conf.comment_server, 'create_comment', comment_body) response = await comment_client.jsonrpc_post(self.conf.comment_server, 'comment.Create', comment_body)
response.update({ response.update({
'is_claim_signature_valid': comment_client.is_comment_signed_by_channel(response, channel) 'is_claim_signature_valid': comment_client.is_comment_signed_by_channel(response, channel)
}) })
@ -5169,7 +5170,7 @@ class Daemon(metaclass=JSONRPCServerType):
""" """
channel = await comment_client.jsonrpc_post( channel = await comment_client.jsonrpc_post(
self.conf.comment_server, self.conf.comment_server,
'get_channel_from_comment_id', 'comment.GetChannelFromCommentID',
comment_id=comment_id comment_id=comment_id
) )
if 'error' in channel: if 'error' in channel:
@ -5186,7 +5187,7 @@ class Daemon(metaclass=JSONRPCServerType):
} }
comment_client.sign_comment(edited_comment, channel_claim) comment_client.sign_comment(edited_comment, channel_claim)
return await comment_client.jsonrpc_post( return await comment_client.jsonrpc_post(
self.conf.comment_server, 'edit_comment', edited_comment self.conf.comment_server, 'comment.Edit', edited_comment
) )
@requires(WALLET_COMPONENT) @requires(WALLET_COMPONENT)
@ -5212,7 +5213,7 @@ class Daemon(metaclass=JSONRPCServerType):
wallet = self.wallet_manager.get_wallet_or_default(wallet_id) wallet = self.wallet_manager.get_wallet_or_default(wallet_id)
abandon_comment_body = {'comment_id': comment_id} abandon_comment_body = {'comment_id': comment_id}
channel = await comment_client.jsonrpc_post( channel = await comment_client.jsonrpc_post(
self.conf.comment_server, 'get_channel_from_comment_id', comment_id=comment_id self.conf.comment_server, 'comment.GetChannelFromCommentID', comment_id=comment_id
) )
if 'error' in channel: if 'error' in channel:
return {comment_id: {'abandoned': False}} return {comment_id: {'abandoned': False}}
@ -5222,7 +5223,7 @@ class Daemon(metaclass=JSONRPCServerType):
'channel_name': channel.claim_name, 'channel_name': channel.claim_name,
}) })
comment_client.sign_comment(abandon_comment_body, channel, abandon=True) comment_client.sign_comment(abandon_comment_body, channel, abandon=True)
return await comment_client.jsonrpc_post(self.conf.comment_server, 'abandon_comment', abandon_comment_body) return await comment_client.jsonrpc_post(self.conf.comment_server, 'comment.Abandon', abandon_comment_body)
@requires(WALLET_COMPONENT) @requires(WALLET_COMPONENT)
async def jsonrpc_comment_hide(self, comment_ids: typing.Union[str, list], wallet_id=None): async def jsonrpc_comment_hide(self, comment_ids: typing.Union[str, list], wallet_id=None):
@ -5268,7 +5269,115 @@ class Daemon(metaclass=JSONRPCServerType):
piece = {'comment_id': comment['comment_id']} piece = {'comment_id': comment['comment_id']}
comment_client.sign_comment(piece, channel, abandon=True) comment_client.sign_comment(piece, channel, abandon=True)
pieces.append(piece) pieces.append(piece)
return await comment_client.jsonrpc_post(self.conf.comment_server, 'hide_comments', pieces=pieces) return await comment_client.jsonrpc_post(self.conf.comment_server, 'comment.Hide', pieces=pieces)
@requires(WALLET_COMPONENT)
async def jsonrpc_comment_react(
self, comment_ids, channel_name=None, channel_id=None,
channel_account_id=None, remove=False, clear_types=None, react_type=None, wallet_id=None):
"""
Create and associate a reaction emoji with a comment using your channel identity.
Usage:
comment_react (--comment_ids=<comment_ids>)
(--channel_id=<channel_id>)
(--channel_name=<channel_name>)
(--react_type=<react_type>)
[(--remove) | (--clear_types=<clear_types>)]
[--channel_account_id=<channel_account_id>...] [--wallet_id=<wallet_id>]
Options:
--comment_ids=<comment_ids> : (str) one or more comment id reacted to, comma delimited
--channel_id=<claim_id> : (str) The ID of channel reacting
--channel_name=<claim_name> : (str) The name of the channel reacting
--wallet_id=<wallet_id> : (str) restrict operation to specific wallet
--react_type=<react_type> : (str) name of reaction type
--remove : (bool) remove specified react_type
--clear_types=<clear_types> : (str) types to clear when adding another type
Returns:
(dict) Reaction object if successfully made, (None) otherwise
{
"Reactions": {
<comment_id>: {
<reaction_type>: (int) Count for this reaction
...
}
}
"""
wallet = self.wallet_manager.get_wallet_or_default(wallet_id)
channel = await self.get_channel_or_error(
wallet, channel_account_id, channel_id, channel_name, for_signing=True
)
react_body = {
'comment_ids': comment_ids,
'channel_id': channel_id,
'channel_name': channel.claim_name,
'type': react_type,
'remove': remove,
'clear_types': clear_types,
}
comment_client.sign_reaction(react_body, channel)
response = await comment_client.jsonrpc_post(self.conf.comment_server, 'reaction.React', react_body)
return response
@requires(WALLET_COMPONENT)
async def jsonrpc_comment_react_list(
self, comment_ids, channel_name=None, channel_id=None,
channel_account_id=None, react_types=None, wallet_id=None):
"""
List reactions emoji with a claim using your channel identity.
Usage:
comment_react_list (--comment_ids=<comment_ids>)
[(--channel_id=<channel_id>)(--channel_name=<channel_name>)]
[--react_types=<react_types>]
Options:
--comment_ids=<comment_ids> : (str) The comment ids reacted to, comma delimited
--channel_id=<claim_id> : (str) The ID of channel reacting
--channel_name=<claim_name> : (str) The name of the channel reacting
--wallet_id=<wallet_id> : (str) restrict operation to specific wallet
--react_types=<react_type> : (str) comma delimited reaction types
Returns:
(dict) Comment object if successfully made, (None) otherwise
{
"my_reactions": {
<comment_id>: {
<reaction_type>: (int) Count for this reaction type
...
}
}
"other_reactions": {
<comment_id>: {
<reaction_type>: (int) Count for this reaction type
...
}
}
}
"""
wallet = self.wallet_manager.get_wallet_or_default(wallet_id)
react_list_body = {
'comment_ids': comment_ids,
}
if channel_id:
channel = await self.get_channel_or_error(
wallet, channel_account_id, channel_id, channel_name, for_signing=True
)
react_list_body['channel_id'] = channel_id
react_list_body['channel_name'] = channel.claim_name
if react_types:
react_list_body['types'] = react_types
if channel_id:
comment_client.sign_reaction(react_list_body, channel)
response = await comment_client.jsonrpc_post(self.conf.comment_server, 'reaction.List', react_list_body)
return response
async def broadcast_or_release(self, tx, blocking=False): async def broadcast_or_release(self, tx, blocking=False):
await self.wallet_manager.broadcast_or_release(tx, blocking) await self.wallet_manager.broadcast_or_release(tx, blocking)

View file

@ -2,7 +2,7 @@ __node_daemon__ = 'lbrycrdd'
__node_cli__ = 'lbrycrd-cli' __node_cli__ = 'lbrycrd-cli'
__node_bin__ = '' __node_bin__ = ''
__node_url__ = ( __node_url__ = (
'https://github.com/lbryio/lbrycrd/releases/download/v0.17.4.5/lbrycrd-linux-1745.zip' 'https://github.com/lbryio/lbrycrd/releases/download/v0.17.4.6/lbrycrd-linux-1746.zip'
) )
__spvserver__ = 'lbry.wallet.server.coin.LBCRegTest' __spvserver__ = 'lbry.wallet.server.coin.LBCRegTest'

View file

@ -1,5 +1,4 @@
import re import re
import time import time
import typing import typing
from math import ceil from math import ceil
@ -32,6 +31,16 @@ class MockedCommentServer:
'is_hidden': False, 'is_hidden': False,
} }
REACT_SCHEMA = {
'react_id': None,
'comment_id': None,
'reaction_type': None,
'timestamp': None,
'signature': None,
'channel_id': None,
'channel_name': None
}
def __init__(self, port=2903): def __init__(self, port=2903):
self.port = port self.port = port
self.app = web.Application(debug=True) self.app = web.Application(debug=True)
@ -39,7 +48,9 @@ class MockedCommentServer:
self.runner = None self.runner = None
self.server = None self.server = None
self.comments = [] self.comments = []
self.reacts = {}
self.comment_id = 0 self.comment_id = 0
self.react_id = 0
@classmethod @classmethod
def _create_comment(cls, **kwargs): def _create_comment(cls, **kwargs):
@ -47,6 +58,12 @@ class MockedCommentServer:
schema.update(**kwargs) schema.update(**kwargs)
return schema return schema
@classmethod
def _react(cls, **kwargs):
schema = cls.REACT_SCHEMA.copy()
schema.update(**kwargs)
return schema
def create_comment(self, claim_id=None, parent_id=None, channel_name=None, channel_id=None, **kwargs): def create_comment(self, claim_id=None, parent_id=None, channel_name=None, channel_id=None, **kwargs):
comment_id = self.comment_id comment_id = self.comment_id
channel_url = 'lbry://' + channel_name + '#' + channel_id if channel_id else None channel_url = 'lbry://' + channel_name + '#' + channel_id if channel_id else None
@ -117,27 +134,18 @@ class MockedCommentServer:
'visible': list(comment_ids - set(hidden)) 'visible': list(comment_ids - set(hidden))
} }
def get_claim_comments(self, claim_id, page=1, page_size=50,**kwargs): def get_claim_comments(self, claim_id, hidden=False, visible=False, page=1, page_size=50,**kwargs):
comments = list(filter(lambda c: c['claim_id'] == claim_id, self.comments)) comments = list(filter(lambda c: c['claim_id'] == claim_id, self.comments))
if hidden ^ visible:
comments = list(filter(lambda c: c['is_hidden'] == hidden, comments))
return { return {
'page': page, 'page': page,
'page_size': page_size, 'page_size': page_size,
'total_pages': ceil(len(comments)/page_size), 'total_pages': ceil(len(comments)/page_size),
'total_items': len(comments), 'total_items': len(comments),
'items': [self.clean(c) for c in (comments[::-1])[(page - 1) * page_size: page * page_size]], 'items': [self.clean(c) for c in (comments[::-1])[(page - 1) * page_size: page * page_size]],
'has_hidden_comments': bool(list(filter(lambda x: x['is_hidden'], comments))) 'has_hidden_comments': bool(list(filter(lambda x: x['is_hidden'], self.comments)))
}
def get_claim_hidden_comments(self, claim_id, hidden=True, page=1, page_size=50):
comments = list(filter(lambda c: c['claim_id'] == claim_id, self.comments))
select_comments = list(filter(lambda c: c['is_hidden'] == hidden, comments))
return {
'page': page,
'page_size': page_size,
'total_pages': ceil(len(select_comments) / page_size),
'total_items': len(select_comments),
'items': [self.clean(c) for c in (select_comments[::-1])[(page - 1) * page_size: page * page_size]],
'has_hidden_comments': bool(list(filter(lambda c: c['is_hidden'], comments)))
} }
def get_comment_channel_by_id(self, comment_id: int, **kwargs): def get_comment_channel_by_id(self, comment_id: int, **kwargs):
@ -157,15 +165,98 @@ class MockedCommentServer:
'has_hidden_comments': bool({c for c in comments if c['is_hidden']}) 'has_hidden_comments': bool({c for c in comments if c['is_hidden']})
} }
def react(
self,
comment_ids=None,
channel_name=None,
channel_id=None,
remove=None,
clear_types=None,
type=None,
signing_ts=None,
**kwargs
):
comment_batch = comment_ids.split(',')
for item in comment_batch:
react_id = self.react_id
c_id = item.strip()
reacts_for_comment_id = [r for r in list(self.reacts.values()) if r['comment_id'] == c_id]
channels_reacts_for_comment_id = [r for r in reacts_for_comment_id if r['channel_id'] == channel_id]
if remove:
matching_react = None
for reaction in channels_reacts_for_comment_id:
if reaction['reaction_type'] == type:
matching_react = reaction
break
if matching_react:
self.reacts.pop(int(matching_react['react_id']), None)
else:
if clear_types:
for r_type in clear_types.split(','):
for reaction in channels_reacts_for_comment_id:
if reaction['reaction_type'] == r_type:
x = self.reacts.pop(int(reaction['react_id']), None)
react = self._react(
react_id=str(react_id),
comment_id=str(c_id),
channel_name=channel_name,
channel_id=channel_id,
reaction_type=type,
timestamp=str(int(time.time())),
**kwargs
)
self.reacts[self.react_id] = react
self.react_id += 1
self.clean(react)
return True
def list_reacts(self, comment_ids, channel_id, channel_name, types=None, **kwargs):
all_types = list(set([r['reaction_type'] for r in list(self.reacts.values())]))
comment_id_list = comment_ids.split(',')
# _reacts: {'a1': {'like': 0, 'dislike': 0}, 'a2': {'like': 0, 'dislike': 0}}
own_reacts = {}
other_reacts = {}
# for each comment_id
# add comment_id: {} to own_reacts and other_reacts
# for each react in own_reacts
# for each react in other_reacts
for cid in comment_id_list:
own_reacts[cid] = {}
other_reacts[cid] = {}
for r_type in all_types:
own_reacts[cid][r_type] = 0
other_reacts[cid][r_type] = 0
# process that comment ids reactions for own and other categories
reacts_for_comment = list(filter(lambda c: c['comment_id'] == cid, list(self.reacts.values())))
if types:
reacts_for_comment = list(filter(lambda c: c['reaction_type'] in types.split(','), reacts_for_comment))
own_reacts_for_comment = list(filter(lambda c: c['channel_id'] == channel_id, reacts_for_comment))
other_reacts_for_comment = list(filter(lambda c: c['channel_id'] != channel_id, reacts_for_comment))
if own_reacts_for_comment:
for react in own_reacts_for_comment:
own_reacts[cid][react['reaction_type']] += 1
if other_reacts_for_comment:
for react in other_reacts_for_comment:
other_reacts[cid][react['reaction_type']] += 1
return {
'my_reactions': own_reacts,
'others_reactions': other_reacts,
}
methods = { methods = {
'get_claim_comments': get_claim_comments, 'comment.List': get_claim_comments,
'get_comments_by_id': get_comments_by_id, 'get_comments_by_id': get_comments_by_id,
'create_comment': create_comment, 'comment.Create': create_comment,
'abandon_comment': abandon_comment, 'comment.Abandon': abandon_comment,
'get_channel_from_comment_id': get_comment_channel_by_id, 'comment.GetChannelFromCommentID': get_comment_channel_by_id,
'get_claim_hidden_comments': get_claim_hidden_comments, 'comment.Hide': hide_comments,
'hide_comments': hide_comments, 'comment.Edit': edit_comment,
'edit_comment': edit_comment, 'reaction.React': react,
'reaction.List': list_reacts,
} }
def process_json(self, body) -> dict: def process_json(self, body) -> dict:
@ -450,7 +541,6 @@ class CommentCommands(CommandTestCase):
channel_id=bee['claim_id'] channel_id=bee['claim_id']
) )
all_comments = [other_comment, owner_comment, hidden_comment] all_comments = [other_comment, owner_comment, hidden_comment]
normal_list = await self.daemon.jsonrpc_comment_list(claim_id) normal_list = await self.daemon.jsonrpc_comment_list(claim_id)
self.assertEqual( self.assertEqual(
{'items', 'page', 'page_size', 'has_hidden_comments', 'total_items', 'total_pages'}, {'items', 'page', 'page_size', 'has_hidden_comments', 'total_items', 'total_pages'},
@ -532,3 +622,123 @@ class CommentCommands(CommandTestCase):
comment='If you see it and you mean then you know you have to go', comment='If you see it and you mean then you know you have to go',
comment_id=original_cid comment_id=original_cid
) )
async def test06_reactions(self):
# wherein a bee insulted by a rude moth accidentally
# 1) likes an insult
# 2) clicks dislike instead, testing "clear_types"
# 3) then clicks dislike again, testing "remove"
# calls from the point of view of the moth
moth = (await self.channel_create('@InconspicuousMoth'))['outputs'][0]
bee = (await self.channel_create('@LazyBumblebee'))['outputs'][0]
stream = await self.stream_create('Cool_Lamps_to_Sit_On', channel_id=moth['claim_id'])
claim_id = stream['outputs'][0]['claim_id']
first_comment = await self.daemon.jsonrpc_comment_create(
comment='Go away you yellow freak',
claim_id=claim_id,
channel_id=moth['claim_id'],
)
second_comment = await self.daemon.jsonrpc_comment_create(
comment='I got my swim trunks and my flippy-floppies',
claim_id=claim_id,
channel_id=bee['claim_id']
)
all_comments = [second_comment, first_comment]
comment_list = await self.daemon.jsonrpc_comment_list(claim_id)
self.assertEqual(
{'items', 'page', 'page_size', 'has_hidden_comments', 'total_items', 'total_pages'},
set(comment_list)
)
self.assertEqual(comment_list['total_items'], 2)
bee_like_reaction = await self.daemon.jsonrpc_comment_react(
comment_ids=first_comment['comment_id'],
channel_id=bee['claim_id'],
channel_name=bee['name'],
react_type='like',
)
moth_like_reaction = await self.daemon.jsonrpc_comment_react(
comment_ids=first_comment['comment_id'],
channel_id=moth['claim_id'],
channel_name=moth['name'],
react_type='like',
)
reactions = await self.daemon.jsonrpc_comment_react_list(
comment_ids=first_comment['comment_id'],
channel_id=moth['claim_id'],
channel_name=moth['name'],
)
# {'my_reactions': {'0': {'like': 1}}, 'others_reactions': {'0': {'like': 1}}}
self.assertEqual(reactions['my_reactions']['0']['like'], 1)
self.assertEqual(reactions['others_reactions']['0']['like'], 1)
bee_dislike_reaction = await self.daemon.jsonrpc_comment_react(
comment_ids=first_comment['comment_id'],
channel_id=bee['claim_id'],
channel_name=bee['name'],
react_type='dislike',
clear_types='like',
)
reactions_after_bee_dislikes = await self.daemon.jsonrpc_comment_react_list(
comment_ids=first_comment['comment_id'],
channel_id=moth['claim_id'],
channel_name=moth['name'],
)
# {'my_reactions': {'0': {'like': 1, 'dislike', 0}}, 'others_reactions': {'0': {'like': 0, 'dislike': 1}}}
self.assertEqual(reactions_after_bee_dislikes['my_reactions']['0']['like'], 1)
self.assertEqual(reactions_after_bee_dislikes['my_reactions']['0']['dislike'], 0)
self.assertEqual(reactions_after_bee_dislikes['others_reactions']['0']['dislike'], 1)
self.assertEqual(reactions_after_bee_dislikes['others_reactions']['0']['like'], 0)
only_likes_after_bee_dislikes = await self.daemon.jsonrpc_comment_react_list(
comment_ids=first_comment['comment_id'],
channel_id=moth['claim_id'],
channel_name=moth['name'],
react_types='like',
)
self.assertEqual(only_likes_after_bee_dislikes['my_reactions']['0']['like'], 1)
self.assertEqual(only_likes_after_bee_dislikes['my_reactions']['0']['dislike'], 0)
self.assertEqual(only_likes_after_bee_dislikes['others_reactions']['0']['dislike'], 0)
self.assertEqual(only_likes_after_bee_dislikes['others_reactions']['0']['like'], 0)
bee_un_dislike_reaction = await self.daemon.jsonrpc_comment_react(
comment_ids=first_comment['comment_id'],
channel_id=bee['claim_id'],
channel_name=bee['name'],
remove=True,
react_type='dislike',
)
reactions_after_bee_absconds = await self.daemon.jsonrpc_comment_react_list(
comment_ids=first_comment['comment_id'],
channel_id=moth['claim_id'],
channel_name=moth['name'],
)
self.assertEqual(reactions_after_bee_absconds['my_reactions']['0']['like'], 1)
self.assertNotIn('dislike', reactions_after_bee_absconds['my_reactions']['0'])
self.assertEqual(reactions_after_bee_absconds['others_reactions']['0']['like'], 0)
self.assertNotIn('dislike', reactions_after_bee_absconds['others_reactions']['0'])
bee_reacts_to_both_comments = await self.daemon.jsonrpc_comment_react(
comment_ids=first_comment['comment_id'] + ',' + second_comment['comment_id'],
channel_id=bee['claim_id'],
channel_name=bee['name'],
react_type='frozen_tom',
)
reactions_after_double_frozen_tom = await self.daemon.jsonrpc_comment_react_list(
comment_ids=first_comment['comment_id'] + ',' + second_comment['comment_id'],
channel_id=moth['claim_id'],
channel_name=moth['name'],
)
self.assertEqual(reactions_after_double_frozen_tom['my_reactions']['0']['like'], 1)
self.assertNotIn('dislike', reactions_after_double_frozen_tom['my_reactions']['0'])
self.assertEqual(reactions_after_double_frozen_tom['others_reactions']['0']['frozen_tom'], 1)
self.assertEqual(reactions_after_double_frozen_tom['others_reactions']['1']['frozen_tom'], 1)