Handle emoji reactions

merge-requests/30/head
Bob Mottram 2021-11-10 12:16:03 +00:00
parent 5b9af53cf9
commit 68badb0e36
9 changed files with 1192 additions and 38 deletions

View File

@ -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)

View File

@ -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')

419
inbox.py
View File

@ -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,

32
like.py
View File

@ -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,

View File

@ -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,

View File

@ -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'

496
reaction.py 100644
View File

@ -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)

103
tests.py
View File

@ -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',

View File

@ -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: