diff --git a/daemon.py b/daemon.py index c52c1303a..3b6d6e2a8 100644 --- a/daemon.py +++ b/daemon.py @@ -1489,7 +1489,9 @@ class PubServer(BaseHTTPRequestHandler): originalMessageJson = messageJson.copy() # whether to add a 'to' field to the message - addToFieldTypes = ('Follow', 'Like', 'Add', 'Remove', 'Ignore') + addToFieldTypes = ( + 'Follow', 'Like', 'EmojiReact', 'Add', 'Remove', 'Ignore' + ) for addToType in addToFieldTypes: messageJson, toFieldExists = \ addToField(addToType, messageJson, self.server.debug) diff --git a/epicyon.py b/epicyon.py index 9cf52179e..f1abb86d8 100644 --- a/epicyon.py +++ b/epicyon.py @@ -81,6 +81,8 @@ from media import getAttachmentMediaType from delete import sendDeleteViaServer from like import sendLikeViaServer from like import sendUndoLikeViaServer +from reaction import sendReactionViaServer +from reaction import sendUndoReactionViaServer from skills import sendSkillViaServer from availability import setAvailability from availability import sendAvailabilityViaServer @@ -510,6 +512,13 @@ parser.add_argument('--favorite', '--like', dest='like', type=str, default=None, help='Like a url') parser.add_argument('--undolike', '--unlike', dest='undolike', type=str, default=None, help='Undo a like of a url') +parser.add_argument('--react', '--reaction', dest='react', type=str, + default=None, help='Reaction url') +parser.add_argument('--emoji', type=str, + default=None, help='Reaction emoji') +parser.add_argument('--undoreact', '--undoreaction', dest='undoreact', + type=str, + default=None, help='Reaction url') parser.add_argument('--bookmark', '--bm', dest='bookmark', type=str, default=None, help='Bookmark the url of a post') @@ -1612,6 +1621,42 @@ if args.like: time.sleep(1) sys.exit() +if args.react: + if not args.nickname: + print('Specify a nickname with the --nickname option') + sys.exit() + if not args.emoji: + print('Specify a reaction emoji with the --emoji option') + sys.exit() + + if not args.password: + args.password = getpass.getpass('Password: ') + if not args.password: + print('Specify a password with the --password option') + sys.exit() + args.password = args.password.replace('\n', '') + + session = createSession(proxyType) + personCache = {} + cachedWebfingers = {} + if not domain: + domain = getConfigParam(baseDir, 'domain') + signingPrivateKeyPem = None + if args.secureMode: + signingPrivateKeyPem = getInstanceActorKey(baseDir, domain) + print('Sending emoji reaction ' + args.emoji + ' to ' + args.react) + + sendReactionViaServer(baseDir, session, + args.nickname, args.password, + domain, port, + httpPrefix, args.react, args.emoji, + cachedWebfingers, personCache, + True, __version__, signingPrivateKeyPem) + for i in range(10): + # TODO detect send success/fail + time.sleep(1) + sys.exit() + if args.undolike: if not args.nickname: print('Specify a nickname with the --nickname option') @@ -1646,6 +1691,43 @@ if args.undolike: time.sleep(1) sys.exit() +if args.undoreact: + if not args.nickname: + print('Specify a nickname with the --nickname option') + sys.exit() + if not args.emoji: + print('Specify a reaction emoji with the --emoji option') + sys.exit() + + if not args.password: + args.password = getpass.getpass('Password: ') + if not args.password: + print('Specify a password with the --password option') + sys.exit() + args.password = args.password.replace('\n', '') + + session = createSession(proxyType) + personCache = {} + cachedWebfingers = {} + if not domain: + domain = getConfigParam(baseDir, 'domain') + signingPrivateKeyPem = None + if args.secureMode: + signingPrivateKeyPem = getInstanceActorKey(baseDir, domain) + print('Sending undo emoji reaction ' + args.emoji + ' to ' + args.react) + + sendUndoReactionViaServer(baseDir, session, + args.nickname, args.password, + domain, port, + httpPrefix, args.undoreact, args.emoji, + cachedWebfingers, personCache, + True, __version__, + signingPrivateKeyPem) + for i in range(10): + # TODO detect send success/fail + time.sleep(1) + sys.exit() + if args.bookmark: if not args.nickname: print('Specify a nickname with the --nickname option') diff --git a/inbox.py b/inbox.py index fab4fca6e..6469cb839 100644 --- a/inbox.py +++ b/inbox.py @@ -15,6 +15,8 @@ import random from linked_data_sig import verifyJsonSignature from languages import understoodPostLanguage from like import updateLikesCollection +from reaction import updateReactionCollection +from utils import removeHtml from utils import fileLastModified from utils import hasObjectString from utils import hasObjectStringObject @@ -50,6 +52,7 @@ from utils import removeModerationPostFromIndex from utils import loadJson from utils import saveJson from utils import undoLikesCollectionEntry +from utils import undoReactionCollectionEntry from utils import hasGroupType from utils import localActorUrl from utils import hasObjectStringType @@ -393,7 +396,8 @@ def inboxMessageHasParams(messageJson: {}) -> bool: return False if not messageJson.get('to'): - allowedWithoutToParam = ['Like', 'Follow', 'Join', 'Request', + allowedWithoutToParam = ['Like', 'EmojiReact', + 'Follow', 'Join', 'Request', 'Accept', 'Capability', 'Undo'] if messageJson['type'] not in allowedWithoutToParam: return False @@ -415,7 +419,9 @@ def inboxPermittedMessage(domain: str, messageJson: {}, if not urlPermitted(actor, federationList): return False - alwaysAllowedTypes = ('Follow', 'Join', 'Like', 'Delete', 'Announce') + alwaysAllowedTypes = ( + 'Follow', 'Join', 'Like', 'EmojiReact', 'Delete', 'Announce' + ) if messageJson['type'] not in alwaysAllowedTypes: if not hasObjectDict(messageJson): return True @@ -1203,6 +1209,271 @@ def _receiveUndoLike(recentPostsCache: {}, return True +def _receiveReaction(recentPostsCache: {}, + session, handle: str, isGroup: bool, baseDir: str, + httpPrefix: str, domain: str, port: int, + onionDomain: str, + sendThreads: [], postLog: [], cachedWebfingers: {}, + personCache: {}, messageJson: {}, federationList: [], + debug: bool, + signingPrivateKeyPem: str, + maxRecentPosts: int, translate: {}, + allowDeletion: bool, + YTReplacementDomain: str, + twitterReplacementDomain: str, + peertubeInstances: [], + allowLocalNetworkAccess: bool, + themeName: str, systemLanguage: str, + maxLikeCount: int, CWlists: {}, + listsEnabled: str) -> bool: + """Receives an emoji reaction within the POST section of HTTPServer + """ + if messageJson['type'] != 'EmojiReact': + return False + if not hasActor(messageJson, debug): + return False + if not hasObjectString(messageJson, debug): + return False + if not messageJson.get('to'): + if debug: + print('DEBUG: ' + messageJson['type'] + ' has no "to" list') + return False + if not messageJson.get('content'): + if debug: + print('DEBUG: ' + messageJson['type'] + ' has no "content"') + return False + if not isinstance(messageJson['content'], str): + if debug: + print('DEBUG: ' + messageJson['type'] + ' content is not string') + return False + if not hasUsersPath(messageJson['actor']): + if debug: + print('DEBUG: "users" or "profile" missing from actor in ' + + messageJson['type']) + return False + if '/statuses/' not in messageJson['object']: + if debug: + print('DEBUG: "statuses" missing from object in ' + + messageJson['type']) + return False + if not os.path.isdir(baseDir + '/accounts/' + handle): + print('DEBUG: unknown recipient of emoji reaction - ' + handle) + # if this post in the outbox of the person? + handleName = handle.split('@')[0] + handleDom = handle.split('@')[1] + postReactionId = messageJson['object'] + emojiContent = removeHtml(messageJson['content']) + if not emojiContent: + if debug: + print('DEBUG: emoji reaction has no content') + return True + postFilename = locatePost(baseDir, handleName, handleDom, postReactionId) + if not postFilename: + if debug: + print('DEBUG: emoji reaction post not found in inbox or outbox') + print(postReactionId) + return True + if debug: + print('DEBUG: emoji reaction post found in inbox') + + reactionActor = messageJson['actor'] + handleName = handle.split('@')[0] + handleDom = handle.split('@')[1] + if not _alreadyReacted(baseDir, + handleName, handleDom, + postReactionId, + reactionActor, + emojiContent): + _reactionNotify(baseDir, domain, onionDomain, handle, + reactionActor, postReactionId, emojiContent) + updateReactionCollection(recentPostsCache, baseDir, postFilename, + postReactionId, reactionActor, + handleName, domain, debug, None, emojiContent) + # regenerate the html + reactionPostJson = loadJson(postFilename, 0, 1) + if reactionPostJson: + if reactionPostJson.get('type'): + if reactionPostJson['type'] == 'Announce' and \ + reactionPostJson.get('object'): + if isinstance(reactionPostJson['object'], str): + announceReactionUrl = reactionPostJson['object'] + announceReactionFilename = \ + locatePost(baseDir, handleName, + domain, announceReactionUrl) + if announceReactionFilename: + postReactionId = announceReactionUrl + postFilename = announceReactionFilename + updateReactionCollection(recentPostsCache, + baseDir, + postFilename, + postReactionId, + reactionActor, + handleName, + domain, debug, None, + emojiContent) + if reactionPostJson: + if debug: + cachedPostFilename = \ + getCachedPostFilename(baseDir, handleName, domain, + reactionPostJson) + print('Reaction post json: ' + str(reactionPostJson)) + print('Reaction post nickname: ' + handleName + ' ' + domain) + print('Reaction post cache: ' + str(cachedPostFilename)) + pageNumber = 1 + showPublishedDateOnly = False + showIndividualPostIcons = True + manuallyApproveFollowers = \ + followerApprovalActive(baseDir, handleName, domain) + notDM = not isDM(reactionPostJson) + individualPostAsHtml(signingPrivateKeyPem, False, + recentPostsCache, maxRecentPosts, + translate, pageNumber, baseDir, + session, cachedWebfingers, personCache, + handleName, domain, port, reactionPostJson, + None, True, allowDeletion, + httpPrefix, __version__, + 'inbox', + YTReplacementDomain, + twitterReplacementDomain, + showPublishedDateOnly, + peertubeInstances, + allowLocalNetworkAccess, + themeName, systemLanguage, + maxLikeCount, notDM, + showIndividualPostIcons, + manuallyApproveFollowers, + False, True, False, CWlists, + listsEnabled) + return True + + +def _receiveUndoReaction(recentPostsCache: {}, + session, handle: str, isGroup: bool, baseDir: str, + httpPrefix: str, domain: str, port: int, + sendThreads: [], postLog: [], cachedWebfingers: {}, + personCache: {}, messageJson: {}, federationList: [], + debug: bool, + signingPrivateKeyPem: str, + maxRecentPosts: int, translate: {}, + allowDeletion: bool, + YTReplacementDomain: str, + twitterReplacementDomain: str, + peertubeInstances: [], + allowLocalNetworkAccess: bool, + themeName: str, systemLanguage: str, + maxLikeCount: int, CWlists: {}, + listsEnabled: str) -> bool: + """Receives an undo emoji reaction within the POST section of HTTPServer + """ + if messageJson['type'] != 'Undo': + return False + if not hasActor(messageJson, debug): + return False + if not hasObjectStringType(messageJson, debug): + return False + if messageJson['object']['type'] != 'EmojiReact': + return False + if not hasObjectStringObject(messageJson, debug): + return False + if not messageJson['object'].get('content'): + if debug: + print('DEBUG: ' + messageJson['type'] + ' has no "content"') + return False + if not isinstance(messageJson['object']['content'], str): + if debug: + print('DEBUG: ' + messageJson['type'] + ' content is not string') + return False + if not hasUsersPath(messageJson['actor']): + if debug: + print('DEBUG: "users" or "profile" missing from actor in ' + + messageJson['type'] + ' reaction') + return False + if '/statuses/' not in messageJson['object']['object']: + if debug: + print('DEBUG: "statuses" missing from reaction object in ' + + messageJson['type']) + return False + if not os.path.isdir(baseDir + '/accounts/' + handle): + print('DEBUG: unknown recipient of undo reaction - ' + handle) + # if this post in the outbox of the person? + handleName = handle.split('@')[0] + handleDom = handle.split('@')[1] + postFilename = \ + locatePost(baseDir, handleName, handleDom, + messageJson['object']['object']) + if not postFilename: + if debug: + print('DEBUG: unreaction post not found in inbox or outbox') + print(messageJson['object']['object']) + return True + if debug: + print('DEBUG: reaction post found in inbox. Now undoing.') + reactionActor = messageJson['actor'] + postReactionId = messageJson['object'] + emojiContent = removeHtml(messageJson['object']['content']) + if not emojiContent: + if debug: + print('DEBUG: unreaction has no content') + return True + undoReactionCollectionEntry(recentPostsCache, baseDir, postFilename, + postReactionId, reactionActor, domain, + debug, None, emojiContent) + # regenerate the html + reactionPostJson = loadJson(postFilename, 0, 1) + if reactionPostJson: + if reactionPostJson.get('type'): + if reactionPostJson['type'] == 'Announce' and \ + reactionPostJson.get('object'): + if isinstance(reactionPostJson['object'], str): + announceReactionUrl = reactionPostJson['object'] + announceReactionFilename = \ + locatePost(baseDir, handleName, + domain, announceReactionUrl) + if announceReactionFilename: + postReactionId = announceReactionUrl + postFilename = announceReactionFilename + undoReactionCollectionEntry(recentPostsCache, baseDir, + postFilename, + postReactionId, + reactionActor, domain, + debug, None, + emojiContent) + if reactionPostJson: + if debug: + cachedPostFilename = \ + getCachedPostFilename(baseDir, handleName, domain, + reactionPostJson) + print('Unreaction post json: ' + str(reactionPostJson)) + print('Unreaction post nickname: ' + handleName + ' ' + domain) + print('Unreaction post cache: ' + str(cachedPostFilename)) + pageNumber = 1 + showPublishedDateOnly = False + showIndividualPostIcons = True + manuallyApproveFollowers = \ + followerApprovalActive(baseDir, handleName, domain) + notDM = not isDM(reactionPostJson) + individualPostAsHtml(signingPrivateKeyPem, False, + recentPostsCache, maxRecentPosts, + translate, pageNumber, baseDir, + session, cachedWebfingers, personCache, + handleName, domain, port, reactionPostJson, + None, True, allowDeletion, + httpPrefix, __version__, + 'inbox', + YTReplacementDomain, + twitterReplacementDomain, + showPublishedDateOnly, + peertubeInstances, + allowLocalNetworkAccess, + themeName, systemLanguage, + maxLikeCount, notDM, + showIndividualPostIcons, + manuallyApproveFollowers, + False, True, False, CWlists, + listsEnabled) + return True + + def _receiveBookmark(recentPostsCache: {}, session, handle: str, isGroup: bool, baseDir: str, httpPrefix: str, domain: str, port: int, @@ -2068,6 +2339,40 @@ def _alreadyLiked(baseDir: str, nickname: str, domain: str, return False +def _alreadyReacted(baseDir: str, nickname: str, domain: str, + postUrl: str, reactionActor: str, + emojiContent: str) -> bool: + """Is the given post already emoji reacted by the given handle? + """ + postFilename = \ + locatePost(baseDir, nickname, domain, postUrl) + if not postFilename: + return False + postJsonObject = loadJson(postFilename, 1) + if not postJsonObject: + return False + if not hasObjectDict(postJsonObject): + return False + if not postJsonObject['object'].get('reactions'): + return False + if not postJsonObject['object']['reactions'].get('items'): + return False + for react in postJsonObject['object']['reactions']['items']: + if not react.get('type'): + continue + if not react.get('content'): + continue + if not react.get('actor'): + continue + if react['type'] != 'EmojiReact': + continue + if react['content'] != emojiContent: + continue + if react['actor'] == reactionActor: + return True + return False + + def _likeNotify(baseDir: str, domain: str, onionDomain: str, handle: str, actor: str, url: str) -> None: """Creates a notification that a like has arrived @@ -2130,6 +2435,71 @@ def _likeNotify(baseDir: str, domain: str, onionDomain: str, pass +def _reactionNotify(baseDir: str, domain: str, onionDomain: str, + handle: str, actor: str, + url: str, emojiContent: str) -> None: + """Creates a notification that an emoji reaction has arrived + """ + # This is not you reacting to your own post + if actor in url: + return + + # check that the reaction post was by this handle + nickname = handle.split('@')[0] + if '/' + domain + '/users/' + nickname not in url: + if not onionDomain: + return + if '/' + onionDomain + '/users/' + nickname not in url: + return + + accountDir = baseDir + '/accounts/' + handle + + # are reaction notifications enabled? + notifyReactionEnabledFilename = accountDir + '/.notifyReaction' + if not os.path.isfile(notifyReactionEnabledFilename): + return + + reactionFile = accountDir + '/.newReaction' + if os.path.isfile(reactionFile): + if '##sent##' not in open(reactionFile).read(): + return + + reactionNickname = getNicknameFromActor(actor) + reactionDomain, reactionPort = getDomainFromActor(actor) + if reactionNickname and reactionDomain: + reactionHandle = reactionNickname + '@' + reactionDomain + else: + print('_reactionNotify reactionHandle: ' + + str(reactionNickname) + '@' + str(reactionDomain)) + reactionHandle = actor + if reactionHandle != handle: + reactionStr = \ + reactionHandle + ' ' + url + '?reactionBy=' + actor + \ + ';emoji=' + emojiContent + prevReactionFile = accountDir + '/.prevReaction' + # was there a previous reaction notification? + if os.path.isfile(prevReactionFile): + # is it the same as the current notification ? + with open(prevReactionFile, 'r') as fp: + prevReactionStr = fp.read() + if prevReactionStr == reactionStr: + return + try: + with open(prevReactionFile, 'w+') as fp: + fp.write(reactionStr) + except BaseException: + print('EX: ERROR: unable to save previous reaction notification ' + + prevReactionFile) + pass + try: + with open(reactionFile, 'w+') as fp: + fp.write(reactionStr) + except BaseException: + print('EX: ERROR: unable to write reaction notification file ' + + reactionFile) + pass + + def _notifyPostArrival(baseDir: str, handle: str, url: str) -> None: """Creates a notification that a new post has arrived. This is for followed accounts with the notify checkbox enabled @@ -2834,6 +3204,51 @@ def _inboxAfterInitial(recentPostsCache: {}, maxRecentPosts: int, print('DEBUG: Undo like accepted from ' + actor) return False + if _receiveReaction(recentPostsCache, + session, handle, isGroup, + baseDir, httpPrefix, + domain, port, + onionDomain, + sendThreads, postLog, + cachedWebfingers, + personCache, + messageJson, + federationList, + debug, signingPrivateKeyPem, + maxRecentPosts, translate, + allowDeletion, + YTReplacementDomain, + twitterReplacementDomain, + peertubeInstances, + allowLocalNetworkAccess, + themeName, systemLanguage, + maxLikeCount, CWlists, listsEnabled): + if debug: + print('DEBUG: Reaction accepted from ' + actor) + return False + + if _receiveUndoReaction(recentPostsCache, + session, handle, isGroup, + baseDir, httpPrefix, + domain, port, + sendThreads, postLog, + cachedWebfingers, + personCache, + messageJson, + federationList, + debug, signingPrivateKeyPem, + maxRecentPosts, translate, + allowDeletion, + YTReplacementDomain, + twitterReplacementDomain, + peertubeInstances, + allowLocalNetworkAccess, + themeName, systemLanguage, + maxLikeCount, CWlists, listsEnabled): + if debug: + print('DEBUG: Undo reaction accepted from ' + actor) + return False + if _receiveBookmark(recentPostsCache, session, handle, isGroup, baseDir, httpPrefix, diff --git a/like.py b/like.py index e1df5a5de..ef1c57a5d 100644 --- a/like.py +++ b/like.py @@ -35,6 +35,22 @@ from auth import createBasicAuthHeader from posts import getPersonBox +def noOfLikes(postJsonObject: {}) -> int: + """Returns the number of likes ona given post + """ + obj = postJsonObject + if hasObjectDict(postJsonObject): + obj = postJsonObject['object'] + if not obj.get('likes'): + return 0 + if not isinstance(obj['likes'], dict): + return 0 + if not obj['likes'].get('items'): + obj['likes']['items'] = [] + obj['likes']['totalItems'] = 0 + return len(obj['likes']['items']) + + def likedByPerson(postJsonObject: {}, nickname: str, domain: str) -> bool: """Returns True if the given post is liked by the given person """ @@ -52,22 +68,6 @@ def likedByPerson(postJsonObject: {}, nickname: str, domain: str) -> bool: return False -def noOfLikes(postJsonObject: {}) -> int: - """Returns the number of likes ona given post - """ - obj = postJsonObject - if hasObjectDict(postJsonObject): - obj = postJsonObject['object'] - if not obj.get('likes'): - return 0 - if not isinstance(obj['likes'], dict): - return 0 - if not obj['likes'].get('items'): - obj['likes']['items'] = [] - obj['likes']['totalItems'] = 0 - return len(obj['likes']['items']) - - def _like(recentPostsCache: {}, session, baseDir: str, federationList: [], nickname: str, domain: str, port: int, diff --git a/outbox.py b/outbox.py index 6b0bc0583..f9f42de9f 100644 --- a/outbox.py +++ b/outbox.py @@ -48,6 +48,8 @@ from skills import outboxSkills from availability import outboxAvailability from like import outboxLike from like import outboxUndoLike +from reaction import outboxReaction +from reaction import outboxUndoReaction from bookmarks import outboxBookmark from bookmarks import outboxUndoBookmark from delete import outboxDelete @@ -338,9 +340,10 @@ def postMessageToOutbox(session, translate: {}, '/system/' + 'media_attachments/files/') - permittedOutboxTypes = ('Create', 'Announce', 'Like', 'Follow', 'Undo', - 'Update', 'Add', 'Remove', 'Block', 'Delete', - 'Skill', 'Ignore') + permittedOutboxTypes = ( + 'Create', 'Announce', 'Like', 'EmojiReact', 'Follow', 'Undo', + 'Update', 'Add', 'Remove', 'Block', 'Delete', 'Skill', 'Ignore' + ) if messageJson['type'] not in permittedOutboxTypes: if debug: print('DEBUG: POST to outbox - ' + messageJson['type'] + @@ -547,6 +550,20 @@ def postMessageToOutbox(session, translate: {}, baseDir, httpPrefix, postToNickname, domain, port, messageJson, debug) + + if debug: + print('DEBUG: handle any emoji reaction requests') + outboxReaction(recentPostsCache, + baseDir, httpPrefix, + postToNickname, domain, port, + messageJson, debug) + if debug: + print('DEBUG: handle any undo emoji reaction requests') + outboxUndoReaction(recentPostsCache, + baseDir, httpPrefix, + postToNickname, domain, port, + messageJson, debug) + if debug: print('DEBUG: handle any undo announce requests') outboxUndoAnnounce(recentPostsCache, diff --git a/posts.py b/posts.py index 763acdd69..347821b10 100644 --- a/posts.py +++ b/posts.py @@ -3506,6 +3506,11 @@ def removePostInteractions(postJsonObject: {}, force: bool) -> bool: postObj['likes'] = { 'items': [] } + # clear the reactions + if postObj.get('reactions'): + postObj['reactions'] = { + 'items': [] + } # remove other collections removeCollections = ( 'replies', 'shares', 'bookmarks', 'ignores' diff --git a/reaction.py b/reaction.py new file mode 100644 index 000000000..2aaea25f6 --- /dev/null +++ b/reaction.py @@ -0,0 +1,496 @@ +__filename__ = "reaction.py" +__author__ = "Bob Mottram" +__license__ = "AGPL3+" +__version__ = "1.2.0" +__maintainer__ = "Bob Mottram" +__email__ = "bob@libreserver.org" +__status__ = "Production" +__module_group__ = "ActivityPub" + +import os +from pprint import pprint +from utils import hasObjectString +from utils import hasObjectStringObject +from utils import hasObjectStringType +from utils import removeDomainPort +from utils import hasObjectDict +from utils import hasUsersPath +from utils import getFullDomain +from utils import removeIdEnding +from utils import urlPermitted +from utils import getNicknameFromActor +from utils import getDomainFromActor +from utils import locatePost +from utils import undoReactionCollectionEntry +from utils import hasGroupType +from utils import localActorUrl +from utils import loadJson +from utils import saveJson +from utils import removePostFromCache +from utils import getCachedPostFilename +from posts import sendSignedJson +from session import postJson +from webfinger import webfingerHandle +from auth import createBasicAuthHeader +from posts import getPersonBox + + +def noOfReactions(postJsonObject: {}, emojiContent: str) -> int: + """Returns the number of emoji reactions of a given content type on a post + """ + obj = postJsonObject + if hasObjectDict(postJsonObject): + obj = postJsonObject['object'] + if not obj.get('reactions'): + return 0 + if not isinstance(obj['reactions'], dict): + return 0 + if not obj['reactions'].get('items'): + obj['reactions']['items'] = [] + obj['reactions']['totalItems'] = 0 + ctr = 0 + for item in obj['reactions']['items']: + if not item.get('content'): + continue + if item['content'] == emojiContent: + ctr += 1 + return ctr + + +def _reaction(recentPostsCache: {}, + session, baseDir: str, federationList: [], + nickname: str, domain: str, port: int, + ccList: [], httpPrefix: str, + objectUrl: str, emojiContent: str, + actorReaction: str, + clientToServer: bool, + sendThreads: [], postLog: [], + personCache: {}, cachedWebfingers: {}, + debug: bool, projectVersion: str, + signingPrivateKeyPem: str) -> {}: + """Creates an emoji reaction + actor is the person doing the reacting + 'to' might be a specific person (actor) whose post was reaction + object is typically the url of the message which was reaction + """ + if not urlPermitted(objectUrl, federationList): + return None + + fullDomain = getFullDomain(domain, port) + + newReactionJson = { + "@context": "https://www.w3.org/ns/activitystreams", + 'type': 'EmojiReact', + 'actor': localActorUrl(httpPrefix, nickname, fullDomain), + 'object': objectUrl, + 'content': emojiContent + } + if ccList: + if len(ccList) > 0: + newReactionJson['cc'] = ccList + + # Extract the domain and nickname from a statuses link + reactionPostNickname = None + reactionPostDomain = None + reactionPostPort = None + groupAccount = False + if actorReaction: + reactionPostNickname = getNicknameFromActor(actorReaction) + reactionPostDomain, reactionPostPort = \ + getDomainFromActor(actorReaction) + groupAccount = hasGroupType(baseDir, actorReaction, personCache) + else: + if hasUsersPath(objectUrl): + reactionPostNickname = getNicknameFromActor(objectUrl) + reactionPostDomain, reactionPostPort = \ + getDomainFromActor(objectUrl) + if '/' + str(reactionPostNickname) + '/' in objectUrl: + actorReaction = \ + objectUrl.split('/' + reactionPostNickname + '/')[0] + \ + '/' + reactionPostNickname + groupAccount = \ + hasGroupType(baseDir, actorReaction, personCache) + + if reactionPostNickname: + postFilename = locatePost(baseDir, nickname, domain, objectUrl) + if not postFilename: + print('DEBUG: reaction baseDir: ' + baseDir) + print('DEBUG: reaction nickname: ' + nickname) + print('DEBUG: reaction domain: ' + domain) + print('DEBUG: reaction objectUrl: ' + objectUrl) + return None + + updateReactionCollection(recentPostsCache, + baseDir, postFilename, objectUrl, + newReactionJson['actor'], + nickname, domain, debug, None, + emojiContent) + + sendSignedJson(newReactionJson, session, baseDir, + nickname, domain, port, + reactionPostNickname, + reactionPostDomain, reactionPostPort, + 'https://www.w3.org/ns/activitystreams#Public', + httpPrefix, True, clientToServer, federationList, + sendThreads, postLog, cachedWebfingers, personCache, + debug, projectVersion, None, groupAccount, + signingPrivateKeyPem, 7165392) + + return newReactionJson + + +def reactionPost(recentPostsCache: {}, + session, baseDir: str, federationList: [], + nickname: str, domain: str, port: int, httpPrefix: str, + reactionNickname: str, reactionDomain: str, reactionPort: int, + ccList: [], + reactionStatusNumber: int, emojiContent: str, + clientToServer: bool, + sendThreads: [], postLog: [], + personCache: {}, cachedWebfingers: {}, + debug: bool, projectVersion: str, + signingPrivateKeyPem: str) -> {}: + """Adds a reaction to a given status post. This is only used by unit tests + """ + reactionDomain = getFullDomain(reactionDomain, reactionPort) + + actorReaction = localActorUrl(httpPrefix, reactionNickname, reactionDomain) + objectUrl = actorReaction + '/statuses/' + str(reactionStatusNumber) + + return _reaction(recentPostsCache, + session, baseDir, federationList, + nickname, domain, port, + ccList, httpPrefix, objectUrl, emojiContent, + actorReaction, clientToServer, + sendThreads, postLog, personCache, cachedWebfingers, + debug, projectVersion, signingPrivateKeyPem) + + +def sendReactionViaServer(baseDir: str, session, + fromNickname: str, password: str, + fromDomain: str, fromPort: int, + httpPrefix: str, reactionUrl: str, + emojiContent: str, + cachedWebfingers: {}, personCache: {}, + debug: bool, projectVersion: str, + signingPrivateKeyPem: str) -> {}: + """Creates a reaction via c2s + """ + if not session: + print('WARN: No session for sendReactionViaServer') + return 6 + + fromDomainFull = getFullDomain(fromDomain, fromPort) + + actor = localActorUrl(httpPrefix, fromNickname, fromDomainFull) + + newReactionJson = { + "@context": "https://www.w3.org/ns/activitystreams", + 'type': 'EmojiReact', + 'actor': actor, + 'object': reactionUrl, + 'content': emojiContent + } + + handle = httpPrefix + '://' + fromDomainFull + '/@' + fromNickname + + # lookup the inbox for the To handle + wfRequest = webfingerHandle(session, handle, httpPrefix, + cachedWebfingers, + fromDomain, projectVersion, debug, False, + signingPrivateKeyPem) + if not wfRequest: + if debug: + print('DEBUG: reaction webfinger failed for ' + handle) + return 1 + if not isinstance(wfRequest, dict): + print('WARN: reaction webfinger for ' + handle + + ' did not return a dict. ' + str(wfRequest)) + return 1 + + postToBox = 'outbox' + + # get the actor inbox for the To handle + originDomain = fromDomain + (inboxUrl, pubKeyId, pubKey, fromPersonId, sharedInbox, avatarUrl, + displayName, _) = getPersonBox(signingPrivateKeyPem, + originDomain, + baseDir, session, wfRequest, + personCache, + projectVersion, httpPrefix, + fromNickname, fromDomain, + postToBox, 72873) + + if not inboxUrl: + if debug: + print('DEBUG: reaction no ' + postToBox + + ' was found for ' + handle) + return 3 + if not fromPersonId: + if debug: + print('DEBUG: reaction no actor was found for ' + handle) + return 4 + + authHeader = createBasicAuthHeader(fromNickname, password) + + headers = { + 'host': fromDomain, + 'Content-type': 'application/json', + 'Authorization': authHeader + } + postResult = postJson(httpPrefix, fromDomainFull, + session, newReactionJson, [], inboxUrl, + headers, 3, True) + if not postResult: + if debug: + print('WARN: POST reaction failed for c2s to ' + inboxUrl) + return 5 + + if debug: + print('DEBUG: c2s POST reaction success') + + return newReactionJson + + +def sendUndoReactionViaServer(baseDir: str, session, + fromNickname: str, password: str, + fromDomain: str, fromPort: int, + httpPrefix: str, reactionUrl: str, + emojiContent: str, + cachedWebfingers: {}, personCache: {}, + debug: bool, projectVersion: str, + signingPrivateKeyPem: str) -> {}: + """Undo a reaction via c2s + """ + if not session: + print('WARN: No session for sendUndoReactionViaServer') + return 6 + + fromDomainFull = getFullDomain(fromDomain, fromPort) + + actor = localActorUrl(httpPrefix, fromNickname, fromDomainFull) + + newUndoReactionJson = { + "@context": "https://www.w3.org/ns/activitystreams", + 'type': 'Undo', + 'actor': actor, + 'object': { + 'type': 'EmojiReact', + 'actor': actor, + 'object': reactionUrl, + 'content': emojiContent + } + } + + handle = httpPrefix + '://' + fromDomainFull + '/@' + fromNickname + + # lookup the inbox for the To handle + wfRequest = webfingerHandle(session, handle, httpPrefix, + cachedWebfingers, + fromDomain, projectVersion, debug, False, + signingPrivateKeyPem) + if not wfRequest: + if debug: + print('DEBUG: unreaction webfinger failed for ' + handle) + return 1 + if not isinstance(wfRequest, dict): + if debug: + print('WARN: unreaction webfinger for ' + handle + + ' did not return a dict. ' + str(wfRequest)) + return 1 + + postToBox = 'outbox' + + # get the actor inbox for the To handle + originDomain = fromDomain + (inboxUrl, pubKeyId, pubKey, fromPersonId, sharedInbox, avatarUrl, + displayName, _) = getPersonBox(signingPrivateKeyPem, + originDomain, + baseDir, session, wfRequest, + personCache, projectVersion, + httpPrefix, fromNickname, + fromDomain, postToBox, + 72625) + + if not inboxUrl: + if debug: + print('DEBUG: unreaction no ' + postToBox + + ' was found for ' + handle) + return 3 + if not fromPersonId: + if debug: + print('DEBUG: unreaction no actor was found for ' + handle) + return 4 + + authHeader = createBasicAuthHeader(fromNickname, password) + + headers = { + 'host': fromDomain, + 'Content-type': 'application/json', + 'Authorization': authHeader + } + postResult = postJson(httpPrefix, fromDomainFull, + session, newUndoReactionJson, [], inboxUrl, + headers, 3, True) + if not postResult: + if debug: + print('WARN: POST unreaction failed for c2s to ' + inboxUrl) + return 5 + + if debug: + print('DEBUG: c2s POST unreaction success') + + return newUndoReactionJson + + +def outboxReaction(recentPostsCache: {}, + baseDir: str, httpPrefix: str, + nickname: str, domain: str, port: int, + messageJson: {}, debug: bool) -> None: + """ When a reaction request is received by the outbox from c2s + """ + if not messageJson.get('type'): + if debug: + print('DEBUG: reaction - no type') + return + if not messageJson['type'] == 'EmojiReact': + if debug: + print('DEBUG: not a reaction') + return + if not hasObjectString(messageJson, debug): + return + if not messageJson.get('content'): + return + if not isinstance(messageJson['content'], str): + return + if debug: + print('DEBUG: c2s reaction request arrived in outbox') + + messageId = removeIdEnding(messageJson['object']) + domain = removeDomainPort(domain) + emojiContent = messageJson['content'] + postFilename = locatePost(baseDir, nickname, domain, messageId) + if not postFilename: + if debug: + print('DEBUG: c2s reaction post not found in inbox or outbox') + print(messageId) + return True + updateReactionCollection(recentPostsCache, + baseDir, postFilename, messageId, + messageJson['actor'], + nickname, domain, debug, None, emojiContent) + if debug: + print('DEBUG: post reaction via c2s - ' + postFilename) + + +def outboxUndoReaction(recentPostsCache: {}, + baseDir: str, httpPrefix: str, + nickname: str, domain: str, port: int, + messageJson: {}, debug: bool) -> None: + """ When an undo reaction request is received by the outbox from c2s + """ + if not messageJson.get('type'): + return + if not messageJson['type'] == 'Undo': + return + if not hasObjectStringType(messageJson, debug): + return + if not messageJson['object']['type'] == 'EmojiReact': + if debug: + print('DEBUG: not a undo reaction') + return + if not messageJson['object'].get('content'): + return + if not isinstance(messageJson['object']['content'], str): + return + if not hasObjectStringObject(messageJson, debug): + return + if debug: + print('DEBUG: c2s undo reaction request arrived in outbox') + + messageId = removeIdEnding(messageJson['object']['object']) + emojiContent = messageJson['object']['content'] + domain = removeDomainPort(domain) + postFilename = locatePost(baseDir, nickname, domain, messageId) + if not postFilename: + if debug: + print('DEBUG: c2s undo reaction post not found in inbox or outbox') + print(messageId) + return True + undoReactionCollectionEntry(recentPostsCache, baseDir, postFilename, + messageId, messageJson['actor'], + domain, debug, None, emojiContent) + if debug: + print('DEBUG: post undo reaction via c2s - ' + postFilename) + + +def updateReactionCollection(recentPostsCache: {}, + baseDir: str, postFilename: str, + objectUrl: str, actor: str, + nickname: str, domain: str, debug: bool, + postJsonObject: {}, + emojiContent: str) -> None: + """Updates the reactions collection within a post + """ + if not postJsonObject: + postJsonObject = loadJson(postFilename) + if not postJsonObject: + return + + # remove any cached version of this post so that the + # reaction icon is changed + removePostFromCache(postJsonObject, recentPostsCache) + cachedPostFilename = getCachedPostFilename(baseDir, nickname, + domain, postJsonObject) + if cachedPostFilename: + if os.path.isfile(cachedPostFilename): + try: + os.remove(cachedPostFilename) + except BaseException: + print('EX: updateReactionCollection unable to delete ' + + cachedPostFilename) + pass + + obj = postJsonObject + if hasObjectDict(postJsonObject): + obj = postJsonObject['object'] + + if not objectUrl.endswith('/reactions'): + objectUrl = objectUrl + '/reactions' + if not obj.get('reactions'): + if debug: + print('DEBUG: Adding initial emoji reaction to ' + objectUrl) + reactionsJson = { + "@context": "https://www.w3.org/ns/activitystreams", + 'id': objectUrl, + 'type': 'Collection', + "totalItems": 1, + 'items': [{ + 'type': 'EmojiReact', + 'actor': actor, + 'content': emojiContent + }] + } + obj['reactions'] = reactionsJson + else: + if not obj['reactions'].get('items'): + obj['reactions']['items'] = [] + for reactionItem in obj['reactions']['items']: + if reactionItem.get('actor') and reactionItem.get('content'): + if reactionItem['actor'] == actor and \ + reactionItem['content'] == emojiContent: + # already reaction + return + newReaction = { + 'type': 'EmojiReact', + 'actor': actor, + 'content': emojiContent + } + obj['reactions']['items'].append(newReaction) + itlen = len(obj['reactions']['items']) + obj['reactions']['totalItems'] = itlen + + if debug: + print('DEBUG: saving post with emoji reaction added') + pprint(postJsonObject) + saveJson(postJsonObject, postFilename) diff --git a/tests.py b/tests.py index 616389903..9a7fb019f 100644 --- a/tests.py +++ b/tests.py @@ -107,6 +107,8 @@ from auth import authorizeBasic from auth import storeBasicCredentials from like import likePost from like import sendLikeViaServer +from reaction import reactionPost +from reaction import sendReactionViaServer from announce import announcePublic from announce import sendAnnounceViaServer from city import parseNogoString @@ -1370,6 +1372,28 @@ def testPostMessageBetweenServers(baseDir: str) -> None: assert 'likes' in open(outboxPostFilename).read() + print('\n\n*******************************************************') + print("Bob reacts to Alice's post") + + assert reactionPost({}, sessionBob, bobDir, federationList, + 'bob', bobDomain, bobPort, httpPrefix, + 'alice', aliceDomain, alicePort, [], + statusNumber, '😀', + False, bobSendThreads, bobPostLog, + bobPersonCache, bobCachedWebfingers, + True, __version__, signingPrivateKeyPem) + + for i in range(20): + if 'reactions' in open(outboxPostFilename).read(): + break + time.sleep(1) + + alicePostJson = loadJson(outboxPostFilename, 0) + if alicePostJson: + pprint(alicePostJson) + + assert 'reactions' in open(outboxPostFilename).read() + print('\n\n*******************************************************') print("Bob repeats Alice's post") objectUrl = \ @@ -3071,22 +3095,62 @@ def testClientToServer(baseDir: str): time.sleep(1) showTestBoxes('alice', aliceInboxPath, aliceOutboxPath) showTestBoxes('bob', bobInboxPath, bobOutboxPath) - assert len([name for name in os.listdir(bobOutboxPath) - if os.path.isfile(os.path.join(bobOutboxPath, name))]) == 2 - assert len([name for name in os.listdir(aliceInboxPath) - if os.path.isfile(os.path.join(aliceInboxPath, name))]) == 0 + bobOutboxPathCtr = \ + len([name for name in os.listdir(bobOutboxPath) + if os.path.isfile(os.path.join(bobOutboxPath, name))]) + print('bobOutboxPathCtr: ' + str(bobOutboxPathCtr)) + assert bobOutboxPathCtr == 2 + aliceInboxPathCtr = \ + len([name for name in os.listdir(aliceInboxPath) + if os.path.isfile(os.path.join(aliceInboxPath, name))]) + print('aliceInboxPathCtr: ' + str(aliceInboxPathCtr)) + assert aliceInboxPathCtr == 0 print('EVENT: Post liked') + print('\n\nEVENT: Bob reacts to the post') + sendReactionViaServer(bobDir, sessionBob, + 'bob', 'bobpass', + bobDomain, bobPort, + httpPrefix, outboxPostId, '😃', + cachedWebfingers, personCache, + True, __version__, signingPrivateKeyPem) + for i in range(20): + if os.path.isdir(outboxPath) and os.path.isdir(inboxPath): + if len([name for name in os.listdir(outboxPath) + if os.path.isfile(os.path.join(outboxPath, name))]) == 3: + test = len([name for name in os.listdir(inboxPath) + if os.path.isfile(os.path.join(inboxPath, name))]) + if test == 1: + break + time.sleep(1) + showTestBoxes('alice', aliceInboxPath, aliceOutboxPath) + showTestBoxes('bob', bobInboxPath, bobOutboxPath) + bobOutboxPathCtr = \ + len([name for name in os.listdir(bobOutboxPath) + if os.path.isfile(os.path.join(bobOutboxPath, name))]) + print('bobOutboxPathCtr: ' + str(bobOutboxPathCtr)) + assert bobOutboxPathCtr == 3 + aliceInboxPathCtr = \ + len([name for name in os.listdir(aliceInboxPath) + if os.path.isfile(os.path.join(aliceInboxPath, name))]) + print('aliceInboxPathCtr: ' + str(aliceInboxPathCtr)) + assert aliceInboxPathCtr == 0 + print('EVENT: Post reacted to') + print(str(len([name for name in os.listdir(outboxPath) if os.path.isfile(os.path.join(outboxPath, name))]))) showTestBoxes('alice', aliceInboxPath, aliceOutboxPath) showTestBoxes('bob', bobInboxPath, bobOutboxPath) - assert len([name for name in os.listdir(outboxPath) - if os.path.isfile(os.path.join(outboxPath, name))]) == 2 - print(str(len([name for name in os.listdir(inboxPath) - if os.path.isfile(os.path.join(inboxPath, name))]))) - assert len([name for name in os.listdir(aliceInboxPath) - if os.path.isfile(os.path.join(aliceInboxPath, name))]) == 0 + outboxPathCtr = \ + len([name for name in os.listdir(outboxPath) + if os.path.isfile(os.path.join(outboxPath, name))]) + print('outboxPathCtr: ' + str(outboxPathCtr)) + assert outboxPathCtr == 3 + inboxPathCtr = \ + len([name for name in os.listdir(inboxPath) + if os.path.isfile(os.path.join(inboxPath, name))]) + print('inboxPathCtr: ' + str(inboxPathCtr)) + assert inboxPathCtr == 0 showTestBoxes('alice', aliceInboxPath, aliceOutboxPath) showTestBoxes('bob', bobInboxPath, bobOutboxPath) print('\n\nEVENT: Bob repeats the post') @@ -3100,7 +3164,7 @@ def testClientToServer(baseDir: str): for i in range(20): if os.path.isdir(outboxPath) and os.path.isdir(inboxPath): if len([name for name in os.listdir(outboxPath) - if os.path.isfile(os.path.join(outboxPath, name))]) == 3: + if os.path.isfile(os.path.join(outboxPath, name))]) == 4: if len([name for name in os.listdir(inboxPath) if os.path.isfile(os.path.join(inboxPath, name))]) == 2: @@ -3109,10 +3173,16 @@ def testClientToServer(baseDir: str): showTestBoxes('alice', aliceInboxPath, aliceOutboxPath) showTestBoxes('bob', bobInboxPath, bobOutboxPath) - assert len([name for name in os.listdir(bobOutboxPath) - if os.path.isfile(os.path.join(bobOutboxPath, name))]) == 4 - assert len([name for name in os.listdir(aliceInboxPath) - if os.path.isfile(os.path.join(aliceInboxPath, name))]) == 1 + bobOutboxPathCtr = \ + len([name for name in os.listdir(bobOutboxPath) + if os.path.isfile(os.path.join(bobOutboxPath, name))]) + print('bobOutboxPathCtr: ' + str(bobOutboxPathCtr)) + assert bobOutboxPathCtr == 5 + aliceInboxPathCtr = \ + len([name for name in os.listdir(aliceInboxPath) + if os.path.isfile(os.path.join(aliceInboxPath, name))]) + print('aliceInboxPathCtr: ' + str(aliceInboxPathCtr)) + assert aliceInboxPathCtr == 1 print('EVENT: Post repeated') inboxPath = bobDir + '/accounts/bob@' + bobDomain + '/inbox' @@ -4581,7 +4651,8 @@ def _testFunctions(): 'E2EEremoveDevice', 'setOrganizationScheme', 'fill_headers', - '_nothing' + '_nothing', + 'noOfReactions' ] excludeImports = [ 'link', diff --git a/utils.py b/utils.py index e67a22a59..09eb9003b 100644 --- a/utils.py +++ b/utils.py @@ -2258,6 +2258,72 @@ def undoLikesCollectionEntry(recentPostsCache: {}, saveJson(postJsonObject, postFilename) +def undoReactionCollectionEntry(recentPostsCache: {}, + baseDir: str, postFilename: str, + objectUrl: str, + actor: str, domain: str, debug: bool, + postJsonObject: {}, emojiContent: str) -> None: + """Undoes an emoji reaction for a particular actor + """ + if not postJsonObject: + postJsonObject = loadJson(postFilename) + if not postJsonObject: + return + # remove any cached version of this post so that the + # like icon is changed + nickname = getNicknameFromActor(actor) + cachedPostFilename = getCachedPostFilename(baseDir, nickname, + domain, postJsonObject) + if cachedPostFilename: + if os.path.isfile(cachedPostFilename): + try: + os.remove(cachedPostFilename) + except BaseException: + print('EX: undoReactionCollectionEntry ' + + 'unable to delete cached post ' + + str(cachedPostFilename)) + pass + removePostFromCache(postJsonObject, recentPostsCache) + + if not postJsonObject.get('type'): + return + if postJsonObject['type'] != 'Create': + return + obj = postJsonObject + if hasObjectDict(postJsonObject): + obj = postJsonObject['object'] + if not obj.get('reactions'): + return + if not isinstance(obj['reactions'], dict): + return + if not obj['reactions'].get('items'): + return + totalItems = 0 + if obj['reactions'].get('totalItems'): + totalItems = obj['reactions']['totalItems'] + itemFound = False + for likeItem in obj['reactions']['items']: + if likeItem.get('actor'): + if likeItem['actor'] == actor and \ + likeItem['content'] == emojiContent: + if debug: + print('DEBUG: emoji reaction was removed for ' + actor) + obj['reactions']['items'].remove(likeItem) + itemFound = True + break + if not itemFound: + return + if totalItems == 1: + if debug: + print('DEBUG: emoji reaction was removed from post') + del obj['reactions'] + else: + itlen = len(obj['reactions']['items']) + obj['reactions']['totalItems'] = itlen + + saveJson(postJsonObject, postFilename) + + def undoAnnounceCollectionEntry(recentPostsCache: {}, baseDir: str, postFilename: str, actor: str, domain: str, debug: bool) -> None: