diff --git a/lbry/conf.py b/lbry/conf.py index 11246d095..7c05389fb 100644 --- a/lbry/conf.py +++ b/lbry/conf.py @@ -611,7 +611,7 @@ class Config(CLIConfig): ('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_name = String("Blockchain name - lbrycrd_main, lbrycrd_regtest, or lbrycrd_testnet", 'lbrycrd_main') diff --git a/lbry/extras/daemon/comment_client.py b/lbry/extras/daemon/comment_client.py index 840c4a8aa..0efbdf105 100644 --- a/lbry/extras/daemon/comment_client.py +++ b/lbry/extras/daemon/comment_client.py @@ -52,11 +52,21 @@ def sign_comment(comment: dict, channel: Output, abandon=False): '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: params = params or {} 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: try: result = await response.json() diff --git a/lbry/extras/daemon/daemon.py b/lbry/extras/daemon/daemon.py index f6e4f41c4..b3251435b 100644 --- a/lbry/extras/daemon/daemon.py +++ b/lbry/extras/daemon/daemon.py @@ -5048,8 +5048,9 @@ class Daemon(metaclass=JSONRPCServerType): if hidden ^ visible: result = await comment_client.jsonrpc_post( self.conf.comment_server, - 'get_claim_hidden_comments', + 'comment.List', claim_id=claim_id, + visible=visible, hidden=hidden, page=page, page_size=page_size @@ -5057,7 +5058,7 @@ class Daemon(metaclass=JSONRPCServerType): else: result = await comment_client.jsonrpc_post( self.conf.comment_server, - 'get_claim_comments', + 'comment.List', claim_id=claim_id, parent_id=parent_id, page=page, @@ -5132,7 +5133,7 @@ class Daemon(metaclass=JSONRPCServerType): } 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({ '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( self.conf.comment_server, - 'get_channel_from_comment_id', + 'comment.GetChannelFromCommentID', comment_id=comment_id ) if 'error' in channel: @@ -5186,7 +5187,7 @@ class Daemon(metaclass=JSONRPCServerType): } comment_client.sign_comment(edited_comment, channel_claim) 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) @@ -5212,7 +5213,7 @@ class Daemon(metaclass=JSONRPCServerType): wallet = self.wallet_manager.get_wallet_or_default(wallet_id) abandon_comment_body = {'comment_id': comment_id} 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: return {comment_id: {'abandoned': False}} @@ -5222,7 +5223,7 @@ class Daemon(metaclass=JSONRPCServerType): 'channel_name': channel.claim_name, }) 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) async def jsonrpc_comment_hide(self, comment_ids: typing.Union[str, list], wallet_id=None): @@ -5268,7 +5269,96 @@ class Daemon(metaclass=JSONRPCServerType): piece = {'comment_id': comment['comment_id']} comment_client.sign_comment(piece, channel, abandon=True) 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_id, channel_name=None, channel_id=None, + channel_account_id=None, remove=False, clear_types=False, react_type=None, wallet_id=None): + """ + Create and associate a reaction emoji with a comment using your channel identity. + + Usage: + comment_react ( | --comment_id=) + (--channel_id=) + (--channel_name=) + (--react_type=) + [(--remove) | (--clear_types=)] + [--channel_account_id=...] [--wallet_id=] + + Options: + --comment_id= : (str) The comment id reacted to + --channel_id= : (str) The ID of channel reacting + --channel_name= : (str) The name of the channel reacting + --wallet_id= : (str) restrict operation to specific wallet + --react_type= : (str) name of reaction type + --remove : (bool) remove specified react_type + --clear_types= : (str) types to clear when adding another type + + + Returns: + (dict) Reaction object if successfully made, (None) otherwise + { + "hidden": (list) IDs of hidden comments. + "visible": (list) IDs of visible comments. + } + """ + 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 + ) + + print(channel) + + react_body = { + 'comment_ids': comment_id, + 'channel_id': channel_id, + 'channel_name': channel.claim_name, + 'type': react_type, + } + 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_id, channel_name=None, channel_id=None, + channel_account_id=None, react_type=None, wallet_id=None): + """ + List reactions emoji with a claim using your channel identity. + + Usage: + comment_react_list ( | --comment_id=) + + Options: + --comment_id= : (str) The comment id reacted to + --channel_id= : (str) The ID of channel reacting + --channel_name= : (str) The name of the channel reacting + --wallet_id= : (str) restrict operation to specific wallet + --react_type= : (str) name of reaction type + + Returns: + (dict) Comment object if successfully made, (None) otherwise + { + "hidden": (list) IDs of hidden comments. + "visible": (list) IDs of visible comments. + } + """ + 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_list_body = { + 'comment_id': comment_id, + 'channel_id': channel_id, + 'channel_name': channel.claim_name, + 'type': react_type, + } + 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): await self.wallet_manager.broadcast_or_release(tx, blocking) diff --git a/tests/integration/other/test_comment_commands.py b/tests/integration/other/test_comment_commands.py index fb72bf7cc..676843f49 100644 --- a/tests/integration/other/test_comment_commands.py +++ b/tests/integration/other/test_comment_commands.py @@ -32,6 +32,15 @@ class MockedCommentServer: 'is_hidden': False, } + REACT_SCHEMA = { + 'comment_id': None, + 'reaction_type': None, + 'timestamp': None, + 'signature': None, + 'channel_id': None, + 'channel_name': None + } + def __init__(self, port=2903): self.port = port self.app = web.Application(debug=True) @@ -39,7 +48,9 @@ class MockedCommentServer: self.runner = None self.server = None self.comments = [] + self.reacts = [] self.comment_id = 0 + self.react_id = 0 @classmethod def _create_comment(cls, **kwargs): @@ -47,6 +58,12 @@ class MockedCommentServer: schema.update(**kwargs) 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): comment_id = self.comment_id channel_url = 'lbry://' + channel_name + '#' + channel_id if channel_id else None @@ -117,27 +134,18 @@ class MockedCommentServer: '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)) + if hidden ^ visible: + comments = list(filter(lambda c: c['is_hidden'] == hidden, comments)) + return { 'page': page, 'page_size': page_size, 'total_pages': ceil(len(comments)/page_size), 'total_items': len(comments), '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))) - } - - 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))) + 'has_hidden_comments': bool(list(filter(lambda x: x['is_hidden'], self.comments))) } def get_comment_channel_by_id(self, comment_id: int, **kwargs): @@ -157,15 +165,83 @@ class MockedCommentServer: '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: + c_id = item.strip() + reacts_for_comment_id = [r for r in self.reacts if r['comment_id'] == c_id] + channels_reacts_for_comment_id = [r for r in reacts_for_comment_id if ['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(matching_react['id']) + else: + if clear_types: + for r_type in clear_types: + for reaction in channels_reacts_for_comment_id: + if reaction['reaction_type'] == r_type: + self.reacts.pop(reaction['id']) + react = self._react( + comment_id=str(c_id), + channel_name=channel_name, + channel_id=channel_id, + reaction_type=type, + timestamp=str(int(time.time())), + **kwargs + ) + self.reacts.append(react) + self.react_id += 1 + return self.clean(react) + + def list_reacts(self, comment_id, channel_id, **kwargs): + reacts_for_comment = list(filter(lambda c: c['comment_id'] == comment_id, self.reacts)) + 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)) + print('seriously wtf', own_reacts_for_comment) + own_counts = {} + if own_reacts_for_comment: + for react in own_reacts_for_comment: + own_counts[react['reaction_type']] = own_counts[react['reaction_type']] + 1 if react['reaction_type'] in own_counts else 1 + print('eh??') + other_counts = {} + if other_reacts_for_comment: + for react in other_reacts_for_comment: + other_counts[react['reaction_type']] = other_counts[react['reaction_type']] + 1 if react['reaction_type'] in other_counts else 1 + + return { + 'my_reactions': { + comment_id: own_counts, + }, + 'others_reactions': { + comment_id: other_counts, + } + } + methods = { - 'get_claim_comments': get_claim_comments, + 'comment.List': get_claim_comments, 'get_comments_by_id': get_comments_by_id, - 'create_comment': create_comment, - 'abandon_comment': abandon_comment, - 'get_channel_from_comment_id': get_comment_channel_by_id, - 'get_claim_hidden_comments': get_claim_hidden_comments, - 'hide_comments': hide_comments, - 'edit_comment': edit_comment, + 'comment.Create': create_comment, + 'comment.Abandon': abandon_comment, + 'comment.GetChannelFromCommentID': get_comment_channel_by_id, + 'comment.Hide': hide_comments, + 'comment.Edit': edit_comment, + 'reaction.React': react, + 'reaction.List': list_reacts, } def process_json(self, body) -> dict: @@ -450,7 +526,6 @@ class CommentCommands(CommandTestCase): channel_id=bee['claim_id'] ) all_comments = [other_comment, owner_comment, hidden_comment] - normal_list = await self.daemon.jsonrpc_comment_list(claim_id) self.assertEqual( {'items', 'page', 'page_size', 'has_hidden_comments', 'total_items', 'total_pages'}, @@ -532,3 +607,55 @@ class CommentCommands(CommandTestCase): comment='If you see it and you mean then you know you have to go', comment_id=original_cid ) + + async def test06_reactions(self): + 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 likes [0] + # moth likes [0,1] + # moth smiles [0,1,2] + # bee dislikes (cleartypes) [1,2,3] + # bee undislikes (remove) [1,2] + # list likes + + bee_like_reaction = await self.daemon.jsonrpc_comment_react( + comment_id=first_comment['comment_id'], + channel_id=bee['claim_id'], + channel_name=bee['name'], + react_type='dislike', + ) + + bee_like_reaction = await self.daemon.jsonrpc_comment_react( + comment_id=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_id=first_comment['comment_id'], + channel_id=moth['claim_id'], + channel_name=moth['name'], + ) + # test assertions