diff --git a/README.md b/README.md index 60743e433..ce9a2d5f8 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Add issues on https://gitlab.com/bashrc2/epicyon/-/issues -Epicyon is a modern [ActivityPub](https://www.w3.org/TR/activitypub) compliant server implementing both S2S and C2S protocols and sutable for installation on single board computers. It includes features such as moderation tools, post expiry, content warnings, image descriptions, news feed and perimeter defense against adversaries. It contains *no javascript* and uses HTML+CSS with a Python backend. +Epicyon is a modern [ActivityPub](https://www.w3.org/TR/activitypub) compliant server implementing both S2S and C2S protocols and suitable for installation on single board computers. It includes features such as moderation tools, post expiry, content warnings, image descriptions, news feed and perimeter defense against adversaries. It contains *no JavaScript* and uses HTML+CSS with a Python backend. [Project Goals](README_goals.md) - [Commandline interface](README_commandline.md) - [Customizations](README_customizations.md) - [Code of Conduct](code-of-conduct.md) @@ -238,7 +238,7 @@ Please be aware that such installations will not federate with ordinary fedivers ## Custom Fonts -If you want to use a particular font then copy it into the *fonts* directory, rename it as *custom.ttf/woff/woff2/otf* and then restart the epicyon daemon. +If you want to use a particular font then copy it into the *fonts* directory, rename it as *custom.ttf/woff/woff2/otf* and then restart the Epicyon daemon. ``` bash systemctl restart epicyon diff --git a/README_commandline.md b/README_commandline.md index 0fc3491f5..57388b8ab 100644 --- a/README_commandline.md +++ b/README_commandline.md @@ -1,6 +1,6 @@ -# Commandline Admin +# Command-line Admin -This system can be administrated from the commandline. +This system can be administrated from the command-line. ## Account Management @@ -52,7 +52,7 @@ To remove an account (be careful!): python3 epicyon.py --rmgroup nickname@domain ``` -Setting avatar or changing background is the same as for any other account on the system. You can also moderate a group, applying filters, blocks or a perimeter, in the same way as for other acounts. +Setting avatar or changing background is the same as for any other account on the system. You can also moderate a group, applying filters, blocks or a perimeter, in the same way as for other accounts. ## Defining a perimeter @@ -76,7 +76,7 @@ The password is for the client to obtain access to the server. You may or may not need to use the *--port*, *--https* and *--tor* options, depending upon how your server was set up. -Unfollowing is silimar: +Unfollowing is similar: ``` bash python3 epicyon.py --nickname [yournick] --domain [name] --unfollow othernick@domain --password [c2s password] @@ -131,12 +131,22 @@ To view the public posts for a person: python3 epicyon.py --posts nickname@domain ``` -If you want to view the raw json: +If you want to view the raw JSON: ``` bash python3 epicyon.py --postsraw nickname@domain ``` +## Getting the JSON for your timelines + +The **--posts** option applies for any ActivityPub compatible fediverse account with visible public posts. You can also use an authenticated version to obtain the paginated JSON for your inbox, outbox, direct messages, etc. + +``` bash +python3 epicyon.py --nickname [yournick] --domain [yourdomain] --box [inbox|outbox|dm] --page [number] --password [yourpassword] +``` + +You could use this to make your own c2s client, or create your own notification system. + ## Listing referenced domains To list the domains referenced in public posts: @@ -156,7 +166,7 @@ xdot socnet.dot ## Delete posts -To delete a post which you wrote you must first know its url. It is usually something like: +To delete a post which you wrote you must first know its URL. It is usually something like: ``` text https://yourDomain/users/yourNickname/statuses/number @@ -177,7 +187,7 @@ Another complication of federated deletion is that the followers collection may ## Announcements/repeats/boosts -To announce or repeat a post you will first need to know it's url. It is usually something like: +To announce or repeat a post you will first need to know it's URL. It is usually something like: ``` text https://domain/users/name/statuses/number @@ -192,7 +202,7 @@ python3 epicyon.py --nickname [yournick] --domain [name] \ ## Like posts -To like a post you will first need to know it's url. It is usually something like: +To like a post you will first need to know it's URL. It is usually something like: ``` text https://domain/users/name/statuses/number @@ -240,7 +250,7 @@ Whether you are using the **--federate** option to define a set of allowed insta python3 epicyon.py --nickname yournick --domain yourdomain --block somenick@somedomain --password [c2s password] ``` -This blocks at the earliest possble stage of receiving messages, such that nothing from the specified account will be written to your inbox. +This blocks at the earliest possible stage of receiving messages, such that nothing from the specified account will be written to your inbox. Or to unblock: @@ -248,6 +258,22 @@ Or to unblock: python3 epicyon.py --nickname yournick --domain yourdomain --unblock somenick@somedomain --password [c2s password] ``` +## Bookmarking + +You may want to bookmark posts for later viewing or replying. This can be done via c2s with the following: + +``` bash +python3 epicyon.py --nickname yournick --domain yourdomain --bookmark [post URL] --password [c2s password] +``` + +Note that the URL must be that of an ActivityPub post in your timeline. Any other URL will be ignored. + +And to undo the bookmark: + +``` bash +python3 epicyon.py --nickname yournick --domain yourdomain --unbookmark [post URL] --password [c2s password] +``` + ## Filtering on words or phrases Blocking based upon the content of a message containing certain words or phrases is relatively crude and not always effective, but can help to reduce unwanted communications. @@ -313,7 +339,7 @@ python3 epicyon.py --nickname [admin nickname] --domain [mydomain] \ --password [c2s password] ``` -This extends the ActivityPub client-to-server protocol to include activities called *Delegate* and *Role*. The json looks like: +This extends the ActivityPub client-to-server protocol to include activities called *Delegate* and *Role*. The JSON looks like: ``` json { 'type': 'Delegate', @@ -343,7 +369,7 @@ python3 epicyon.py --nickname [nick] --domain [mydomain] \ The level value is a percentage which indicates how proficient you are with that skill. -This extends the ActivityPub client-to-server protocol to include an activity called *Skill*. The json looks like: +This extends the ActivityPub client-to-server protocol to include an activity called *Skill*. The JSON looks like: ``` json { 'type': 'Skill', @@ -365,7 +391,7 @@ python3 epicyon.py --nickname [nick] --domain [mydomain] \ The status value can be any string, and can become part of organization building by combining it with roles and skills. -This extends the ActivityPub client-to-server protocol to include an activity called *Availability*. "Status" was avoided because of te possibility of confusion with other things. The json looks like: +This extends the ActivityPub client-to-server protocol to include an activity called *Availability*. "Status" was avoided because of the possibility of confusion with other things. The JSON looks like: ``` json { 'type': 'Availability', @@ -377,7 +403,7 @@ This extends the ActivityPub client-to-server protocol to include an activity ca ## Shares -This system includes a feature for bartering or gifting (i.e. common resource pooling or exchange without money), based upon the earlier Sharings plugin made by the Las Indias group which existed within GNU Social. It's intended to operate at the municipal level, sharing physical objects with people in your local vicinity. For example, sharing gardening tools on a street or a 3D printer between makerspaces. +This system includes a feature for bartering or gifting (i.e. common resource pooling or exchange without money), based upon the earlier Sharings plugin made by the Las Indias group which existed within GNU Social. It's intended to operate at the municipal level, sharing physical objects with people in your local vicinity. For example, sharing gardening tools on a street or a 3D printer between maker-spaces. To share an item. @@ -385,7 +411,7 @@ To share an item. python3 epicyon.py --itemName "spanner" --nickname [yournick] --domain [yourdomain] --summary "It's a spanner" --itemType "tool" --itemCategory "mechanical" --location [yourCity] --duration "2 months" --itemImage spanner.png --password [c2s password] ``` -For the duration of the share you can use hours,days,weeks,months or years. +For the duration of the share you can use hours, days, weeks, months, or years. To remove a shared item: @@ -422,25 +448,30 @@ Or if you have picospeaker installed: The desktop client has a few commands, which may be more convenient than the web interface for some purposes: ``` bash -quit Exit from the notification client -mute Turn off the screen reader -speak Turn on the screen reader -sounds on Turn on notification sounds -sounds off Turn off notification sounds -rp Repeat the last post -like Like the last post -unlike Unlike the last post -reply Reply to the last post -post Create a new post -post to [handle] Create a new direct message -announce/boost Boost the last post -follow [handle] Make a follow request -unfollow [handle] Stop following the give handle -show dm|sent|inbox|replies Show a timeline -next Next page in the timeline -prev Previous page in the timeline -read [post number] Read a post from a timeline -open [post number] Open web links within a timeline post +quit Exit from the desktop client +mute Turn off the screen reader +speak Turn on the screen reader +sounds on Turn on notification sounds +sounds off Turn off notification sounds +rp Repeat the last post +like Like the last post +unlike Unlike the last post +bookmark Bookmark the last post +unbookmark Unbookmark the last post +mute Mute the last post +unmute Unmute the last post +reply Reply to the last post +post Create a new post +post to [handle] Create a new direct message +announce/boost Boost the last post +follow [handle] Make a follow request +unfollow [handle] Stop following the give handle +show dm|sent|inbox|replies|bookmarks Show a timeline +next Next page in the timeline +prev Previous page in the timeline +read [post number] Read a post from a timeline +open [post number] Open web links within a timeline post +profile [post number] Show profile for the person who made the given post ``` If you have a GPG key configured on your local system and are sending a direct message to someone who has a PGP key (the exported key, not just the key ID) set as a tag on their profile then it will try to encrypt the message automatically. So under some conditions end-to-end encryption is possible, such that the instance server only sees ciphertext. Conversely, for arriving direct messages if they are PGP encrypted then the desktop client will try to obtain the relevant public key and decrypt. diff --git a/README_customizations.md b/README_customizations.md index 841596df8..a6f86ad7d 100644 --- a/README_customizations.md +++ b/README_customizations.md @@ -28,4 +28,4 @@ Extra emoji can be added to the *emoji* directory and you should then update the ## Themes -If you want to create a new theme then the functions for that are within *theme.py*. These functions take the css templates and modify them. You will need to edit *themesDropdown* within *webinterface.py* and add the appropriate translations for the theme name. Themes are selectable from the profile screen of the administrator. +If you want to create a new theme then the functions for that are within *theme.py*. These functions take the CSS templates and modify them. You will need to edit *themesDropdown* within *webinterface.py* and add the appropriate translations for the theme name. Themes are selectable from the profile screen of the administrator. diff --git a/README_goals.md b/README_goals.md index 1fd443003..8403bba5c 100644 --- a/README_goals.md +++ b/README_goals.md @@ -10,22 +10,22 @@ * Attention to accessibility and should be usable in lynx with a screen reader * Remove metadata from attached images, avatars and backgrounds * Support for multiple themes, with ability to create custom themes - * Being able to build crowdsouced organizations with roles and skills + * Being able to build crowd-sourced organizations with roles and skills * Sharings collection, similar to the gnusocial sharings plugin * Quotas for received posts per day, per domain and per account - * Hellthread detection and removal + * Hell-thread detection and removal * Instance and account level federation lists * Support content warnings, reporting and blocking * http signatures and basic auth - * json-LD signatures on outgoing posts, optional on incoming - * Compatible with http (onion addresses, i2p), https and hypercore + * JSON-LD signatures on outgoing posts, optional on incoming + * Compatible with HTTP (onion addresses, i2p), HTTPS and hypercore * Minimal dependencies * Dependencies are maintained Debian packages * Data minimization principle. Configurable post expiry time * Likes and repeats only visible to authorized viewers - * ReplyGuy mitigation - maxmimum replies per post or posts per day + * Reply Guy mitigation - maximum replies per post or posts per day * Ability to delete or hide specific conversation threads - * Commandline interface + * Command-line interface * Simple web interface * Designed for intermittent connectivity. Assume network disruptions * Limited visibility of follows/followers @@ -36,17 +36,17 @@ **Features which won't be implemented** -The following are considered antifeatures of other social network systems, since they encourage dysfunctional social interactions. +The following are considered anti-features of other social network systems, since they encourage dysfunctional social interactions. * Features designed to scale to large numbers of accounts (say, more than 20 active users) * Trending hashtags, or trending anything * Ranking, rating or recommending mechanisms for posts or people (other than likes or repeats/boosts) - * Geolocation features + * Geo-location features * Algorithmic timelines (i.e. non-chronological) * Direct payment mechanisms, although integration with other services may be possible * Any variety of blockchain * Sponsored posts * Enterprise features for use cases applicable only to businesses. Epicyon could be used in a small business, but it's not primarily designed for that - * Collaborative editing of posts, although you could do that outside of this system using etherpad, or similar + * Collaborative editing of posts, although you could do that outside of this system using Etherpad, or similar * Anonymous posts from random internet users published under a single generic instance account * Hierarchies of roles beyond ordinary moderation, such as X requires special agreement from Y before sending a post diff --git a/announce.py b/announce.py index 60679e4de..435cd911e 100644 --- a/announce.py +++ b/announce.py @@ -225,8 +225,8 @@ def sendAnnounceViaServer(baseDir: str, session, print('DEBUG: announce webfinger failed for ' + handle) return 1 if not isinstance(wfRequest, dict): - print('WARN: Webfinger for ' + handle + ' did not return a dict. ' + - str(wfRequest)) + print('WARN: announce webfinger for ' + handle + + ' did not return a dict. ' + str(wfRequest)) return 1 postToBox = 'outbox' @@ -242,11 +242,12 @@ def sendAnnounceViaServer(baseDir: str, session, if not inboxUrl: if debug: - print('DEBUG: No ' + postToBox + ' was found for ' + handle) + print('DEBUG: announce no ' + postToBox + + ' was found for ' + handle) return 3 if not fromPersonId: if debug: - print('DEBUG: No actor was found for ' + handle) + print('DEBUG: announce no actor was found for ' + handle) return 4 authHeader = createBasicAuthHeader(fromNickname, password) @@ -257,11 +258,90 @@ def sendAnnounceViaServer(baseDir: str, session, 'Authorization': authHeader } postResult = postJson(session, newAnnounceJson, [], inboxUrl, - headers, 30, True) + headers, 3, True) if not postResult: - print('WARN: Announce not posted') + print('WARN: announce not posted') if debug: print('DEBUG: c2s POST announce success') return newAnnounceJson + + +def sendUndoAnnounceViaServer(baseDir: str, session, + undoPostJsonObject: {}, + nickname: str, password: str, + domain: str, port: int, + httpPrefix: str, repeatObjectUrl: str, + cachedWebfingers: {}, personCache: {}, + debug: bool, projectVersion: str) -> {}: + """Undo an announce message via c2s + """ + if not session: + print('WARN: No session for sendUndoAnnounceViaServer') + return 6 + + domainFull = getFullDomain(domain, port) + + actor = httpPrefix + '://' + domainFull + '/users/' + nickname + handle = actor.replace('/users/', '/@') + + statusNumber, published = getStatusNumber() + unAnnounceJson = { + '@context': 'https://www.w3.org/ns/activitystreams', + 'id': actor + '/statuses/' + str(statusNumber) + '/undo', + 'type': 'Undo', + 'actor': actor, + 'object': undoPostJsonObject['object'] + } + + # lookup the inbox for the To handle + wfRequest = webfingerHandle(session, handle, httpPrefix, + cachedWebfingers, + domain, projectVersion, debug) + if not wfRequest: + if debug: + print('DEBUG: undo announce webfinger failed for ' + handle) + return 1 + if not isinstance(wfRequest, dict): + print('WARN: undo announce webfinger for ' + handle + + ' did not return a dict. ' + str(wfRequest)) + return 1 + + postToBox = 'outbox' + + # get the actor inbox for the To handle + (inboxUrl, pubKeyId, pubKey, fromPersonId, + sharedInbox, avatarUrl, + displayName) = getPersonBox(baseDir, session, wfRequest, + personCache, + projectVersion, httpPrefix, + nickname, domain, + postToBox, 73528) + + if not inboxUrl: + if debug: + print('DEBUG: undo announce no ' + postToBox + + ' was found for ' + handle) + return 3 + if not fromPersonId: + if debug: + print('DEBUG: undo announce no actor was found for ' + handle) + return 4 + + authHeader = createBasicAuthHeader(nickname, password) + + headers = { + 'host': domain, + 'Content-type': 'application/json', + 'Authorization': authHeader + } + postResult = postJson(session, unAnnounceJson, [], inboxUrl, + headers, 3, True) + if not postResult: + print('WARN: undo announce not posted') + + if debug: + print('DEBUG: c2s POST undo announce success') + + return unAnnounceJson diff --git a/availability.py b/availability.py index 5b88c4103..7b8196448 100644 --- a/availability.py +++ b/availability.py @@ -108,11 +108,11 @@ def sendAvailabilityViaServer(baseDir: str, session, domain, projectVersion, debug) if not wfRequest: if debug: - print('DEBUG: announce webfinger failed for ' + handle) + print('DEBUG: availability webfinger failed for ' + handle) return 1 if not isinstance(wfRequest, dict): - print('WARN: Webfinger for ' + handle + ' did not return a dict. ' + - str(wfRequest)) + print('WARN: availability webfinger for ' + handle + + ' did not return a dict. ' + str(wfRequest)) return 1 postToBox = 'outbox' @@ -127,11 +127,12 @@ def sendAvailabilityViaServer(baseDir: str, session, if not inboxUrl: if debug: - print('DEBUG: No ' + postToBox + ' was found for ' + handle) + print('DEBUG: availability no ' + postToBox + + ' was found for ' + handle) return 3 if not fromPersonId: if debug: - print('DEBUG: No actor was found for ' + handle) + print('DEBUG: availability no actor was found for ' + handle) return 4 authHeader = createBasicAuthHeader(nickname, password) @@ -144,7 +145,7 @@ def sendAvailabilityViaServer(baseDir: str, session, postResult = postJson(session, newAvailabilityJson, [], inboxUrl, headers, 30, True) if not postResult: - print('WARN: failed to post availability') + print('WARN: availability failed to post') if debug: print('DEBUG: c2s POST availability success') diff --git a/blocking.py b/blocking.py index 29aa2a45b..ff59f2d4b 100644 --- a/blocking.py +++ b/blocking.py @@ -7,7 +7,11 @@ __email__ = "bob@freedombone.net" __status__ = "Production" import os +import json from datetime import datetime +from utils import getCachedPostFilename +from utils import loadJson +from utils import saveJson from utils import fileLastModified from utils import setConfigParam from utils import hasUsersPath @@ -361,6 +365,268 @@ def outboxUndoBlock(baseDir: str, httpPrefix: str, print('DEBUG: post undo blocked via c2s - ' + postFilename) +def mutePost(baseDir: str, nickname: str, domain: str, port: int, + httpPrefix: str, postId: str, recentPostsCache: {}, + debug: bool) -> None: + """ Mutes the given post + """ + postFilename = locatePost(baseDir, nickname, domain, postId) + if not postFilename: + return + postJsonObject = loadJson(postFilename) + if not postJsonObject: + return + + if postJsonObject.get('object'): + if isinstance(postJsonObject['object'], dict): + domainFull = getFullDomain(domain, port) + actor = httpPrefix + '://' + domainFull + '/users/' + nickname + # does this post have ignores on it from differenent actors? + if not postJsonObject['object'].get('ignores'): + if debug: + print('DEBUG: Adding initial mute to ' + postId) + ignoresJson = { + "@context": "https://www.w3.org/ns/activitystreams", + 'id': postId, + 'type': 'Collection', + "totalItems": 1, + 'items': [{ + 'type': 'Ignore', + 'actor': actor + }] + } + postJsonObject['object']['ignores'] = ignoresJson + else: + if not postJsonObject['object']['ignores'].get('items'): + postJsonObject['object']['ignores']['items'] = [] + itemsList = postJsonObject['object']['ignores']['items'] + for ignoresItem in itemsList: + if ignoresItem.get('actor'): + if ignoresItem['actor'] == actor: + return + newIgnore = { + 'type': 'Ignore', + 'actor': actor + } + igIt = len(itemsList) + itemsList.append(newIgnore) + postJsonObject['object']['ignores']['totalItems'] = igIt + saveJson(postJsonObject, postFilename) + + # remove cached post so that the muted version gets recreated + # without its content text and/or image + cachedPostFilename = \ + getCachedPostFilename(baseDir, nickname, domain, postJsonObject) + if cachedPostFilename: + if os.path.isfile(cachedPostFilename): + os.remove(cachedPostFilename) + + muteFile = open(postFilename + '.muted', 'w+') + if muteFile: + muteFile.write('\n') + muteFile.close() + print('MUTE: ' + postFilename + '.muted file added') + + # if the post is in the recent posts cache then mark it as muted + if recentPostsCache.get('index'): + postId = \ + removeIdEnding(postJsonObject['id']).replace('/', '#') + if postId in recentPostsCache['index']: + print('MUTE: ' + postId + ' is in recent posts cache') + if recentPostsCache['json'].get(postId): + postJsonObject['muted'] = True + recentPostsCache['json'][postId] = json.dumps(postJsonObject) + if recentPostsCache.get('html'): + if recentPostsCache['html'].get(postId): + del recentPostsCache['html'][postId] + print('MUTE: ' + postId + + ' marked as muted in recent posts memory cache') + + +def unmutePost(baseDir: str, nickname: str, domain: str, port: int, + httpPrefix: str, postId: str, recentPostsCache: {}, + debug: bool) -> None: + """ Unmutes the given post + """ + postFilename = locatePost(baseDir, nickname, domain, postId) + if not postFilename: + return + postJsonObject = loadJson(postFilename) + if not postJsonObject: + return + + muteFilename = postFilename + '.muted' + if os.path.isfile(muteFilename): + os.remove(muteFilename) + print('UNMUTE: ' + muteFilename + ' file removed') + + if postJsonObject.get('object'): + if isinstance(postJsonObject['object'], dict): + if postJsonObject['object'].get('ignores'): + domainFull = getFullDomain(domain, port) + actor = httpPrefix + '://' + domainFull + '/users/' + nickname + totalItems = 0 + if postJsonObject['object']['ignores'].get('totalItems'): + totalItems = \ + postJsonObject['object']['ignores']['totalItems'] + itemsList = postJsonObject['object']['ignores']['items'] + for ignoresItem in itemsList: + if ignoresItem.get('actor'): + if ignoresItem['actor'] == actor: + if debug: + print('DEBUG: mute was removed for ' + actor) + itemsList.remove(ignoresItem) + break + if totalItems == 1: + if debug: + print('DEBUG: mute was removed from post') + del postJsonObject['object']['ignores'] + else: + igItLen = len(postJsonObject['object']['ignores']['items']) + postJsonObject['object']['ignores']['totalItems'] = igItLen + saveJson(postJsonObject, postFilename) + + # remove cached post so that the muted version gets recreated + # with its content text and/or image + cachedPostFilename = \ + getCachedPostFilename(baseDir, nickname, domain, postJsonObject) + if cachedPostFilename: + if os.path.isfile(cachedPostFilename): + os.remove(cachedPostFilename) + + # if the post is in the recent posts cache then mark it as unmuted + if recentPostsCache.get('index'): + postId = \ + removeIdEnding(postJsonObject['id']).replace('/', '#') + if postId in recentPostsCache['index']: + print('UNMUTE: ' + postId + ' is in recent posts cache') + if recentPostsCache['json'].get(postId): + postJsonObject['muted'] = False + recentPostsCache['json'][postId] = json.dumps(postJsonObject) + if recentPostsCache.get('html'): + if recentPostsCache['html'].get(postId): + del recentPostsCache['html'][postId] + print('UNMUTE: ' + postId + + ' marked as unmuted in recent posts cache') + + +def outboxMute(baseDir: str, httpPrefix: str, + nickname: str, domain: str, port: int, + messageJson: {}, debug: bool, + recentPostsCache: {}) -> None: + """When a mute is received by the outbox from c2s + """ + if not messageJson.get('type'): + return + if not messageJson.get('actor'): + return + domainFull = getFullDomain(domain, port) + if not messageJson['actor'].endswith(domainFull + '/users/' + nickname): + return + if not messageJson['type'] == 'Ignore': + return + if not messageJson.get('object'): + if debug: + print('DEBUG: no object in mute') + return + if not isinstance(messageJson['object'], str): + if debug: + print('DEBUG: mute object is not string') + return + if debug: + print('DEBUG: c2s mute request arrived in outbox') + + messageId = removeIdEnding(messageJson['object']) + if '/statuses/' not in messageId: + if debug: + print('DEBUG: c2s mute object is not a status') + return + if not hasUsersPath(messageId): + if debug: + print('DEBUG: c2s mute object has no nickname') + return + if ':' in domain: + domain = domain.split(':')[0] + postFilename = locatePost(baseDir, nickname, domain, messageId) + if not postFilename: + if debug: + print('DEBUG: c2s mute post not found in inbox or outbox') + print(messageId) + return + nicknameMuted = getNicknameFromActor(messageJson['object']) + if not nicknameMuted: + print('WARN: unable to find nickname in ' + messageJson['object']) + return + + mutePost(baseDir, nickname, domain, port, + httpPrefix, messageJson['object'], recentPostsCache, + debug) + + if debug: + print('DEBUG: post muted via c2s - ' + postFilename) + + +def outboxUndoMute(baseDir: str, httpPrefix: str, + nickname: str, domain: str, port: int, + messageJson: {}, debug: bool, + recentPostsCache: {}) -> None: + """When an undo mute is received by the outbox from c2s + """ + if not messageJson.get('type'): + return + if not messageJson.get('actor'): + return + domainFull = getFullDomain(domain, port) + if not messageJson['actor'].endswith(domainFull + '/users/' + nickname): + return + if not messageJson['type'] == 'Undo': + return + if not messageJson.get('object'): + return + if not isinstance(messageJson['object'], dict): + return + if not messageJson['object'].get('type'): + return + if messageJson['object']['type'] != 'Ignore': + return + if not isinstance(messageJson['object']['object'], str): + if debug: + print('DEBUG: undo mute object is not a string') + return + if debug: + print('DEBUG: c2s undo mute request arrived in outbox') + + messageId = removeIdEnding(messageJson['object']['object']) + if '/statuses/' not in messageId: + if debug: + print('DEBUG: c2s undo mute object is not a status') + return + if not hasUsersPath(messageId): + if debug: + print('DEBUG: c2s undo mute object has no nickname') + return + if ':' in domain: + domain = domain.split(':')[0] + postFilename = locatePost(baseDir, nickname, domain, messageId) + if not postFilename: + if debug: + print('DEBUG: c2s undo mute post not found in inbox or outbox') + print(messageId) + return + nicknameMuted = getNicknameFromActor(messageJson['object']['object']) + if not nicknameMuted: + print('WARN: unable to find nickname in ' + + messageJson['object']['object']) + return + + unmutePost(baseDir, nickname, domain, port, + httpPrefix, messageJson['object']['object'], + recentPostsCache, debug) + + if debug: + print('DEBUG: post undo mute via c2s - ' + postFilename) + + def setBrochMode(baseDir: str, domainFull: str, enabled: bool) -> None: """Broch mode can be used to lock down the instance during a period of time when it is temporarily under attack. diff --git a/bookmarks.py b/bookmarks.py index 4749ba505..f4e292c3a 100644 --- a/bookmarks.py +++ b/bookmarks.py @@ -8,6 +8,8 @@ __status__ = "Production" import os from pprint import pprint +from webfinger import webfingerHandle +from auth import createBasicAuthHeader from utils import hasUsersPath from utils import getFullDomain from utils import removeIdEnding @@ -19,6 +21,8 @@ from utils import locatePost from utils import getCachedPostFilename from utils import loadJson from utils import saveJson +from posts import getPersonBox +from session import postJson def undoBookmarksCollectionEntry(recentPostsCache: {}, @@ -67,8 +71,8 @@ def undoBookmarksCollectionEntry(recentPostsCache: {}, return if not postJsonObject.get('object'): if debug: - pprint(postJsonObject) - print('DEBUG: post ' + objectUrl + ' has no object') + print('DEBUG: bookmarked post has no object ' + + str(postJsonObject)) return if not isinstance(postJsonObject['object'], dict): return @@ -154,11 +158,12 @@ def updateBookmarksCollection(recentPostsCache: {}, if not postJsonObject.get('object'): if debug: - pprint(postJsonObject) - print('DEBUG: post ' + objectUrl + ' has no object') + print('DEBUG: no object in bookmarked post ' + + str(postJsonObject)) return if not objectUrl.endswith('/bookmarks'): objectUrl = objectUrl + '/bookmarks' + # does this post have bookmarks on it from differenent actors? if not postJsonObject['object'].get('bookmarks'): if debug: print('DEBUG: Adding initial bookmarks to ' + objectUrl) @@ -180,14 +185,14 @@ def updateBookmarksCollection(recentPostsCache: {}, if bookmarkItem.get('actor'): if bookmarkItem['actor'] == actor: return - newBookmark = { - 'type': 'Bookmark', - 'actor': actor - } - nb = newBookmark - bmIt = len(postJsonObject['object']['bookmarks']['items']) - postJsonObject['object']['bookmarks']['items'].append(nb) - postJsonObject['object']['bookmarks']['totalItems'] = bmIt + newBookmark = { + 'type': 'Bookmark', + 'actor': actor + } + nb = newBookmark + bmIt = len(postJsonObject['object']['bookmarks']['items']) + postJsonObject['object']['bookmarks']['items'].append(nb) + postJsonObject['object']['bookmarks']['totalItems'] = bmIt if debug: print('DEBUG: saving post with bookmarks added') @@ -341,6 +346,174 @@ def undoBookmark(recentPostsCache: {}, return newUndoBookmarkJson +def sendBookmarkViaServer(baseDir: str, session, + nickname: str, password: str, + domain: str, fromPort: int, + httpPrefix: str, bookmarkUrl: str, + cachedWebfingers: {}, personCache: {}, + debug: bool, projectVersion: str) -> {}: + """Creates a bookmark via c2s + """ + if not session: + print('WARN: No session for sendBookmarkViaServer') + return 6 + + domainFull = getFullDomain(domain, fromPort) + + actor = httpPrefix + '://' + domainFull + '/users/' + nickname + + newBookmarkJson = { + "@context": "https://www.w3.org/ns/activitystreams", + "type": "Add", + "actor": actor, + "to": [actor], + "object": { + "type": "Document", + "url": bookmarkUrl, + "to": [actor] + }, + "target": actor + "/tlbookmarks" + } + + handle = httpPrefix + '://' + domainFull + '/@' + nickname + + # lookup the inbox for the To handle + wfRequest = webfingerHandle(session, handle, httpPrefix, + cachedWebfingers, + domain, projectVersion, debug) + if not wfRequest: + if debug: + print('DEBUG: bookmark webfinger failed for ' + handle) + return 1 + if not isinstance(wfRequest, dict): + print('WARN: bookmark webfinger for ' + handle + + ' did not return a dict. ' + str(wfRequest)) + return 1 + + postToBox = 'outbox' + + # get the actor inbox for the To handle + (inboxUrl, pubKeyId, pubKey, fromPersonId, sharedInbox, + avatarUrl, displayName) = getPersonBox(baseDir, session, wfRequest, + personCache, + projectVersion, httpPrefix, + nickname, domain, + postToBox, 52594) + + if not inboxUrl: + if debug: + print('DEBUG: bookmark no ' + postToBox + + ' was found for ' + handle) + return 3 + if not fromPersonId: + if debug: + print('DEBUG: bookmark no actor was found for ' + handle) + return 4 + + authHeader = createBasicAuthHeader(nickname, password) + + headers = { + 'host': domain, + 'Content-type': 'application/json', + 'Authorization': authHeader + } + postResult = postJson(session, newBookmarkJson, [], inboxUrl, + headers, 3, True) + if not postResult: + if debug: + print('WARN: POST bookmark failed for c2s to ' + inboxUrl) + return 5 + + if debug: + print('DEBUG: c2s POST bookmark success') + + return newBookmarkJson + + +def sendUndoBookmarkViaServer(baseDir: str, session, + nickname: str, password: str, + domain: str, fromPort: int, + httpPrefix: str, bookmarkUrl: str, + cachedWebfingers: {}, personCache: {}, + debug: bool, projectVersion: str) -> {}: + """Removes a bookmark via c2s + """ + if not session: + print('WARN: No session for sendUndoBookmarkViaServer') + return 6 + + domainFull = getFullDomain(domain, fromPort) + + actor = httpPrefix + '://' + domainFull + '/users/' + nickname + + newBookmarkJson = { + "@context": "https://www.w3.org/ns/activitystreams", + "type": "Remove", + "actor": actor, + "to": [actor], + "object": { + "type": "Document", + "url": bookmarkUrl, + "to": [actor] + }, + "target": actor + "/tlbookmarks" + } + + handle = httpPrefix + '://' + domainFull + '/@' + nickname + + # lookup the inbox for the To handle + wfRequest = webfingerHandle(session, handle, httpPrefix, + cachedWebfingers, + domain, projectVersion, debug) + if not wfRequest: + if debug: + print('DEBUG: unbookmark webfinger failed for ' + handle) + return 1 + if not isinstance(wfRequest, dict): + print('WARN: unbookmark webfinger for ' + handle + + ' did not return a dict. ' + str(wfRequest)) + return 1 + + postToBox = 'outbox' + + # get the actor inbox for the To handle + (inboxUrl, pubKeyId, pubKey, fromPersonId, sharedInbox, + avatarUrl, displayName) = getPersonBox(baseDir, session, wfRequest, + personCache, + projectVersion, httpPrefix, + nickname, domain, + postToBox, 52594) + + if not inboxUrl: + if debug: + print('DEBUG: unbookmark no ' + postToBox + + ' was found for ' + handle) + return 3 + if not fromPersonId: + if debug: + print('DEBUG: unbookmark no actor was found for ' + handle) + return 4 + + authHeader = createBasicAuthHeader(nickname, password) + + headers = { + 'host': domain, + 'Content-type': 'application/json', + 'Authorization': authHeader + } + postResult = postJson(session, newBookmarkJson, [], inboxUrl, + headers, 3, True) + if not postResult: + if debug: + print('WARN: POST unbookmark failed for c2s to ' + inboxUrl) + return 5 + + if debug: + print('DEBUG: c2s POST unbookmark success') + + return newBookmarkJson + + def outboxBookmark(recentPostsCache: {}, baseDir: str, httpPrefix: str, nickname: str, domain: str, port: int, @@ -348,44 +521,63 @@ def outboxBookmark(recentPostsCache: {}, """ When a bookmark request is received by the outbox from c2s """ if not messageJson.get('type'): - if debug: - print('DEBUG: bookmark - no type') return - if not messageJson['type'] == 'Bookmark': + if messageJson['type'] != 'Add': + return + if not messageJson.get('actor'): if debug: - print('DEBUG: not a bookmark') + print('DEBUG: no actor in bookmark Add') return if not messageJson.get('object'): if debug: - print('DEBUG: no object in bookmark') + print('DEBUG: no object in bookmark Add') return - if not isinstance(messageJson['object'], str): + if not messageJson.get('target'): if debug: - print('DEBUG: bookmark object is not string') + print('DEBUG: no target in bookmark Add') + return + if not isinstance(messageJson['object'], dict): + if debug: + print('DEBUG: bookmark Add object is not dict') + return + if not messageJson['object'].get('type'): + if debug: + print('DEBUG: no object type in bookmark Add') + return + if not isinstance(messageJson['target'], str): + if debug: + print('DEBUG: bookmark Add target is not string') + return + domainFull = getFullDomain(domain, port) + if not messageJson['target'].endswith('://' + domainFull + + '/users/' + nickname + + '/tlbookmarks'): + if debug: + print('DEBUG: bookmark Add target invalid ' + + messageJson['target']) + return + if messageJson['object']['type'] != 'Document': + if debug: + print('DEBUG: bookmark Add type is not Document') + return + if not messageJson['object'].get('url'): + if debug: + print('DEBUG: bookmark Add missing url') return - if messageJson.get('to'): - if not isinstance(messageJson['to'], list): - return - if len(messageJson['to']) != 1: - print('WARN: Bookmark should only be sent to one recipient') - return - if messageJson['to'][0] != messageJson['actor']: - print('WARN: Bookmark should be addressed to the same actor') - return if debug: - print('DEBUG: c2s bookmark request arrived in outbox') + print('DEBUG: c2s bookmark Add request arrived in outbox') - messageId = removeIdEnding(messageJson['object']) + messageUrl = removeIdEnding(messageJson['object']['url']) if ':' in domain: domain = domain.split(':')[0] - postFilename = locatePost(baseDir, nickname, domain, messageId) + postFilename = locatePost(baseDir, nickname, domain, messageUrl) if not postFilename: if debug: - print('DEBUG: c2s bookmark post not found in inbox or outbox') - print(messageId) + print('DEBUG: c2s like post not found in inbox or outbox') + print(messageUrl) return True updateBookmarksCollection(recentPostsCache, - baseDir, postFilename, messageId, + baseDir, postFilename, messageUrl, messageJson['actor'], domain, debug) if debug: print('DEBUG: post bookmarked via c2s - ' + postFilename) @@ -399,53 +591,62 @@ def outboxUndoBookmark(recentPostsCache: {}, """ if not messageJson.get('type'): return - if not messageJson['type'] == 'Undo': + if messageJson['type'] != 'Remove': + return + if not messageJson.get('actor'): + if debug: + print('DEBUG: no actor in unbookmark Remove') return if not messageJson.get('object'): + if debug: + print('DEBUG: no object in unbookmark Remove') + return + if not messageJson.get('target'): + if debug: + print('DEBUG: no target in unbookmark Remove') return if not isinstance(messageJson['object'], dict): if debug: - print('DEBUG: undo bookmark object is not dict') + print('DEBUG: unbookmark Remove object is not dict') return if not messageJson['object'].get('type'): if debug: - print('DEBUG: undo bookmark - no type') + print('DEBUG: no object type in bookmark Remove') return - if not messageJson['object']['type'] == 'Bookmark': + if not isinstance(messageJson['target'], str): if debug: - print('DEBUG: not a undo bookmark') + print('DEBUG: unbookmark Remove target is not string') return - if not messageJson['object'].get('object'): + domainFull = getFullDomain(domain, port) + if not messageJson['target'].endswith('://' + domainFull + + '/users/' + nickname + + '/tlbookmarks'): if debug: - print('DEBUG: no object in undo bookmark') + print('DEBUG: unbookmark Remove target invalid ' + + messageJson['target']) return - if not isinstance(messageJson['object']['object'], str): + if messageJson['object']['type'] != 'Document': if debug: - print('DEBUG: undo bookmark object is not string') + print('DEBUG: unbookmark Remove type is not Document') + return + if not messageJson['object'].get('url'): + if debug: + print('DEBUG: unbookmark Remove missing url') return - if messageJson.get('to'): - if not isinstance(messageJson['to'], list): - return - if len(messageJson['to']) != 1: - print('WARN: Bookmark should only be sent to one recipient') - return - if messageJson['to'][0] != messageJson['actor']: - print('WARN: Bookmark should be addressed to the same actor') - return if debug: - print('DEBUG: c2s undo bookmark request arrived in outbox') + print('DEBUG: c2s unbookmark Remove request arrived in outbox') - messageId = removeIdEnding(messageJson['object']['object']) + messageUrl = removeIdEnding(messageJson['object']['url']) if ':' in domain: domain = domain.split(':')[0] - postFilename = locatePost(baseDir, nickname, domain, messageId) + postFilename = locatePost(baseDir, nickname, domain, messageUrl) if not postFilename: if debug: - print('DEBUG: c2s undo bookmark post not found in inbox or outbox') - print(messageId) + print('DEBUG: c2s unbookmark post not found in inbox or outbox') + print(messageUrl) return True - undoBookmarksCollectionEntry(recentPostsCache, - baseDir, postFilename, messageId, - messageJson['actor'], domain, debug) + updateBookmarksCollection(recentPostsCache, + baseDir, postFilename, messageUrl, + messageJson['actor'], domain, debug) if debug: - print('DEBUG: post undo bookmarked via c2s - ' + postFilename) + print('DEBUG: post unbookmarked via c2s - ' + postFilename) diff --git a/content.py b/content.py index cd10d9d73..baabd8c9e 100644 --- a/content.py +++ b/content.py @@ -643,6 +643,8 @@ def removeLongWords(content: str, maxWordLength: int, if wordStr not in longWordsList: longWordsList.append(wordStr) for wordStr in longWordsList: + if wordStr.startswith('

'): + wordStr = wordStr.replace('

', '') if wordStr.startswith('<'): continue if len(wordStr) == 76: @@ -678,6 +680,8 @@ def removeLongWords(content: str, maxWordLength: int, continue if '<' in wordStr: replaceWord = wordStr.split('<', 1)[0] + # if len(replaceWord) > maxWordLength: + # replaceWord = replaceWord[:maxWordLength] content = content.replace(wordStr, replaceWord) wordStr = replaceWord if '/' in wordStr: diff --git a/daemon.py b/daemon.py index 92bf42014..adc1d81b4 100644 --- a/daemon.py +++ b/daemon.py @@ -10,7 +10,6 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer, HTTPServer import sys import json import time -import locale import urllib.parse import datetime from socket import error as SocketError @@ -74,8 +73,6 @@ from posts import pinPost from posts import jsonPinPost from posts import undoPinnedPost from posts import isModerator -from posts import mutePost -from posts import unmutePost from posts import createQuestionPost from posts import createPublicPost from posts import createBlogPost @@ -109,6 +106,8 @@ from threads import threadWithTrace from threads import removeDormantThreads from media import replaceYouTube from media import attachMedia +from blocking import mutePost +from blocking import unmutePost from blocking import setBrochMode from blocking import addBlock from blocking import removeBlock @@ -192,6 +191,7 @@ from shares import addShare from shares import removeShare from shares import expireShares from categories import setHashtagCategory +from utils import loadTranslationsFromFile from utils import getLocalNetworkAddresses from utils import decodedHost from utils import isPublicPost @@ -471,6 +471,8 @@ class PubServer(BaseHTTPRequestHandler): postJsonObject['replies'] = {} if postJsonObject.get('bookmarks'): postJsonObject['bookmarks'] = {} + if postJsonObject.get('ignores'): + postJsonObject['ignores'] = {} if not postJsonObject.get('object'): return if not isinstance(postJsonObject['object'], dict): @@ -483,6 +485,8 @@ class PubServer(BaseHTTPRequestHandler): postJsonObject['object']['replies'] = {} if postJsonObject['object'].get('bookmarks'): postJsonObject['object']['bookmarks'] = {} + if postJsonObject['object'].get('ignores'): + postJsonObject['object']['ignores'] = {} def _requestHTTP(self) -> bool: """Should a http response be given? @@ -1260,15 +1264,10 @@ class PubServer(BaseHTTPRequestHandler): originalMessageJson = messageJson.copy() - # For follow activities add a 'to' field, which is a copy - # of the object field - messageJson, toFieldExists = \ - addToField('Follow', messageJson, self.server.debug) - - # For like activities add a 'to' field, which is a copy of - # the actor within the object field - messageJson, toFieldExists = \ - addToField('Like', messageJson, self.server.debug) + addToFieldTypes = ('Follow', 'Like', 'Add', 'Remove', 'Ignore') + for addToType in addToFieldTypes: + messageJson, toFieldExists = \ + addToField(addToType, messageJson, self.server.debug) beginSaveTime = time.time() # save the json for later queue processing @@ -7015,8 +7014,9 @@ class PubServer(BaseHTTPRequestHandler): actor = \ httpPrefix + '://' + domainFull + path.split('?mute=')[0] nickname = getNicknameFromActor(actor) - mutePost(baseDir, nickname, domain, - muteUrl, self.server.recentPostsCache) + mutePost(baseDir, nickname, domain, port, + httpPrefix, muteUrl, + self.server.recentPostsCache, debug) self.server.GETbusy = False if callingDomain.endswith('.onion') and onionDomain: actor = \ @@ -7059,8 +7059,9 @@ class PubServer(BaseHTTPRequestHandler): actor = \ httpPrefix + '://' + domainFull + path.split('?unmute=')[0] nickname = getNicknameFromActor(actor) - unmutePost(baseDir, nickname, domain, - muteUrl, self.server.recentPostsCache) + unmutePost(baseDir, nickname, domain, port, + httpPrefix, muteUrl, + self.server.recentPostsCache, debug) self.server.GETbusy = False if callingDomain.endswith('.onion') and onionDomain: actor = \ @@ -14443,32 +14444,11 @@ def runDaemon(brochMode: bool, httpd.translate = {} httpd.systemLanguage = 'en' if not unitTest: - if not os.path.isdir(baseDir + '/translations'): - print('ERROR: translations directory not found') - return - if not language: - systemLanguage = locale.getdefaultlocale()[0] - else: - systemLanguage = language - if not systemLanguage: - systemLanguage = 'en' - if '_' in systemLanguage: - systemLanguage = systemLanguage.split('_')[0] - while '/' in systemLanguage: - systemLanguage = systemLanguage.split('/')[1] - if '.' in systemLanguage: - systemLanguage = systemLanguage.split('.')[0] - translationsFile = baseDir + '/translations/' + \ - systemLanguage + '.json' - if not os.path.isfile(translationsFile): - systemLanguage = 'en' - translationsFile = baseDir + '/translations/' + \ - systemLanguage + '.json' - print('System language: ' + systemLanguage) - httpd.systemLanguage = systemLanguage - httpd.translate = loadJson(translationsFile) + httpd.translate, httpd.systemLanguage = \ + loadTranslationsFromFile(baseDir, language) + print('System language: ' + httpd.systemLanguage) if not httpd.translate: - print('ERROR: no translations loaded from ' + translationsFile) + print('ERROR: no translations were loaded') sys.exit() # For moderated newswire feeds this is the amount of time allowed @@ -14546,8 +14526,8 @@ def runDaemon(brochMode: bool, # max POST size of 30M httpd.maxPostLength = 1024 * 1024 * 30 httpd.maxMediaSize = httpd.maxPostLength - # Maximum text length is 32K - enough for a blog post - httpd.maxMessageLength = 32000 + # Maximum text length is 64K - enough for a blog post + httpd.maxMessageLength = 64000 # Maximum overall number of posts per box httpd.maxPostsInBox = 32000 httpd.domain = domain diff --git a/delete.py b/delete.py index 737f574e8..7f9380a4d 100644 --- a/delete.py +++ b/delete.py @@ -58,11 +58,11 @@ def sendDeleteViaServer(baseDir: str, session, fromDomain, projectVersion, debug) if not wfRequest: if debug: - print('DEBUG: announce webfinger failed for ' + handle) + print('DEBUG: delete webfinger failed for ' + handle) return 1 if not isinstance(wfRequest, dict): - print('WARN: Webfinger for ' + handle + ' did not return a dict. ' + - str(wfRequest)) + print('WARN: delete webfinger for ' + handle + + ' did not return a dict. ' + str(wfRequest)) return 1 postToBox = 'outbox' @@ -76,11 +76,12 @@ def sendDeleteViaServer(baseDir: str, session, if not inboxUrl: if debug: - print('DEBUG: No ' + postToBox + ' was found for ' + handle) + print('DEBUG: delete no ' + postToBox + + ' was found for ' + handle) return 3 if not fromPersonId: if debug: - print('DEBUG: No actor was found for ' + handle) + print('DEBUG: delete no actor was found for ' + handle) return 4 authHeader = createBasicAuthHeader(fromNickname, password) @@ -91,10 +92,10 @@ def sendDeleteViaServer(baseDir: str, session, 'Authorization': authHeader } postResult = \ - postJson(session, newDeleteJson, [], inboxUrl, headers, 30, True) + postJson(session, newDeleteJson, [], inboxUrl, headers, 3, True) if not postResult: if debug: - print('DEBUG: POST announce failed for c2s to ' + inboxUrl) + print('DEBUG: POST delete failed for c2s to ' + inboxUrl) return 5 if debug: diff --git a/desktop_client.py b/desktop_client.py new file mode 100644 index 000000000..9bd3f5c98 --- /dev/null +++ b/desktop_client.py @@ -0,0 +1,1920 @@ +__filename__ = "desktop_client.py" +__author__ = "Bob Mottram" +__license__ = "AGPL3+" +__version__ = "1.2.0" +__maintainer__ = "Bob Mottram" +__email__ = "bob@freedombone.net" +__status__ = "Production" + +import os +import html +import time +import sys +import select +import webbrowser +import urllib.parse +from pathlib import Path +from random import randint +from utils import getFullDomain +from utils import isDM +from utils import loadTranslationsFromFile +from utils import removeHtml +from utils import getNicknameFromActor +from utils import getDomainFromActor +from utils import isPGPEncrypted +from session import createSession +from speaker import speakableText +from speaker import getSpeakerPitch +from speaker import getSpeakerRate +from speaker import getSpeakerRange +from like import sendLikeViaServer +from like import sendUndoLikeViaServer +from follow import sendFollowRequestViaServer +from follow import sendUnfollowRequestViaServer +from posts import sendMuteViaServer +from posts import sendUndoMuteViaServer +from posts import sendPostViaServer +from posts import c2sBoxJson +from posts import downloadAnnounce +from announce import sendAnnounceViaServer +from announce import sendUndoAnnounceViaServer +from pgp import pgpDecrypt +from pgp import hasLocalPGPkey +from pgp import pgpEncryptToActor +from pgp import pgpPublicKeyUpload +from like import noOfLikes +from bookmarks import sendBookmarkViaServer +from bookmarks import sendUndoBookmarkViaServer +from delete import sendDeleteViaServer +from person import getActorJson + + +def _desktopHelp() -> None: + """Shows help + """ + indent = ' ' + print('') + print(indent + 'Commands:') + print('') + print(indent + 'quit ' + + 'Exit from the desktop client') + print(indent + 'show dm|sent|inbox|replies|bookmarks ' + + 'Show a timeline') + print(indent + 'mute ' + + 'Turn off the screen reader') + print(indent + 'speak ' + + 'Turn on the screen reader') + print(indent + 'sounds on ' + + 'Turn on notification sounds') + print(indent + 'sounds off ' + + 'Turn off notification sounds') + print(indent + 'rp ' + + 'Repeat the last post') + print(indent + 'like ' + + 'Like the last post') + print(indent + 'unlike ' + + 'Unlike the last post') + print(indent + 'bookmark ' + + 'Bookmark the last post') + print(indent + 'unbookmark ' + + 'Unbookmark the last post') + print(indent + 'mute ' + + 'Mute the last post') + print(indent + 'unmute ' + + 'Unmute the last post') + print(indent + 'reply ' + + 'Reply to the last post') + print(indent + 'post ' + + 'Create a new post') + print(indent + 'post to [handle] ' + + 'Create a new direct message') + print(indent + 'announce/boost ' + + 'Boost the last post') + print(indent + 'follow [handle] ' + + 'Make a follow request') + print(indent + 'unfollow [handle] ' + + 'Stop following the give handle') + print(indent + 'next ' + + 'Next page in the timeline') + print(indent + 'prev ' + + 'Previous page in the timeline') + print(indent + 'read [post number] ' + + 'Read a post from a timeline') + print(indent + 'open [post number] ' + + 'Open web links within a timeline post') + print(indent + 'profile [post number] ' + + 'Show profile for the person who made the given post') + print('') + + +def _createDesktopConfig(actor: str) -> None: + """Sets up directories for desktop client configuration + """ + homeDir = str(Path.home()) + if not os.path.isdir(homeDir + '/.config'): + os.mkdir(homeDir + '/.config') + if not os.path.isdir(homeDir + '/.config/epicyon'): + os.mkdir(homeDir + '/.config/epicyon') + nickname = getNicknameFromActor(actor) + domain, port = getDomainFromActor(actor) + handle = nickname + '@' + domain + if port != 443 and port != 80: + handle += '_' + str(port) + readPostsDir = homeDir + '/.config/epicyon/' + handle + if not os.path.isdir(readPostsDir): + os.mkdir(readPostsDir) + + +def _markPostAsRead(actor: str, postId: str, postCategory: str) -> None: + """Marks the given post as read by the given actor + """ + homeDir = str(Path.home()) + _createDesktopConfig(actor) + nickname = getNicknameFromActor(actor) + domain, port = getDomainFromActor(actor) + handle = nickname + '@' + domain + if port != 443 and port != 80: + handle += '_' + str(port) + readPostsDir = homeDir + '/.config/epicyon/' + handle + readPostsFilename = readPostsDir + '/' + postCategory + '.txt' + if os.path.isfile(readPostsFilename): + if postId in open(readPostsFilename).read(): + return + try: + # prepend to read posts file + postId += '\n' + with open(readPostsFilename, 'r+') as readFile: + content = readFile.read() + if postId not in content: + readFile.seek(0, 0) + readFile.write(postId + content) + except Exception as e: + print('WARN: Failed to mark post as read' + str(e)) + else: + readFile = open(readPostsFilename, 'w+') + if readFile: + readFile.write(postId + '\n') + readFile.close() + + +def _hasReadPost(actor: str, postId: str, postCategory: str) -> bool: + """Returns true if the given post has been read by the actor + """ + homeDir = str(Path.home()) + _createDesktopConfig(actor) + nickname = getNicknameFromActor(actor) + domain, port = getDomainFromActor(actor) + handle = nickname + '@' + domain + if port != 443 and port != 80: + handle += '_' + str(port) + readPostsDir = homeDir + '/.config/epicyon/' + handle + readPostsFilename = readPostsDir + '/' + postCategory + '.txt' + if os.path.isfile(readPostsFilename): + if postId in open(readPostsFilename).read(): + return True + return False + + +def _postIsToYou(actor: str, postJsonObject: {}) -> bool: + """Returns true if the post is to the actor + """ + toYourActor = False + if postJsonObject.get('to'): + if actor in postJsonObject['to']: + toYourActor = True + if not toYourActor and postJsonObject.get('object'): + if isinstance(postJsonObject['object'], dict): + if postJsonObject['object'].get('to'): + if actor in postJsonObject['object']['to']: + toYourActor = True + return toYourActor + + +def _newDesktopNotifications(actor: str, inboxJson: {}, + notifyJson: {}) -> None: + """Looks for changes in the inbox and adds notifications + """ + if not inboxJson: + return + if not inboxJson.get('orderedItems'): + return + newDM = False + newReply = False + notifyJson['dmNotifyChanged'] = False + notifyJson['repliesNotifyChanged'] = False + for postJsonObject in inboxJson['orderedItems']: + if not postJsonObject.get('id'): + continue + if not _postIsToYou(actor, postJsonObject): + continue + if 'dmNotify' not in notifyJson: + notifyJson['dmNotify'] = False + if isDM(postJsonObject): + if not newDM: + if not _hasReadPost(actor, postJsonObject['id'], 'dm'): + if notifyJson.get('dmPostId'): + if notifyJson['dmPostId'] != postJsonObject['id']: + notifyJson['dmNotify'] = True + notifyJson['dmNotifyChanged'] = True + newDM = True + else: + notifyJson['dmNotifyChanged'] = False + notifyJson['dmPostId'] = postJsonObject['id'] + if newDM: + break + else: + if not newReply: + if not _hasReadPost(actor, postJsonObject['id'], 'replies'): + if notifyJson.get('repliesPostId'): + if notifyJson['repliesPostId'] != postJsonObject['id']: + notifyJson['repliesNotify'] = True + notifyJson['repliesNotifyChanged'] = True + newReply = True + else: + notifyJson['repliesNotifyChanged'] = False + notifyJson['repliesPostId'] = postJsonObject['id'] + if newReply: + break + + +def _desktopClearScreen() -> None: + """Clears the screen + """ + os.system('cls' if os.name == 'nt' else 'clear') + + +def _desktopShowBanner() -> None: + """Shows the banner at the top + """ + bannerFilename = 'banner.txt' + if not os.path.isfile(bannerFilename): + bannerTheme = 'starlight' + bannerFilename = 'theme/' + bannerTheme + '/banner.txt' + if not os.path.isfile(bannerFilename): + return + with open(bannerFilename, 'r') as bannerFile: + banner = bannerFile.read() + if banner: + print(banner + '\n') + + +def _desktopWaitForCmd(timeout: int, debug: bool) -> str: + """Waits for a command to be entered with a timeout + Returns the command, or None on timeout + """ + i, o, e = select.select([sys.stdin], [], [], timeout) + + if (i): + text = sys.stdin.readline().strip() + if debug: + print("Text entered: " + text) + return text + else: + if debug: + print("Timeout") + return None + + +def _speakerEspeak(espeak, pitch: int, rate: int, srange: int, + sayText: str) -> None: + """Speaks the given text with espeak + """ + espeak.set_parameter(espeak.Parameter.Pitch, pitch) + espeak.set_parameter(espeak.Parameter.Rate, rate) + espeak.set_parameter(espeak.Parameter.Range, srange) + espeak.synth(html.unescape(sayText)) + + +def _speakerPicospeaker(pitch: int, rate: int, systemLanguage: str, + sayText: str) -> None: + """TTS using picospeaker + """ + speakerLang = 'en-GB' + if systemLanguage: + if systemLanguage.startswith('fr'): + speakerLang = 'fr-FR' + elif systemLanguage.startswith('es'): + speakerLang = 'es-ES' + elif systemLanguage.startswith('de'): + speakerLang = 'de-DE' + elif systemLanguage.startswith('it'): + speakerLang = 'it-IT' + speakerCmd = 'picospeaker ' + \ + '-l ' + speakerLang + \ + ' -r ' + str(rate) + \ + ' -p ' + str(pitch) + ' "' + \ + html.unescape(sayText) + '" 2> /dev/null' + os.system(speakerCmd) + + +def _playNotificationSound(soundFilename: str, player='ffplay') -> None: + """Plays a sound + """ + if not os.path.isfile(soundFilename): + return + + if player == 'ffplay': + os.system('ffplay ' + soundFilename + + ' -autoexit -hide_banner -nodisp 2> /dev/null') + + +def _desktopNotification(notificationType: str, + title: str, message: str) -> None: + """Shows a desktop notification + """ + if not notificationType: + return + + if notificationType == 'notify-send': + # Ubuntu + os.system('notify-send "' + title + '" "' + message + '"') + elif notificationType == 'zenity': + # Zenity + os.system('zenity --notification --title "' + title + + '" --text="' + message + '"') + elif notificationType == 'osascript': + # Mac + os.system("osascript -e 'display notification \"" + + message + "\" with title \"" + title + "\"'") + elif notificationType == 'New-BurntToastNotification': + # Windows + os.system("New-BurntToastNotification -Text \"" + + title + "\", '" + message + "'") + + +def _textToSpeech(sayStr: str, screenreader: str, + pitch: int, rate: int, srange: int, + systemLanguage: str, espeak=None) -> None: + """Say something via TTS + """ + # speak the post content + if screenreader == 'espeak': + _speakerEspeak(espeak, pitch, rate, srange, sayStr) + elif screenreader == 'picospeaker': + _speakerPicospeaker(pitch, rate, + systemLanguage, sayStr) + + +def _sayCommand(content: str, sayStr: str, screenreader: str, + systemLanguage: str, + espeak=None, + speakerName='screen reader', + speakerGender='They/Them') -> None: + """Speaks a command + """ + print(content) + if not screenreader: + return + + pitch = getSpeakerPitch(speakerName, + screenreader, speakerGender) + rate = getSpeakerRate(speakerName, screenreader) + srange = getSpeakerRange(speakerName) + + _textToSpeech(sayStr, screenreader, + pitch, rate, srange, + systemLanguage, espeak) + + +def _desktopReplyToPost(session, postId: str, + baseDir: str, nickname: str, password: str, + domain: str, port: int, httpPrefix: str, + cachedWebfingers: {}, personCache: {}, + debug: bool, subject: str, + screenreader: str, systemLanguage: str, + espeak) -> None: + """Use the desktop client to send a reply to the most recent post + """ + if '://' not in postId: + return + toNickname = getNicknameFromActor(postId) + toDomain, toPort = getDomainFromActor(postId) + sayStr = 'Replying to ' + toNickname + '@' + toDomain + _sayCommand(sayStr, sayStr, + screenreader, systemLanguage, espeak) + sayStr = 'Type your reply message, then press Enter.' + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + replyMessage = input() + if not replyMessage: + sayStr = 'No reply was entered.' + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + return + replyMessage = replyMessage.strip() + if not replyMessage: + sayStr = 'No reply was entered.' + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + return + print('') + sayStr = 'You entered this reply:' + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + _sayCommand(replyMessage, replyMessage, screenreader, + systemLanguage, espeak) + sayStr = 'Send this reply, yes or no?' + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + yesno = input() + if 'y' not in yesno.lower(): + sayStr = 'Abandoning reply' + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + return + ccUrl = None + followersOnly = False + attach = None + mediaType = None + attachedImageDescription = None + isArticle = False + subject = None + commentsEnabled = True + sayStr = 'Sending reply' + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + if sendPostViaServer(__version__, + baseDir, session, nickname, password, + domain, port, + toNickname, toDomain, toPort, ccUrl, + httpPrefix, replyMessage, followersOnly, + commentsEnabled, attach, mediaType, + attachedImageDescription, + cachedWebfingers, personCache, isArticle, + debug, postId, postId, subject) == 0: + sayStr = 'Reply sent' + else: + sayStr = 'Reply failed' + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + + +def _desktopNewPost(session, + baseDir: str, nickname: str, password: str, + domain: str, port: int, httpPrefix: str, + cachedWebfingers: {}, personCache: {}, + debug: bool, + screenreader: str, systemLanguage: str, + espeak) -> None: + """Use the desktop client to create a new post + """ + sayStr = 'Create new post' + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + sayStr = 'Type your post, then press Enter.' + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + newMessage = input() + if not newMessage: + sayStr = 'No post was entered.' + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + return + newMessage = newMessage.strip() + if not newMessage: + sayStr = 'No post was entered.' + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + return + print('') + sayStr = 'You entered this public post:' + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + _sayCommand(newMessage, newMessage, screenreader, systemLanguage, espeak) + sayStr = 'Send this post, yes or no?' + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + yesno = input() + if 'y' not in yesno.lower(): + sayStr = 'Abandoning new post' + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + return + ccUrl = None + followersOnly = False + attach = None + mediaType = None + attachedImageDescription = None + isArticle = False + subject = None + commentsEnabled = True + subject = None + sayStr = 'Sending' + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + if sendPostViaServer(__version__, + baseDir, session, nickname, password, + domain, port, + None, '#Public', port, ccUrl, + httpPrefix, newMessage, followersOnly, + commentsEnabled, attach, mediaType, + attachedImageDescription, + cachedWebfingers, personCache, isArticle, + debug, None, None, subject) == 0: + sayStr = 'Post sent' + else: + sayStr = 'Post failed' + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + + +def _safeMessage(content: str) -> str: + """Removes anything potentially unsafe from a string + """ + return content.replace('`', '').replace('$(', '$ (') + + +def _timelineIsEmpty(boxJson: {}) -> bool: + """Returns true if the given timeline is empty + """ + empty = False + if not boxJson: + empty = True + else: + if not isinstance(boxJson, dict): + empty = True + elif not boxJson.get('orderedItems'): + empty = True + return empty + + +def _getFirstItemId(boxJson: {}) -> str: + """Returns the id of the first item in the timeline + """ + if _timelineIsEmpty(boxJson): + return + if len(boxJson['orderedItems']) == 0: + return + return boxJson['orderedItems'][0]['id'] + + +def _textOnlyContent(content: str) -> str: + """Remove formatting from the given string + """ + content = urllib.parse.unquote_plus(content) + content = html.unescape(content) + return removeHtml(content) + + +def _getImageDescription(postJsonObject: {}) -> str: + """Returns a image description/s on a post + """ + imageDescription = '' + if not postJsonObject['object'].get('attachment'): + return imageDescription + + attachList = postJsonObject['object']['attachment'] + if not isinstance(attachList, list): + return imageDescription + + # for each attachment + for img in attachList: + if not isinstance(img, dict): + continue + if not img.get('name'): + continue + if not isinstance(img['name'], str): + continue + messageStr = img['name'] + if messageStr: + messageStr = messageStr.strip() + if not messageStr.endswith('.'): + imageDescription += messageStr + '. ' + else: + imageDescription += messageStr + ' ' + return imageDescription + + +def _readLocalBoxPost(session, nickname: str, domain: str, + httpPrefix: str, baseDir: str, boxName: str, + pageNumber: int, index: int, boxJson: {}, + systemLanguage: str, + screenreader: str, espeak, + translate: {}, yourActor: str) -> {}: + """Reads a post from the given timeline + Returns the post json + """ + if _timelineIsEmpty(boxJson): + return {} + + postJsonObject = _desktopGetBoxPostObject(boxJson, index) + if not postJsonObject: + return {} + gender = 'They/Them' + + boxNameStr = boxName + if boxName.startswith('tl'): + boxNameStr = boxName[2:] + sayStr = 'Reading ' + boxNameStr + ' post ' + str(index) + \ + ' from page ' + str(pageNumber) + '.' + sayStr2 = sayStr.replace(' dm ', ' DM ') + _sayCommand(sayStr, sayStr2, screenreader, systemLanguage, espeak) + + if postJsonObject['type'] == 'Announce': + actor = postJsonObject['actor'] + nameStr = getNicknameFromActor(actor) + recentPostsCache = {} + allowLocalNetworkAccess = False + YTReplacementDomain = None + postJsonObject2 = \ + downloadAnnounce(session, baseDir, + httpPrefix, + nickname, domain, + postJsonObject, + __version__, translate, + YTReplacementDomain, + allowLocalNetworkAccess, + recentPostsCache, False) + if postJsonObject2: + if postJsonObject2.get('object'): + if postJsonObject2['object'].get('attributedTo') and \ + postJsonObject2['object'].get('content'): + actor = postJsonObject2['object']['attributedTo'] + nameStr += ' ' + translate['announces'] + ' ' + \ + getNicknameFromActor(actor) + sayStr = nameStr + _sayCommand(sayStr, sayStr, screenreader, + systemLanguage, espeak) + if screenreader: + time.sleep(2) + content = \ + _textOnlyContent(postJsonObject2['object']['content']) + content += _getImageDescription(postJsonObject2) + messageStr, detectedLinks = \ + speakableText(baseDir, content, translate) + sayStr = content + _sayCommand(sayStr, messageStr, screenreader, + systemLanguage, espeak) + return postJsonObject2 + return {} + + actor = postJsonObject['object']['attributedTo'] + nameStr = getNicknameFromActor(actor) + content = _textOnlyContent(postJsonObject['object']['content']) + content += _getImageDescription(postJsonObject) + + if isPGPEncrypted(content): + sayStr = 'Encrypted message. Please enter your passphrase.' + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + content = pgpDecrypt(content, actor) + if isPGPEncrypted(content): + sayStr = 'Message could not be decrypted' + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + return {} + + content = _safeMessage(content) + messageStr, detectedLinks = speakableText(baseDir, content, translate) + + if screenreader: + time.sleep(2) + + # say the speaker's name + _sayCommand(nameStr, nameStr, screenreader, + systemLanguage, espeak, nameStr, gender) + + if postJsonObject['object'].get('inReplyTo'): + print('Replying to ' + postJsonObject['object']['inReplyTo'] + '\n') + + if screenreader: + time.sleep(2) + + # speak the post content + _sayCommand(content, messageStr, screenreader, + systemLanguage, espeak, nameStr, gender) + + # if the post is addressed to you then mark it as read + if _postIsToYou(yourActor, postJsonObject): + if isDM(postJsonObject['id']): + _markPostAsRead(yourActor, postJsonObject['id'], 'dm') + else: + _markPostAsRead(yourActor, postJsonObject['id'], 'replies') + + return postJsonObject + + +def _showProfile(session, nickname: str, domain: str, + httpPrefix: str, baseDir: str, boxName: str, + pageNumber: int, index: int, boxJson: {}, + systemLanguage: str, + screenreader: str, espeak, + translate: {}, yourActor: str) -> {}: + """Shows the profile of the actor for the given post + Returns the actor json + """ + if _timelineIsEmpty(boxJson): + return {} + + postJsonObject = _desktopGetBoxPostObject(boxJson, index) + if not postJsonObject: + return {} + + actor = None + if postJsonObject['type'] == 'Announce': + nickname = getNicknameFromActor(postJsonObject['object']) + if nickname: + nickStr = '/' + nickname + '/' + if nickStr in postJsonObject['object']: + actor = \ + postJsonObject['object'].split(nickStr)[0] + \ + '/' + nickname + else: + actor = postJsonObject['object']['attributedTo'] + + if not actor: + return {} + + isHttp = False + if 'http://' in actor: + isHttp = True + actorJson = getActorJson(actor, isHttp, False, False, True) + + actor = actorJson['id'] + actorNickname = getNicknameFromActor(actor) + actorDomain, actorPort = getDomainFromActor(actor) + actorDomainFull = getFullDomain(actorDomain, actorPort) + handle = '@' + actorNickname + '@' + actorDomainFull + + sayStr = handle + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + print(actor) + if actorJson.get('movedTo'): + sayStr = 'Moved to ' + actorJson['movedTo'] + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + if actorJson.get('alsoKnownAs'): + alsoKnownAsStr = '' + ctr = 0 + for altActor in actorJson['alsoKnownAs']: + if ctr > 0: + alsoKnownAsStr += ', ' + ctr += 1 + alsoKnownAsStr += altActor + + sayStr = 'Also known as ' + alsoKnownAsStr + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + if actorJson.get('summary'): + sayStr = removeHtml(actorJson['summary']) + sayStr2 = speakableText(baseDir, sayStr, translate) + _sayCommand(sayStr, sayStr2, screenreader, systemLanguage, espeak) + + return actorJson + + +def _desktopGetBoxPostObject(boxJson: {}, index: int) -> {}: + """Gets the post with the given index from the timeline + """ + ctr = 0 + for postJsonObject in boxJson['orderedItems']: + if not postJsonObject.get('type'): + continue + if not postJsonObject.get('object'): + continue + if postJsonObject['type'] == 'Announce': + if not isinstance(postJsonObject['object'], str): + continue + ctr += 1 + if ctr == index: + return postJsonObject + continue + if not isinstance(postJsonObject['object'], dict): + continue + if not postJsonObject['object'].get('published'): + continue + if not postJsonObject['object'].get('content'): + continue + ctr += 1 + if ctr == index: + return postJsonObject + return None + + +def _formatPublished(published: str) -> str: + """Formats the published time for display on timeline + """ + dateStr = published.split('T')[0] + monthStr = dateStr.split('-')[1] + dayStr = dateStr.split('-')[2] + timeStr = published.split('T')[1] + hourStr = timeStr.split(':')[0] + minStr = timeStr.split(':')[1] + return monthStr + '-' + dayStr + ' ' + hourStr + ':' + minStr + 'Z' + + +def _padToWidth(content: str, width: int) -> str: + """Pads the given string to the given width + """ + if len(content) > width: + content = content[:width] + else: + while len(content) < width: + content += ' ' + return content + + +def _desktopShowBox(boxName: str, boxJson: {}, + screenreader: str, systemLanguage: str, espeak, + pageNumber=1, + newReplies=False, + newDMs=False) -> bool: + """Shows online timeline + """ + numberWidth = 2 + nameWidth = 16 + contentWidth = 50 + indent = ' ' + + # title + _desktopClearScreen() + _desktopShowBanner() + + notificationIcons = '' + if boxName.startswith('tl'): + boxNameStr = boxName[2:] + else: + boxNameStr = boxName + titleStr = '\33[7m' + boxNameStr.upper() + '\33[0m' + + if newDMs: + notificationIcons += ' 📩' + if newReplies: + notificationIcons += ' 📨' + + if notificationIcons: + while len(titleStr) < 95 - len(notificationIcons): + titleStr += ' ' + titleStr += notificationIcons + print(indent + titleStr + '\n') + + if _timelineIsEmpty(boxJson): + boxStr = boxNameStr + if boxName == 'dm': + boxStr = 'DM' + sayStr = indent + 'You have no ' + boxStr + ' posts yet.' + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + print('') + return False + + ctr = 1 + for postJsonObject in boxJson['orderedItems']: + if not postJsonObject.get('type'): + continue + if postJsonObject['type'] == 'Announce': + if postJsonObject.get('actor') and \ + postJsonObject.get('object'): + if isinstance(postJsonObject['object'], str): + authorActor = postJsonObject['actor'] + name = getNicknameFromActor(authorActor) + ' ⮌' + name = _padToWidth(name, nameWidth) + ctrStr = str(ctr) + posStr = _padToWidth(ctrStr, numberWidth) + published = _formatPublished(postJsonObject['published']) + announcedNickname = \ + getNicknameFromActor(postJsonObject['object']) + announcedDomain, announcedPort = \ + getDomainFromActor(postJsonObject['object']) + announcedHandle = announcedNickname + '@' + announcedDomain + print(indent + str(posStr) + ' | ' + name + ' | ' + + published + ' | ' + + _padToWidth(announcedHandle, contentWidth)) + ctr += 1 + continue + + if not postJsonObject.get('object'): + continue + if not isinstance(postJsonObject['object'], dict): + continue + if not postJsonObject['object'].get('published'): + continue + if not postJsonObject['object'].get('content'): + continue + ctrStr = str(ctr) + posStr = _padToWidth(ctrStr, numberWidth) + + authorActor = postJsonObject['object']['attributedTo'] + contentWarning = None + if postJsonObject['object'].get('summary'): + contentWarning = '⚡' + \ + _padToWidth(postJsonObject['object']['summary'], + contentWidth) + name = getNicknameFromActor(authorActor) + + # append icons to the end of the name + spaceAdded = False + if postJsonObject['object'].get('inReplyTo'): + if not spaceAdded: + spaceAdded = True + name += ' ' + name += '↲' + likesCount = noOfLikes(postJsonObject) + if likesCount > 10: + likesCount = 10 + for like in range(likesCount): + if not spaceAdded: + spaceAdded = True + name += ' ' + name += '❤' + name = _padToWidth(name, nameWidth) + + published = _formatPublished(postJsonObject['published']) + + content = _textOnlyContent(postJsonObject['object']['content']) + if boxName != 'dm': + if isDM(postJsonObject): + content = '📧' + content + if not contentWarning: + if isPGPEncrypted(content): + content = '🔒' + content + elif '://' in content: + content = '🔗' + content + content = _padToWidth(content, contentWidth) + else: + # display content warning + if isPGPEncrypted(content): + content = '🔒' + contentWarning + else: + if '://' in content: + content = '🔗' + contentWarning + else: + content = contentWarning + if postJsonObject['object'].get('ignores'): + content = '🔇' + if postJsonObject['object'].get('bookmarks'): + content = '🔖' + content + print(indent + str(posStr) + ' | ' + name + ' | ' + + published + ' | ' + content) + ctr += 1 + + print('') + + # say the post number range + sayStr = indent + boxNameStr + ' page ' + str(pageNumber) + \ + ' containing ' + str(ctr - 1) + ' posts. ' + if newDMs and boxName != 'dm': + sayStr += \ + 'Use \33[3mshow dm\33[0m to view direct messages.' + elif newReplies and boxName != 'tlreplies': + sayStr += \ + 'Use \33[3mshow replies\33[0m to view reply posts.' + else: + sayStr += \ + 'Use the \33[3mnext\33[0m and ' + \ + '\33[3mprev\33[0m commands to navigate.' + sayStr2 = sayStr.replace('\33[3m', '').replace('\33[0m', '') + sayStr2 = sayStr2.replace('show dm', 'show DM') + sayStr2 = sayStr2.replace('dm post', 'Direct message post') + _sayCommand(sayStr, sayStr2, screenreader, systemLanguage, espeak) + print('') + return True + + +def _desktopNewDM(session, toHandle: str, + baseDir: str, nickname: str, password: str, + domain: str, port: int, httpPrefix: str, + cachedWebfingers: {}, personCache: {}, + debug: bool, + screenreader: str, systemLanguage: str, + espeak) -> None: + """Use the desktop client to create a new direct message + which can include multiple destination handles + """ + if ' ' in toHandle: + handlesList = toHandle.split(' ') + elif ',' in toHandle: + handlesList = toHandle.split(',') + elif ';' in toHandle: + handlesList = toHandle.split(';') + else: + handlesList = [toHandle] + + for handle in handlesList: + handle = handle.strip() + _desktopNewDMbase(session, handle, + baseDir, nickname, password, + domain, port, httpPrefix, + cachedWebfingers, personCache, + debug, + screenreader, systemLanguage, + espeak) + + +def _desktopNewDMbase(session, toHandle: str, + baseDir: str, nickname: str, password: str, + domain: str, port: int, httpPrefix: str, + cachedWebfingers: {}, personCache: {}, + debug: bool, + screenreader: str, systemLanguage: str, + espeak) -> None: + """Use the desktop client to create a new direct message + """ + toPort = port + if '://' in toHandle: + toNickname = getNicknameFromActor(toHandle) + toDomain, toPort = getDomainFromActor(toHandle) + toHandle = toNickname + '@' + toDomain + else: + if toHandle.startswith('@'): + toHandle = toHandle[1:] + toNickname = toHandle.split('@')[0] + toDomain = toHandle.split('@')[1] + + sayStr = 'Create new direct message to ' + toHandle + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + sayStr = 'Type your direct message, then press Enter.' + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + newMessage = input() + if not newMessage: + sayStr = 'No direct message was entered.' + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + return + newMessage = newMessage.strip() + if not newMessage: + sayStr = 'No direct message was entered.' + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + return + sayStr = 'You entered this direct message to ' + toHandle + ':' + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + _sayCommand(newMessage, newMessage, screenreader, systemLanguage, espeak) + ccUrl = None + followersOnly = False + attach = None + mediaType = None + attachedImageDescription = None + isArticle = False + subject = None + commentsEnabled = True + subject = None + + # if there is a local PGP key then attempt to encrypt the DM + # using the PGP public key of the recipient + if hasLocalPGPkey(): + sayStr = \ + 'Local PGP key detected...' + \ + 'Fetching PGP public key for ' + toHandle + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + paddedMessage = newMessage + if len(paddedMessage) < 32: + # add some padding before and after + # This is to guard against cribs based on small messages, like "Hi" + for before in range(randint(1, 16)): + paddedMessage = ' ' + paddedMessage + for after in range(randint(1, 16)): + paddedMessage += ' ' + cipherText = \ + pgpEncryptToActor(paddedMessage, toHandle) + if not cipherText: + sayStr = \ + toHandle + ' has no PGP public key. ' + \ + 'Your message will be sent in clear text' + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + else: + newMessage = cipherText + sayStr = 'Message encrypted' + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + + sayStr = 'Send this direct message, yes or no?' + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + yesno = input() + if 'y' not in yesno.lower(): + sayStr = 'Abandoning new direct message' + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + return + + sayStr = 'Sending' + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + if sendPostViaServer(__version__, + baseDir, session, nickname, password, + domain, port, + toNickname, toDomain, toPort, ccUrl, + httpPrefix, newMessage, followersOnly, + commentsEnabled, attach, mediaType, + attachedImageDescription, + cachedWebfingers, personCache, isArticle, + debug, None, None, subject) == 0: + sayStr = 'Direct message sent' + else: + sayStr = 'Direct message failed' + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + + +def runDesktopClient(baseDir: str, proxyType: str, httpPrefix: str, + nickname: str, domain: str, port: int, + password: str, screenreader: str, + systemLanguage: str, + notificationSounds: bool, + notificationType: str, + noKeyPress: bool, + storeInboxPosts: bool, + showNewPosts: bool, + language: str, + debug: bool) -> None: + """Runs the desktop and screen reader client, + which announces new inbox items + """ + indent = ' ' + if showNewPosts: + indent = '' + + _desktopClearScreen() + _desktopShowBanner() + + espeak = None + if screenreader: + if screenreader == 'espeak': + print('Setting up espeak') + from espeak import espeak + elif screenreader != 'picospeaker': + print(screenreader + ' is not a supported TTS system') + return + + sayStr = indent + 'Running ' + screenreader + ' for ' + \ + nickname + '@' + domain + _sayCommand(sayStr, sayStr, screenreader, + systemLanguage, espeak) + else: + print(indent + 'Running desktop notifications for ' + + nickname + '@' + domain) + if notificationSounds: + sayStr = indent + 'Notification sounds on' + else: + sayStr = indent + 'Notification sounds off' + _sayCommand(sayStr, sayStr, screenreader, + systemLanguage, espeak) + + currTimeline = 'inbox' + pageNumber = 1 + + postJsonObject = {} + originalScreenReader = screenreader + soundsDir = 'theme/default/sounds/' + # prevSay = '' + # prevCalendar = False + # prevFollow = False + # prevLike = '' + # prevShare = False + dmSoundFilename = soundsDir + 'dm.ogg' + replySoundFilename = soundsDir + 'reply.ogg' + # calendarSoundFilename = soundsDir + 'calendar.ogg' + # followSoundFilename = soundsDir + 'follow.ogg' + # likeSoundFilename = soundsDir + 'like.ogg' + # shareSoundFilename = soundsDir + 'share.ogg' + player = 'ffplay' + nameStr = None + gender = None + messageStr = None + content = None + cachedWebfingers = {} + personCache = {} + newRepliesExist = False + newDMsExist = False + pgpKeyUpload = False + + sayStr = indent + 'Loading translations file' + _sayCommand(sayStr, sayStr, screenreader, + systemLanguage, espeak) + translate, systemLanguage = \ + loadTranslationsFromFile(baseDir, language) + + sayStr = indent + 'Connecting...' + _sayCommand(sayStr, sayStr, screenreader, + systemLanguage, espeak) + session = createSession(proxyType) + + sayStr = indent + '/q or /quit to exit' + _sayCommand(sayStr, sayStr, screenreader, + systemLanguage, espeak) + + domainFull = getFullDomain(domain, port) + yourActor = httpPrefix + '://' + domainFull + '/users/' + nickname + actorJson = None + + notifyJson = { + "dmPostId": "Initial", + "dmNotify": False, + "dmNotifyChanged": False, + "repliesPostId": "Initial", + "repliesNotify": False, + "repliesNotifyChanged": False + } + prevTimelineFirstId = '' + while (1): + if not pgpKeyUpload: + sayStr = indent + 'Uploading PGP public key' + _sayCommand(sayStr, sayStr, screenreader, + systemLanguage, espeak) + pgpPublicKeyUpload(baseDir, session, + nickname, password, + domain, port, httpPrefix, + cachedWebfingers, personCache, + debug, False) + sayStr = indent + 'PGP public key uploaded' + _sayCommand(sayStr, sayStr, screenreader, + systemLanguage, espeak) + pgpKeyUpload = True + + boxJson = c2sBoxJson(baseDir, session, + nickname, password, + domain, port, httpPrefix, + currTimeline, pageNumber, + debug) + + if not (currTimeline == 'inbox' and pageNumber == 1): + # monitor the inbox to generate notifications + inboxJson = c2sBoxJson(baseDir, session, + nickname, password, + domain, port, httpPrefix, + 'inbox', 1, debug) + else: + inboxJson = boxJson + newDMsExist = False + newRepliesExist = False + if inboxJson: + _newDesktopNotifications(yourActor, inboxJson, notifyJson) + if notifyJson.get('dmNotify'): + newDMsExist = True + if notifyJson.get('dmNotifyChanged'): + _desktopNotification(notificationType, + "Epicyon", + "New DM " + yourActor + '/dm') + _playNotificationSound(dmSoundFilename, player) + if notifyJson.get('repliesNotify'): + newRepliesExist = True + if notifyJson.get('repliesNotifyChanged'): + _desktopNotification(notificationType, + "Epicyon", + "New reply " + yourActor + '/replies') + _playNotificationSound(replySoundFilename, player) + + if boxJson: + timelineFirstId = _getFirstItemId(boxJson) + if timelineFirstId != prevTimelineFirstId: + _desktopClearScreen() + _desktopShowBox(currTimeline, boxJson, + None, systemLanguage, espeak, + pageNumber, + newRepliesExist, + newDMsExist) + prevTimelineFirstId = timelineFirstId + else: + session = createSession(proxyType) + + # wait for a while, or until a key is pressed + if noKeyPress: + time.sleep(10) + else: + commandStr = _desktopWaitForCmd(30, debug) + if commandStr: + refreshTimeline = False + + if commandStr.startswith('/'): + commandStr = commandStr[1:] + if commandStr == 'q' or \ + commandStr == 'quit' or \ + commandStr == 'exit': + sayStr = 'Quit' + _sayCommand(sayStr, sayStr, screenreader, + systemLanguage, espeak) + if screenreader: + commandStr = _desktopWaitForCmd(2, debug) + break + elif commandStr.startswith('show dm'): + pageNumber = 1 + prevTimelineFirstId = '' + currTimeline = 'dm' + boxJson = c2sBoxJson(baseDir, session, + nickname, password, + domain, port, httpPrefix, + currTimeline, pageNumber, + debug) + if boxJson: + _desktopShowBox(currTimeline, boxJson, + screenreader, systemLanguage, espeak, + pageNumber, + newRepliesExist, newDMsExist) + newDMsExist = False + elif commandStr.startswith('show rep'): + pageNumber = 1 + prevTimelineFirstId = '' + currTimeline = 'tlreplies' + boxJson = c2sBoxJson(baseDir, session, + nickname, password, + domain, port, httpPrefix, + currTimeline, pageNumber, + debug) + if boxJson: + _desktopShowBox(currTimeline, boxJson, + screenreader, systemLanguage, espeak, + pageNumber, + newRepliesExist, newDMsExist) + # Turn off the replies indicator + newRepliesExist = False + elif commandStr.startswith('show b'): + pageNumber = 1 + prevTimelineFirstId = '' + currTimeline = 'tlbookmarks' + boxJson = c2sBoxJson(baseDir, session, + nickname, password, + domain, port, httpPrefix, + currTimeline, pageNumber, + debug) + if boxJson: + _desktopShowBox(currTimeline, boxJson, + screenreader, systemLanguage, espeak, + pageNumber, + newRepliesExist, newDMsExist) + # Turn off the replies indicator + newRepliesExist = False + elif (commandStr.startswith('show sen') or + commandStr.startswith('show out')): + pageNumber = 1 + prevTimelineFirstId = '' + currTimeline = 'outbox' + boxJson = c2sBoxJson(baseDir, session, + nickname, password, + domain, port, httpPrefix, + currTimeline, pageNumber, + debug) + if boxJson: + _desktopShowBox(currTimeline, boxJson, + screenreader, systemLanguage, espeak, + pageNumber, + newRepliesExist, newDMsExist) + elif (commandStr == 'show' or commandStr.startswith('show in') or + commandStr == 'clear'): + pageNumber = 1 + prevTimelineFirstId = '' + currTimeline = 'inbox' + refreshTimeline = True + elif commandStr.startswith('next'): + pageNumber += 1 + prevTimelineFirstId = '' + refreshTimeline = True + elif commandStr.startswith('prev'): + pageNumber -= 1 + if pageNumber < 1: + pageNumber = 1 + prevTimelineFirstId = '' + boxJson = c2sBoxJson(baseDir, session, + nickname, password, + domain, port, httpPrefix, + currTimeline, pageNumber, + debug) + if boxJson: + _desktopShowBox(currTimeline, boxJson, + screenreader, systemLanguage, espeak, + pageNumber, + newRepliesExist, newDMsExist) + elif commandStr.startswith('read ') or commandStr == 'read': + if commandStr == 'read': + postIndexStr = '1' + else: + postIndexStr = commandStr.split('read ')[1] + if boxJson and postIndexStr.isdigit(): + _desktopShowBox(currTimeline, boxJson, + screenreader, systemLanguage, + espeak, pageNumber, + newRepliesExist, newDMsExist) + postIndex = int(postIndexStr) + postJsonObject = \ + _readLocalBoxPost(session, nickname, domain, + httpPrefix, baseDir, currTimeline, + pageNumber, postIndex, boxJson, + systemLanguage, screenreader, + espeak, translate, yourActor) + print('') + elif commandStr.startswith('profile ') or commandStr == 'profile': + if commandStr == 'profile': + postIndexStr = '1' + else: + postIndexStr = commandStr.split('profile ')[1] + if boxJson and postIndexStr.isdigit(): + _desktopShowBox(currTimeline, boxJson, + screenreader, systemLanguage, + espeak, pageNumber, + newRepliesExist, newDMsExist) + postIndex = int(postIndexStr) + actorJson = \ + _showProfile(session, nickname, domain, + httpPrefix, baseDir, currTimeline, + pageNumber, postIndex, boxJson, + systemLanguage, screenreader, + espeak, translate, yourActor) + print('') + elif commandStr == 'reply' or commandStr == 'r': + if postJsonObject: + if postJsonObject.get('id'): + postId = postJsonObject['id'] + subject = None + if postJsonObject['object'].get('summary'): + subject = postJsonObject['object']['summary'] + sessionReply = createSession(proxyType) + _desktopReplyToPost(sessionReply, postId, + baseDir, nickname, password, + domain, port, httpPrefix, + cachedWebfingers, personCache, + debug, subject, + screenreader, systemLanguage, + espeak) + refreshTimeline = True + print('') + elif (commandStr == 'post' or commandStr == 'p' or + commandStr == 'send' or + commandStr.startswith('dm ') or + commandStr.startswith('direct message ') or + commandStr.startswith('post ') or + commandStr.startswith('send ')): + sessionPost = createSession(proxyType) + if commandStr.startswith('dm ') or \ + commandStr.startswith('direct message ') or \ + commandStr.startswith('post ') or \ + commandStr.startswith('send '): + commandStr = commandStr.replace(' to ', ' ') + commandStr = commandStr.replace(' dm ', ' ') + commandStr = commandStr.replace(' DM ', ' ') + # direct message + toHandle = None + if commandStr.startswith('post '): + toHandle = commandStr.split('post ', 1)[1] + elif commandStr.startswith('send '): + toHandle = commandStr.split('send ', 1)[1] + elif commandStr.startswith('dm '): + toHandle = commandStr.split('dm ', 1)[1] + elif commandStr.startswith('direct message '): + toHandle = commandStr.split('direct message ', 1)[1] + if toHandle: + _desktopNewDM(sessionPost, toHandle, + baseDir, nickname, password, + domain, port, httpPrefix, + cachedWebfingers, personCache, + debug, + screenreader, systemLanguage, + espeak) + refreshTimeline = True + else: + # public post + _desktopNewPost(sessionPost, + baseDir, nickname, password, + domain, port, httpPrefix, + cachedWebfingers, personCache, + debug, + screenreader, systemLanguage, + espeak) + refreshTimeline = True + print('') + elif commandStr == 'like' or commandStr.startswith('like '): + currIndex = 0 + if ' ' in commandStr: + postIndex = commandStr.split(' ')[-1].strip() + if postIndex.isdigit(): + currIndex = int(postIndex) + if currIndex > 0 and boxJson: + postJsonObject = \ + _desktopGetBoxPostObject(boxJson, currIndex) + if postJsonObject: + if postJsonObject.get('id'): + likeActor = postJsonObject['object']['attributedTo'] + sayStr = 'Liking post by ' + \ + getNicknameFromActor(likeActor) + _sayCommand(sayStr, sayStr, + screenreader, + systemLanguage, espeak) + sessionLike = createSession(proxyType) + sendLikeViaServer(baseDir, sessionLike, + nickname, password, + domain, port, httpPrefix, + postJsonObject['id'], + cachedWebfingers, personCache, + False, __version__) + refreshTimeline = True + print('') + elif (commandStr == 'undo mute' or + commandStr == 'undo ignore' or + commandStr == 'remove mute' or + commandStr == 'rm mute' or + commandStr == 'unmute' or + commandStr == 'unignore' or + commandStr == 'mute undo' or + commandStr.startswith('undo mute ') or + commandStr.startswith('undo ignore ') or + commandStr.startswith('remove mute ') or + commandStr.startswith('remove ignore ') or + commandStr.startswith('unignore ') or + commandStr.startswith('unmute ')): + currIndex = 0 + if ' ' in commandStr: + postIndex = commandStr.split(' ')[-1].strip() + if postIndex.isdigit(): + currIndex = int(postIndex) + if currIndex > 0 and boxJson: + postJsonObject = \ + _desktopGetBoxPostObject(boxJson, currIndex) + if postJsonObject: + if postJsonObject.get('id'): + muteActor = postJsonObject['object']['attributedTo'] + sayStr = 'Unmuting post by ' + \ + getNicknameFromActor(muteActor) + _sayCommand(sayStr, sayStr, + screenreader, + systemLanguage, espeak) + sessionMute = createSession(proxyType) + sendUndoMuteViaServer(baseDir, sessionMute, + nickname, password, + domain, port, + httpPrefix, postJsonObject['id'], + cachedWebfingers, personCache, + False, __version__) + refreshTimeline = True + print('') + elif (commandStr == 'mute' or + commandStr == 'ignore' or + commandStr.startswith('mute ') or + commandStr.startswith('ignore ')): + currIndex = 0 + if ' ' in commandStr: + postIndex = commandStr.split(' ')[-1].strip() + if postIndex.isdigit(): + currIndex = int(postIndex) + if currIndex > 0 and boxJson: + postJsonObject = \ + _desktopGetBoxPostObject(boxJson, currIndex) + if postJsonObject: + if postJsonObject.get('id'): + muteActor = postJsonObject['object']['attributedTo'] + sayStr = 'Muting post by ' + \ + getNicknameFromActor(muteActor) + _sayCommand(sayStr, sayStr, + screenreader, + systemLanguage, espeak) + sessionMute = createSession(proxyType) + sendMuteViaServer(baseDir, sessionMute, + nickname, password, + domain, port, + httpPrefix, postJsonObject['id'], + cachedWebfingers, personCache, + False, __version__) + refreshTimeline = True + print('') + elif (commandStr == 'undo bookmark' or + commandStr == 'remove bookmark' or + commandStr == 'rm bookmark' or + commandStr == 'undo bm' or + commandStr == 'rm bm' or + commandStr == 'remove bm' or + commandStr == 'unbookmark' or + commandStr == 'bookmark undo' or + commandStr == 'bm undo ' or + commandStr.startswith('undo bm ') or + commandStr.startswith('remove bm ') or + commandStr.startswith('undo bookmark ') or + commandStr.startswith('remove bookmark ') or + commandStr.startswith('unbookmark ') or + commandStr.startswith('unbm ')): + currIndex = 0 + if ' ' in commandStr: + postIndex = commandStr.split(' ')[-1].strip() + if postIndex.isdigit(): + currIndex = int(postIndex) + if currIndex > 0 and boxJson: + postJsonObject = \ + _desktopGetBoxPostObject(boxJson, currIndex) + if postJsonObject: + if postJsonObject.get('id'): + bmActor = postJsonObject['object']['attributedTo'] + sayStr = 'Unbookmarking post by ' + \ + getNicknameFromActor(bmActor) + _sayCommand(sayStr, sayStr, + screenreader, + systemLanguage, espeak) + sessionbm = createSession(proxyType) + sendUndoBookmarkViaServer(baseDir, sessionbm, + nickname, password, + domain, port, httpPrefix, + postJsonObject['id'], + cachedWebfingers, + personCache, + False, __version__) + refreshTimeline = True + print('') + elif (commandStr == 'bookmark' or + commandStr == 'bm' or + commandStr.startswith('bookmark ') or + commandStr.startswith('bm ')): + currIndex = 0 + if ' ' in commandStr: + postIndex = commandStr.split(' ')[-1].strip() + if postIndex.isdigit(): + currIndex = int(postIndex) + if currIndex > 0 and boxJson: + postJsonObject = \ + _desktopGetBoxPostObject(boxJson, currIndex) + if postJsonObject: + if postJsonObject.get('id'): + bmActor = postJsonObject['object']['attributedTo'] + sayStr = 'Bookmarking post by ' + \ + getNicknameFromActor(bmActor) + _sayCommand(sayStr, sayStr, + screenreader, + systemLanguage, espeak) + sessionbm = createSession(proxyType) + sendBookmarkViaServer(baseDir, sessionbm, + nickname, password, + domain, port, httpPrefix, + postJsonObject['id'], + cachedWebfingers, personCache, + False, __version__) + refreshTimeline = True + print('') + elif commandStr == 'unlike' or commandStr == 'undo like': + currIndex = 0 + if ' ' in commandStr: + postIndex = commandStr.split(' ')[-1].strip() + if postIndex.isdigit(): + currIndex = int(postIndex) + if currIndex > 0 and boxJson: + postJsonObject = \ + _desktopGetBoxPostObject(boxJson, currIndex) + if postJsonObject: + if postJsonObject.get('id'): + unlikeActor = postJsonObject['object']['attributedTo'] + sayStr = \ + 'Undoing like of post by ' + \ + getNicknameFromActor(unlikeActor) + _sayCommand(sayStr, sayStr, + screenreader, + systemLanguage, espeak) + sessionUnlike = createSession(proxyType) + sendUndoLikeViaServer(baseDir, sessionUnlike, + nickname, password, + domain, port, httpPrefix, + postJsonObject['id'], + cachedWebfingers, personCache, + False, __version__) + refreshTimeline = True + print('') + elif (commandStr.startswith('announce') or + commandStr.startswith('boost') or + commandStr.startswith('retweet')): + currIndex = 0 + if ' ' in commandStr: + postIndex = commandStr.split(' ')[-1].strip() + if postIndex.isdigit(): + currIndex = int(postIndex) + if currIndex > 0 and boxJson: + postJsonObject = \ + _desktopGetBoxPostObject(boxJson, currIndex) + if postJsonObject: + if postJsonObject.get('id'): + postId = postJsonObject['id'] + announceActor = \ + postJsonObject['object']['attributedTo'] + sayStr = 'Announcing post by ' + \ + getNicknameFromActor(announceActor) + _sayCommand(sayStr, sayStr, + screenreader, + systemLanguage, espeak) + sessionAnnounce = createSession(proxyType) + sendAnnounceViaServer(baseDir, sessionAnnounce, + nickname, password, + domain, port, + httpPrefix, postId, + cachedWebfingers, personCache, + True, __version__) + refreshTimeline = True + print('') + elif (commandStr.startswith('unannounce') or + commandStr.startswith('undo announce') or + commandStr.startswith('unboost') or + commandStr.startswith('undo boost') or + commandStr.startswith('undo retweet')): + currIndex = 0 + if ' ' in commandStr: + postIndex = commandStr.split(' ')[-1].strip() + if postIndex.isdigit(): + currIndex = int(postIndex) + if currIndex > 0 and boxJson: + postJsonObject = \ + _desktopGetBoxPostObject(boxJson, currIndex) + if postJsonObject: + if postJsonObject.get('id'): + postId = postJsonObject['id'] + announceActor = \ + postJsonObject['object']['attributedTo'] + sayStr = 'Undoing announce post by ' + \ + getNicknameFromActor(announceActor) + _sayCommand(sayStr, sayStr, + screenreader, + systemLanguage, espeak) + sessionAnnounce = createSession(proxyType) + sendUndoAnnounceViaServer(baseDir, sessionAnnounce, + postJsonObject, + nickname, password, + domain, port, + httpPrefix, postId, + cachedWebfingers, + personCache, + True, __version__) + refreshTimeline = True + print('') + elif (commandStr == 'follow' or + commandStr.startswith('follow ')): + if commandStr == 'follow': + if actorJson: + followHandle = actorJson['id'] + else: + followHandle = '' + else: + followHandle = commandStr.replace('follow ', '').strip() + if followHandle.startswith('@'): + followHandle = followHandle[1:] + + if '@' in followHandle or '://' in followHandle: + followNickname = getNicknameFromActor(followHandle) + followDomain, followPort = \ + getDomainFromActor(followHandle) + if followNickname and followDomain: + sayStr = 'Sending follow request to ' + \ + followNickname + '@' + followDomain + _sayCommand(sayStr, sayStr, + screenreader, systemLanguage, espeak) + sessionFollow = createSession(proxyType) + sendFollowRequestViaServer(baseDir, + sessionFollow, + nickname, password, + domain, port, + followNickname, + followDomain, + followPort, + httpPrefix, + cachedWebfingers, + personCache, + debug, __version__) + else: + if followHandle: + sayStr = followHandle + ' is not valid' + else: + sayStr = 'Specify a handle to follow' + _sayCommand(sayStr, + screenreader, systemLanguage, espeak) + print('') + elif (commandStr.startswith('unfollow ') or + commandStr.startswith('stop following ')): + followHandle = commandStr.replace('unfollow ', '').strip() + followHandle = followHandle.replace('stop following ', '') + if followHandle.startswith('@'): + followHandle = followHandle[1:] + if '@' in followHandle or '://' in followHandle: + followNickname = getNicknameFromActor(followHandle) + followDomain, followPort = \ + getDomainFromActor(followHandle) + if followNickname and followDomain: + sayStr = 'Stop following ' + \ + followNickname + '@' + followDomain + _sayCommand(sayStr, sayStr, + screenreader, systemLanguage, espeak) + sessionUnfollow = createSession(proxyType) + sendUnfollowRequestViaServer(baseDir, sessionUnfollow, + nickname, password, + domain, port, + followNickname, + followDomain, + followPort, + httpPrefix, + cachedWebfingers, + personCache, + debug, __version__) + else: + sayStr = followHandle + ' is not valid' + _sayCommand(sayStr, sayStr, + screenreader, systemLanguage, espeak) + print('') + elif (commandStr == 'repeat' or commandStr == 'replay' or + commandStr == 'rp' or commandStr == 'again' or + commandStr == 'say again'): + if screenreader and nameStr and \ + gender and messageStr and content: + sayStr = 'Repeating ' + nameStr + _sayCommand(sayStr, sayStr, screenreader, + systemLanguage, espeak, + nameStr, gender) + time.sleep(2) + _sayCommand(content, messageStr, screenreader, + systemLanguage, espeak, + nameStr, gender) + print('') + elif (commandStr == 'sounds on' or + commandStr == 'sound on' or + commandStr == 'sound'): + sayStr = 'Notification sounds on' + _sayCommand(sayStr, sayStr, screenreader, + systemLanguage, espeak) + notificationSounds = True + elif (commandStr == 'sounds off' or + commandStr == 'sound off' or + commandStr == 'nosound'): + sayStr = 'Notification sounds off' + _sayCommand(sayStr, sayStr, screenreader, + systemLanguage, espeak) + notificationSounds = False + elif (commandStr == 'speak' or + commandStr == 'screen reader on' or + commandStr == 'speaker on' or + commandStr == 'talker on' or + commandStr == 'reader on'): + if originalScreenReader: + screenreader = originalScreenReader + sayStr = 'Screen reader on' + _sayCommand(sayStr, sayStr, screenreader, + systemLanguage, espeak) + else: + print('No --screenreader option was specified') + elif (commandStr == 'mute' or + commandStr == 'screen reader off' or + commandStr == 'speaker off' or + commandStr == 'talker off' or + commandStr == 'reader off'): + if originalScreenReader: + screenreader = None + sayStr = 'Screen reader off' + _sayCommand(sayStr, sayStr, originalScreenReader, + systemLanguage, espeak) + else: + print('No --screenreader option was specified') + elif commandStr.startswith('open'): + currIndex = 0 + if ' ' in commandStr: + postIndex = commandStr.split(' ')[-1].strip() + if postIndex.isdigit(): + currIndex = int(postIndex) + if currIndex > 0 and boxJson: + postJsonObject = \ + _desktopGetBoxPostObject(boxJson, currIndex) + if postJsonObject: + if postJsonObject['type'] == 'Announce': + recentPostsCache = {} + allowLocalNetworkAccess = False + YTReplacementDomain = None + postJsonObject2 = \ + downloadAnnounce(session, baseDir, + httpPrefix, + nickname, domain, + postJsonObject, + __version__, translate, + YTReplacementDomain, + allowLocalNetworkAccess, + recentPostsCache, False) + if postJsonObject2: + postJsonObject = postJsonObject2 + if postJsonObject: + content = postJsonObject['object']['content'] + messageStr, detectedLinks = \ + speakableText(baseDir, content, translate) + linkOpened = False + for url in detectedLinks: + if '://' in url: + webbrowser.open(url) + linkOpened = True + if linkOpened: + sayStr = 'Opened web links' + _sayCommand(sayStr, sayStr, originalScreenReader, + systemLanguage, espeak) + else: + sayStr = 'There are no web links to open.' + _sayCommand(sayStr, sayStr, originalScreenReader, + systemLanguage, espeak) + print('') + elif commandStr.startswith('h'): + _desktopHelp() + elif (commandStr == 'delete' or + commandStr == 'rm' or + commandStr.startswith('delete ') or + commandStr.startswith('rm ')): + currIndex = 0 + if ' ' in commandStr: + postIndex = commandStr.split(' ')[-1].strip() + if postIndex.isdigit(): + currIndex = int(postIndex) + if currIndex > 0 and boxJson: + postJsonObject = \ + _desktopGetBoxPostObject(boxJson, currIndex) + if postJsonObject: + if postJsonObject.get('id'): + rmActor = postJsonObject['object']['attributedTo'] + if rmActor != yourActor: + sayStr = 'You can only delete your own posts' + _sayCommand(sayStr, sayStr, + screenreader, + systemLanguage, espeak) + else: + print('') + if postJsonObject['object'].get('summary'): + print(postJsonObject['object']['summary']) + print(postJsonObject['object']['content']) + print('') + sayStr = 'Confirm delete, yes or no?' + _sayCommand(sayStr, sayStr, screenreader, + systemLanguage, espeak) + yesno = input() + if 'y' not in yesno.lower(): + sayStr = 'Deleting post' + _sayCommand(sayStr, sayStr, + screenreader, + systemLanguage, espeak) + sessionrm = createSession(proxyType) + sendDeleteViaServer(baseDir, sessionrm, + nickname, password, + domain, port, + httpPrefix, + postJsonObject['id'], + cachedWebfingers, + personCache, + False, __version__) + refreshTimeline = True + print('') + + if refreshTimeline: + if boxJson: + _desktopShowBox(currTimeline, boxJson, + screenreader, systemLanguage, + espeak, pageNumber, + newRepliesExist, newDMsExist) diff --git a/epicyon.py b/epicyon.py index 567c5372b..b5575c1a4 100644 --- a/epicyon.py +++ b/epicyon.py @@ -22,6 +22,11 @@ from person import deactivateAccount from skills import setSkillLevel from roles import setRole from webfinger import webfingerHandle +from bookmarks import sendBookmarkViaServer +from bookmarks import sendUndoBookmarkViaServer +from posts import sendMuteViaServer +from posts import sendUndoMuteViaServer +from posts import c2sBoxJson from posts import downloadFollowCollection from posts import getPublicPostDomains from posts import getPublicPostDomainsBlocked @@ -48,6 +53,7 @@ from follow import sendUnfollowRequestViaServer from tests import testPostMessageBetweenServers from tests import testFollowBetweenServers from tests import testClientToServer +from tests import testUpdateActor from tests import runAllTests from auth import storeBasicCredentials from auth import createPassword @@ -78,7 +84,7 @@ from theme import setTheme from announce import sendAnnounceViaServer from socnet import instancesGraph from migrate import migrateAccounts -from notifications_client import runNotificationsClient +from desktop_client import runDesktopClient def str2bool(v) -> bool: @@ -304,7 +310,7 @@ parser.add_argument("--notifyShowNewPosts", dest='notifyShowNewPosts', type=str2bool, nargs='?', const=True, default=False, - help="Notification client shows/speaks new posts " + + help="Desktop client shows/speaks new posts " + "as they arrive") parser.add_argument("--noapproval", type=str2bool, nargs='?', const=True, default=False, @@ -404,10 +410,23 @@ parser.add_argument("--allowdeletion", type=str2bool, nargs='?', parser.add_argument('--repeat', '--announce', dest='announce', type=str, default=None, help='Announce/repeat a url') +parser.add_argument('--box', type=str, + default=None, + help='Returns the json for a given timeline, ' + + 'with authentication') +parser.add_argument('--page', '--pageNumber', dest='pageNumber', type=int, + default=1, + help='Page number when using the --box option') 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('--bookmark', '--bm', dest='bookmark', type=str, + default=None, + help='Bookmark the url of a post') +parser.add_argument('--unbookmark', '--unbm', dest='unbookmark', type=str, + default=None, + help='Undo a bookmark given the url of a post') parser.add_argument('--sendto', dest='sendto', type=str, default=None, help='Address to send a post to') parser.add_argument('--attach', dest='attach', type=str, @@ -461,6 +480,10 @@ parser.add_argument('--block', dest='block', type=str, default=None, help='Block a particular address') parser.add_argument('--unblock', dest='unblock', type=str, default=None, help='Remove a block on a particular address') +parser.add_argument('--mute', dest='mute', type=str, default=None, + help='Mute a particular post URL') +parser.add_argument('--unmute', dest='unmute', type=str, default=None, + help='Unmute a particular post URL') parser.add_argument('--delegate', dest='delegate', type=str, default=None, help='Address of an account to delegate a role to') parser.add_argument('--undodelegate', '--undelegate', dest='undelegate', @@ -531,6 +554,7 @@ if args.testsnetwork: testPostMessageBetweenServers() testFollowBetweenServers() testClientToServer() + testUpdateActor() print('All tests succeeded') sys.exit() @@ -1110,6 +1134,46 @@ if args.announce: time.sleep(1) sys.exit() +if args.box: + if not domain: + print('Specify a domain with the --domain option') + sys.exit() + + if not args.nickname: + print('Specify a nickname with the --nickname 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', '') + + proxyType = None + if args.tor or domain.endswith('.onion'): + proxyType = 'tor' + if domain.endswith('.onion'): + args.port = 80 + elif args.i2p or domain.endswith('.i2p'): + proxyType = 'i2p' + if domain.endswith('.i2p'): + args.port = 80 + elif args.gnunet: + proxyType = 'gnunet' + + session = createSession(proxyType) + boxJson = c2sBoxJson(baseDir, session, + args.nickname, args.password, + domain, port, httpPrefix, + args.box, args.pageNumber, + args.debug) + if boxJson: + pprint(boxJson) + else: + print('Box not found: ' + args.box) + sys.exit() + if args.itemName: if not args.password: args.password = getpass.getpass('Password: ') @@ -1254,6 +1318,62 @@ if args.undolike: time.sleep(1) sys.exit() +if args.bookmark: + if not args.nickname: + print('Specify a nickname with the --nickname 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 = {} + print('Sending bookmark of ' + args.bookmark) + + sendBookmarkViaServer(baseDir, session, + args.nickname, args.password, + domain, port, + httpPrefix, args.bookmark, + cachedWebfingers, personCache, + True, __version__) + for i in range(10): + # TODO detect send success/fail + time.sleep(1) + sys.exit() + +if args.unbookmark: + if not args.nickname: + print('Specify a nickname with the --nickname 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 = {} + print('Sending undo bookmark of ' + args.unbookmark) + + sendUndoBookmarkViaServer(baseDir, session, + args.nickname, args.password, + domain, port, + httpPrefix, args.unbookmark, + cachedWebfingers, personCache, + True, __version__) + for i in range(10): + # TODO detect send success/fail + time.sleep(1) + sys.exit() + if args.delete: if not args.nickname: print('Specify a nickname with the --nickname option') @@ -1411,7 +1531,7 @@ if args.migrations: sys.exit() if args.actor: - getActorJson(args.actor, args.http, args.gnunet, False) + getActorJson(args.actor, args.http, args.gnunet, debug) sys.exit() if args.followers: @@ -1868,15 +1988,16 @@ if args.desktop: # only store inbox posts if we are not running as a daemon storeInboxPosts = not args.noKeyPress - runNotificationsClient(baseDir, proxyType, httpPrefix, - nickname, domain, port, args.password, - args.screenreader, args.language, - args.notificationSounds, - args.notificationType, - args.noKeyPress, - storeInboxPosts, - args.notifyShowNewPosts, - args.debug) + runDesktopClient(baseDir, proxyType, httpPrefix, + nickname, domain, port, args.password, + args.screenreader, args.language, + args.notificationSounds, + args.notificationType, + args.noKeyPress, + storeInboxPosts, + args.notifyShowNewPosts, + args.language, + args.debug) sys.exit() if federationList: @@ -1921,6 +2042,60 @@ if args.block: time.sleep(1) sys.exit() +if args.mute: + if not nickname: + print('Specify a nickname with the --nickname 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 = {} + print('Sending mute of ' + args.mute) + + sendMuteViaServer(baseDir, session, nickname, args.password, + domain, port, + httpPrefix, args.mute, + cachedWebfingers, personCache, + True, __version__) + for i in range(10): + # TODO detect send success/fail + time.sleep(1) + sys.exit() + +if args.unmute: + if not nickname: + print('Specify a nickname with the --nickname 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 = {} + print('Sending undo mute of ' + args.unmute) + + sendUndoMuteViaServer(baseDir, session, nickname, args.password, + domain, port, + httpPrefix, args.unmute, + cachedWebfingers, personCache, + True, __version__) + for i in range(10): + # TODO detect send success/fail + time.sleep(1) + sys.exit() + if args.delegate: if not nickname: print('Specify a nickname with the --nickname option') @@ -2121,7 +2296,7 @@ if args.testdata: testFollowersOnly = False testSaveToFile = True - testClientToServer = False + testC2S = False testCommentsEnabled = True testAttachImageFilename = None testMediaType = None @@ -2131,7 +2306,7 @@ if args.testdata: "like this is totally just a #test man", testFollowersOnly, testSaveToFile, - testClientToServer, + testC2S, testCommentsEnabled, testAttachImageFilename, testMediaType, testImageDescription) @@ -2139,7 +2314,7 @@ if args.testdata: "Zoiks!!!", testFollowersOnly, testSaveToFile, - testClientToServer, + testC2S, testCommentsEnabled, testAttachImageFilename, testMediaType, testImageDescription) @@ -2147,7 +2322,7 @@ if args.testdata: "Hey scoob we need like a hundred more #milkshakes", testFollowersOnly, testSaveToFile, - testClientToServer, + testC2S, testCommentsEnabled, testAttachImageFilename, testMediaType, testImageDescription) @@ -2155,7 +2330,7 @@ if args.testdata: "Getting kinda spooky around here", testFollowersOnly, testSaveToFile, - testClientToServer, + testC2S, testCommentsEnabled, testAttachImageFilename, testMediaType, testImageDescription, @@ -2165,7 +2340,7 @@ if args.testdata: "if it wasn't for those pesky hackers", testFollowersOnly, testSaveToFile, - testClientToServer, + testC2S, testCommentsEnabled, 'img/logo.png', 'image/png', 'Description of image') @@ -2173,7 +2348,7 @@ if args.testdata: "man these centralized sites are like the worst!", testFollowersOnly, testSaveToFile, - testClientToServer, + testC2S, testCommentsEnabled, testAttachImageFilename, testMediaType, testImageDescription) @@ -2181,7 +2356,7 @@ if args.testdata: "another mystery solved #test", testFollowersOnly, testSaveToFile, - testClientToServer, + testC2S, testCommentsEnabled, testAttachImageFilename, testMediaType, testImageDescription) @@ -2189,7 +2364,7 @@ if args.testdata: "let's go bowling", testFollowersOnly, testSaveToFile, - testClientToServer, + testC2S, testCommentsEnabled, testAttachImageFilename, testMediaType, testImageDescription) diff --git a/follow.py b/follow.py index 6404eda2a..51b116a89 100644 --- a/follow.py +++ b/follow.py @@ -992,11 +992,11 @@ def sendFollowRequestViaServer(baseDir: str, session, fromDomain, projectVersion, debug) if not wfRequest: if debug: - print('DEBUG: announce webfinger failed for ' + handle) + print('DEBUG: follow request webfinger failed for ' + handle) return 1 if not isinstance(wfRequest, dict): - print('WARN: Webfinger for ' + handle + ' did not return a dict. ' + - str(wfRequest)) + print('WARN: follow request Webfinger for ' + handle + + ' did not return a dict. ' + str(wfRequest)) return 1 postToBox = 'outbox' @@ -1010,11 +1010,12 @@ def sendFollowRequestViaServer(baseDir: str, session, if not inboxUrl: if debug: - print('DEBUG: No ' + postToBox + ' was found for ' + handle) + print('DEBUG: follow request no ' + postToBox + + ' was found for ' + handle) return 3 if not fromPersonId: if debug: - print('DEBUG: No actor was found for ' + handle) + print('DEBUG: follow request no actor was found for ' + handle) return 4 authHeader = createBasicAuthHeader(fromNickname, password) @@ -1025,14 +1026,14 @@ def sendFollowRequestViaServer(baseDir: str, session, 'Authorization': authHeader } postResult = \ - postJson(session, newFollowJson, [], inboxUrl, headers, 30, True) + postJson(session, newFollowJson, [], inboxUrl, headers, 3, True) if not postResult: if debug: - print('DEBUG: POST announce failed for c2s to ' + inboxUrl) + print('DEBUG: POST follow request failed for c2s to ' + inboxUrl) return 5 if debug: - print('DEBUG: c2s POST follow success') + print('DEBUG: c2s POST follow request success') return newFollowJson @@ -1081,11 +1082,11 @@ def sendUnfollowRequestViaServer(baseDir: str, session, fromDomain, projectVersion, debug) if not wfRequest: if debug: - print('DEBUG: announce webfinger failed for ' + handle) + print('DEBUG: unfollow webfinger failed for ' + handle) return 1 if not isinstance(wfRequest, dict): - print('WARN: Webfinger for ' + handle + ' did not return a dict. ' + - str(wfRequest)) + print('WARN: unfollow webfinger for ' + handle + + ' did not return a dict. ' + str(wfRequest)) return 1 postToBox = 'outbox' @@ -1102,11 +1103,12 @@ def sendUnfollowRequestViaServer(baseDir: str, session, if not inboxUrl: if debug: - print('DEBUG: No ' + postToBox + ' was found for ' + handle) + print('DEBUG: unfollow no ' + postToBox + + ' was found for ' + handle) return 3 if not fromPersonId: if debug: - print('DEBUG: No actor was found for ' + handle) + print('DEBUG: unfollow no actor was found for ' + handle) return 4 authHeader = createBasicAuthHeader(fromNickname, password) @@ -1117,10 +1119,10 @@ def sendUnfollowRequestViaServer(baseDir: str, session, 'Authorization': authHeader } postResult = \ - postJson(session, unfollowJson, [], inboxUrl, headers, 30, True) + postJson(session, unfollowJson, [], inboxUrl, headers, 3, True) if not postResult: if debug: - print('DEBUG: POST announce failed for c2s to ' + inboxUrl) + print('DEBUG: POST unfollow failed for c2s to ' + inboxUrl) return 5 if debug: diff --git a/inbox.py b/inbox.py index e69605af7..670275283 100644 --- a/inbox.py +++ b/inbox.py @@ -1082,60 +1082,73 @@ def _receiveBookmark(recentPostsCache: {}, debug: bool) -> bool: """Receives a bookmark activity within the POST section of HTTPServer """ - if messageJson['type'] != 'Bookmark': + if not messageJson.get('type'): + return False + if messageJson['type'] != 'Add': return False if not messageJson.get('actor'): if debug: - print('DEBUG: ' + messageJson['type'] + ' has no actor') + print('DEBUG: no actor in inbox bookmark Add') return False if not messageJson.get('object'): if debug: - print('DEBUG: ' + messageJson['type'] + ' has no object') + print('DEBUG: no object in inbox bookmark Add') return False - if not isinstance(messageJson['object'], str): + if not messageJson.get('target'): if debug: - print('DEBUG: ' + messageJson['type'] + ' object is not a string') + print('DEBUG: no target in inbox bookmark Add') return False - if not messageJson.get('to'): + if not isinstance(messageJson['object'], dict): if debug: - print('DEBUG: ' + messageJson['type'] + ' has no "to" list') + print('DEBUG: inbox bookmark Add object is not string') return False - if '/users/' not in messageJson['actor']: + if not messageJson['object'].get('type'): if debug: - print('DEBUG: "users" missing from actor in ' + - messageJson['type']) + print('DEBUG: no object type in inbox bookmark Add') return False - if '/statuses/' not in messageJson['object']: + if not isinstance(messageJson['target'], str): if debug: - print('DEBUG: "statuses" missing from object in ' + - messageJson['type']) - return False - if domain not in handle.split('@')[1]: - if debug: - print('DEBUG: unrecognized domain ' + handle) + print('DEBUG: inbox bookmark Add target is not string') return False domainFull = getFullDomain(domain, port) nickname = handle.split('@')[0] if not messageJson['actor'].endswith(domainFull + '/users/' + nickname): if debug: - print('DEBUG: ' + - 'bookmark actor should be the same as the handle sent to ' + - handle + ' != ' + messageJson['actor']) + print('DEBUG: inbox bookmark Add unexpected actor') return False - if not os.path.isdir(baseDir + '/accounts/' + handle): - print('DEBUG: unknown recipient of bookmark - ' + handle) - # if this post in the outbox of the person? - postFilename = locatePost(baseDir, nickname, domain, messageJson['object']) + if not messageJson['target'].endswith(messageJson['actor'] + + '/tlbookmarks'): + if debug: + print('DEBUG: inbox bookmark Add target invalid ' + + messageJson['target']) + return False + if messageJson['object']['type'] != 'Document': + if debug: + print('DEBUG: inbox bookmark Add type is not Document') + return False + if not messageJson['object'].get('url'): + if debug: + print('DEBUG: inbox bookmark Add missing url') + return False + if '/statuses/' not in messageJson['object']['url']: + if debug: + print('DEBUG: inbox bookmark Add missing statuses un url') + return False + if debug: + print('DEBUG: c2s inbox bookmark Add request arrived in outbox') + + messageUrl = removeIdEnding(messageJson['object']['url']) + if ':' in domain: + domain = domain.split(':')[0] + postFilename = locatePost(baseDir, nickname, domain, messageUrl) if not postFilename: if debug: - print('DEBUG: post not found in inbox or outbox') - print(messageJson['object']) + print('DEBUG: c2s inbox like post not found in inbox or outbox') + print(messageUrl) return True - if debug: - print('DEBUG: bookmarked post was found') updateBookmarksCollection(recentPostsCache, baseDir, postFilename, - messageJson['object'], + messageJson['object']['url'], messageJson['actor'], domain, debug) return True @@ -1148,63 +1161,74 @@ def _receiveUndoBookmark(recentPostsCache: {}, debug: bool) -> bool: """Receives an undo bookmark activity within the POST section of HTTPServer """ - if messageJson['type'] != 'Undo': + if not messageJson.get('type'): + return False + if messageJson['type'] != 'Remove': return False if not messageJson.get('actor'): + if debug: + print('DEBUG: no actor in inbox undo bookmark Remove') return False if not messageJson.get('object'): + if debug: + print('DEBUG: no object in inbox undo bookmark Remove') + return False + if not messageJson.get('target'): + if debug: + print('DEBUG: no target in inbox undo bookmark Remove') return False if not isinstance(messageJson['object'], dict): + if debug: + print('DEBUG: inbox Remove bookmark object is not dict') return False if not messageJson['object'].get('type'): - return False - if messageJson['object']['type'] != 'Bookmark': - return False - if not messageJson['object'].get('object'): if debug: - print('DEBUG: ' + messageJson['type'] + ' like has no object') + print('DEBUG: no object type in inbox bookmark Remove') return False - if not isinstance(messageJson['object']['object'], str): + if not isinstance(messageJson['target'], str): if debug: - print('DEBUG: ' + messageJson['type'] + - ' like object is not a string') - return False - if '/users/' not in messageJson['actor']: - if debug: - print('DEBUG: "users" missing from actor in ' + - messageJson['type'] + ' like') - return False - if '/statuses/' not in messageJson['object']['object']: - if debug: - print('DEBUG: "statuses" missing from like object in ' + - messageJson['type']) + print('DEBUG: inbox Remove bookmark target is not string') return False domainFull = getFullDomain(domain, port) nickname = handle.split('@')[0] - if domain not in handle.split('@')[1]: - if debug: - print('DEBUG: unrecognized bookmark domain ' + handle) - return False if not messageJson['actor'].endswith(domainFull + '/users/' + nickname): if debug: - print('DEBUG: ' + - 'bookmark actor should be the same as the handle sent to ' + - handle + ' != ' + messageJson['actor']) + print('DEBUG: inbox undo bookmark Remove unexpected actor') return False - if not os.path.isdir(baseDir + '/accounts/' + handle): - print('DEBUG: unknown recipient of bookmark undo - ' + handle) - # if this post in the outbox of the person? - postFilename = locatePost(baseDir, nickname, domain, - messageJson['object']['object']) + if not messageJson['target'].endswith(messageJson['actor'] + + '/tlbookmarks'): + if debug: + print('DEBUG: inbox undo bookmark Remove target invalid ' + + messageJson['target']) + return False + if messageJson['object']['type'] != 'Document': + if debug: + print('DEBUG: inbox undo bookmark Remove type is not Document') + return False + if not messageJson['object'].get('url'): + if debug: + print('DEBUG: inbox undo bookmark Remove missing url') + return False + if '/statuses/' not in messageJson['object']['url']: + if debug: + print('DEBUG: inbox undo bookmark Remove missing statuses un url') + return False + if debug: + print('DEBUG: c2s inbox Remove bookmark ' + + 'request arrived in outbox') + + messageUrl = removeIdEnding(messageJson['object']['url']) + if ':' in domain: + domain = domain.split(':')[0] + postFilename = locatePost(baseDir, nickname, domain, messageUrl) if not postFilename: if debug: - print('DEBUG: unbookmarked post not found in inbox or outbox') - print(messageJson['object']['object']) + print('DEBUG: c2s inbox like post not found in inbox or outbox') + print(messageUrl) return True - if debug: - print('DEBUG: bookmarked post found. Now undoing.') + undoBookmarksCollectionEntry(recentPostsCache, baseDir, postFilename, - messageJson['object'], + messageJson['object']['url'], messageJson['actor'], domain, debug) return True @@ -1588,12 +1612,14 @@ def populateReplies(baseDir: str, httpPrefix: str, domain: str, return False if messageId not in open(postRepliesFilename).read(): repliesFile = open(postRepliesFilename, 'a+') - repliesFile.write(messageId + '\n') - repliesFile.close() + if repliesFile: + repliesFile.write(messageId + '\n') + repliesFile.close() else: repliesFile = open(postRepliesFilename, 'w+') - repliesFile.write(messageId + '\n') - repliesFile.close() + if repliesFile: + repliesFile.write(messageId + '\n') + repliesFile.close() return True diff --git a/like.py b/like.py index acf243f52..f009cbd20 100644 --- a/like.py +++ b/like.py @@ -170,11 +170,11 @@ def sendLikeViaServer(baseDir: str, session, fromDomain, projectVersion, debug) if not wfRequest: if debug: - print('DEBUG: announce webfinger failed for ' + handle) + print('DEBUG: like webfinger failed for ' + handle) return 1 if not isinstance(wfRequest, dict): - print('WARN: Webfinger for ' + handle + ' did not return a dict. ' + - str(wfRequest)) + print('WARN: like webfinger for ' + handle + + ' did not return a dict. ' + str(wfRequest)) return 1 postToBox = 'outbox' @@ -189,11 +189,11 @@ def sendLikeViaServer(baseDir: str, session, if not inboxUrl: if debug: - print('DEBUG: No ' + postToBox + ' was found for ' + handle) + print('DEBUG: like no ' + postToBox + ' was found for ' + handle) return 3 if not fromPersonId: if debug: - print('DEBUG: No actor was found for ' + handle) + print('DEBUG: like no actor was found for ' + handle) return 4 authHeader = createBasicAuthHeader(fromNickname, password) @@ -204,10 +204,10 @@ def sendLikeViaServer(baseDir: str, session, 'Authorization': authHeader } postResult = postJson(session, newLikeJson, [], inboxUrl, - headers, 30, True) + headers, 3, True) if not postResult: if debug: - print('WARN: POST announce failed for c2s to ' + inboxUrl) + print('WARN: POST like failed for c2s to ' + inboxUrl) return 5 if debug: @@ -251,11 +251,11 @@ def sendUndoLikeViaServer(baseDir: str, session, fromDomain, projectVersion, debug) if not wfRequest: if debug: - print('DEBUG: announce webfinger failed for ' + handle) + print('DEBUG: unlike webfinger failed for ' + handle) return 1 if not isinstance(wfRequest, dict): if debug: - print('WARN: Webfinger for ' + handle + + print('WARN: unlike webfinger for ' + handle + ' did not return a dict. ' + str(wfRequest)) return 1 @@ -271,11 +271,11 @@ def sendUndoLikeViaServer(baseDir: str, session, if not inboxUrl: if debug: - print('DEBUG: No ' + postToBox + ' was found for ' + handle) + print('DEBUG: unlike no ' + postToBox + ' was found for ' + handle) return 3 if not fromPersonId: if debug: - print('DEBUG: No actor was found for ' + handle) + print('DEBUG: unlike no actor was found for ' + handle) return 4 authHeader = createBasicAuthHeader(fromNickname, password) @@ -286,14 +286,14 @@ def sendUndoLikeViaServer(baseDir: str, session, 'Authorization': authHeader } postResult = postJson(session, newUndoLikeJson, [], inboxUrl, - headers, 30, True) + headers, 3, True) if not postResult: if debug: - print('WARN: POST announce failed for c2s to ' + inboxUrl) + print('WARN: POST unlike failed for c2s to ' + inboxUrl) return 5 if debug: - print('DEBUG: c2s POST undo like success') + print('DEBUG: c2s POST unlike success') return newUndoLikeJson diff --git a/notifications_client.py b/notifications_client.py deleted file mode 100644 index 281845967..000000000 --- a/notifications_client.py +++ /dev/null @@ -1,1388 +0,0 @@ -__filename__ = "notifications_client.py" -__author__ = "Bob Mottram" -__license__ = "AGPL3+" -__version__ = "1.2.0" -__maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" -__status__ = "Production" - -import os -import html -import time -import sys -import select -import webbrowser -from pathlib import Path -from random import randint -from utils import getStatusNumber -from utils import loadJson -from utils import saveJson -from utils import getNicknameFromActor -from utils import getDomainFromActor -from utils import getFullDomain -from utils import isPGPEncrypted -from session import createSession -from speaker import getSpeakerFromServer -from speaker import getSpeakerPitch -from speaker import getSpeakerRate -from speaker import getSpeakerRange -from like import sendLikeViaServer -from like import sendUndoLikeViaServer -from follow import sendFollowRequestViaServer -from follow import sendUnfollowRequestViaServer -from posts import sendPostViaServer -from announce import sendAnnounceViaServer -from pgp import pgpDecrypt -from pgp import hasLocalPGPkey -from pgp import pgpEncryptToActor - - -def _desktopHelp() -> None: - """Shows help - """ - indent = ' ' - print('') - print(indent + 'Commands:') - print('') - print(indent + 'quit ' + - 'Exit from the notification client') - print(indent + 'show dm|sent|inbox|replies ' + - 'Show a timeline') - print(indent + 'mute ' + - 'Turn off the screen reader') - print(indent + 'speak ' + - 'Turn on the screen reader') - print(indent + 'sounds on ' + - 'Turn on notification sounds') - print(indent + 'sounds off ' + - 'Turn off notification sounds') - print(indent + 'rp ' + - 'Repeat the last post') - print(indent + 'like ' + - 'Like the last post') - print(indent + 'unlike ' + - 'Unlike the last post') - print(indent + 'reply ' + - 'Reply to the last post') - print(indent + 'post ' + - 'Create a new post') - print(indent + 'post to [handle] ' + - 'Create a new direct message') - print(indent + 'announce/boost ' + - 'Boost the last post') - print(indent + 'follow [handle] ' + - 'Make a follow request') - print(indent + 'unfollow [handle] ' + - 'Stop following the give handle') - print(indent + 'next ' + - 'Next page in the timeline') - print(indent + 'prev ' + - 'Previous page in the timeline') - print(indent + 'read [post number] ' + - 'Read a post from a timeline') - print(indent + 'open [post number] ' + - 'Open web links within a timeline post') - print('') - - -def _clearScreen() -> None: - """Clears the screen - """ - os.system('cls' if os.name == 'nt' else 'clear') - - -def _showDesktopBanner() -> None: - """Shows the banner at the top - """ - bannerFilename = 'banner.txt' - if not os.path.isfile(bannerFilename): - bannerTheme = 'starlight' - bannerFilename = 'theme/' + bannerTheme + '/banner.txt' - if not os.path.isfile(bannerFilename): - return - with open(bannerFilename, 'r') as bannerFile: - banner = bannerFile.read() - if banner: - print(banner + '\n') - - -def _waitForKeypress(timeout: int, debug: bool) -> str: - """Waits for a keypress with a timeout - Returns the key pressed, or None on timeout - """ - i, o, e = select.select([sys.stdin], [], [], timeout) - - if (i): - text = sys.stdin.readline().strip() - if debug: - print("Text entered: " + text) - return text - else: - if debug: - print("Timeout") - return None - - -def _speakerEspeak(espeak, pitch: int, rate: int, srange: int, - sayText: str) -> None: - """Speaks the given text with espeak - """ - espeak.set_parameter(espeak.Parameter.Pitch, pitch) - espeak.set_parameter(espeak.Parameter.Rate, rate) - espeak.set_parameter(espeak.Parameter.Range, srange) - espeak.synth(html.unescape(sayText)) - - -def _speakerPicospeaker(pitch: int, rate: int, systemLanguage: str, - sayText: str) -> None: - """TTS using picospeaker - """ - speakerLang = 'en-GB' - if systemLanguage: - if systemLanguage.startswith('fr'): - speakerLang = 'fr-FR' - elif systemLanguage.startswith('es'): - speakerLang = 'es-ES' - elif systemLanguage.startswith('de'): - speakerLang = 'de-DE' - elif systemLanguage.startswith('it'): - speakerLang = 'it-IT' - speakerCmd = 'picospeaker ' + \ - '-l ' + speakerLang + \ - ' -r ' + str(rate) + \ - ' -p ' + str(pitch) + ' "' + \ - html.unescape(sayText) + '" 2> /dev/null' - os.system(speakerCmd) - - -def _playNotificationSound(soundFilename: str, player='ffplay') -> None: - """Plays a sound - """ - if not os.path.isfile(soundFilename): - return - - if player == 'ffplay': - os.system('ffplay ' + soundFilename + - ' -autoexit -hide_banner -nodisp 2> /dev/null') - - -def _desktopNotification(notificationType: str, - title: str, message: str) -> None: - """Shows a desktop notification - """ - if not notificationType: - return - - if notificationType == 'notify-send': - # Ubuntu - os.system('notify-send "' + title + '" "' + message + '"') - elif notificationType == 'zenity': - # Zenity - os.system('zenity --notification --title "' + title + - '" --text="' + message + '"') - elif notificationType == 'osascript': - # Mac - os.system("osascript -e 'display notification \"" + - message + "\" with title \"" + title + "\"'") - elif notificationType == 'New-BurntToastNotification': - # Windows - os.system("New-BurntToastNotification -Text \"" + - title + "\", '" + message + "'") - - -def _textToSpeech(sayStr: str, screenreader: str, - pitch: int, rate: int, srange: int, - systemLanguage: str, espeak=None) -> None: - """Say something via TTS - """ - # speak the post content - if screenreader == 'espeak': - _speakerEspeak(espeak, pitch, rate, srange, sayStr) - elif screenreader == 'picospeaker': - _speakerPicospeaker(pitch, rate, - systemLanguage, sayStr) - - -def _sayCommand(content: str, sayStr: str, screenreader: str, - systemLanguage: str, - espeak=None, - speakerName='screen reader', - speakerGender='They/Them') -> None: - """Speaks a command - """ - print(content) - if not screenreader: - return - - pitch = getSpeakerPitch(speakerName, - screenreader, speakerGender) - rate = getSpeakerRate(speakerName, screenreader) - srange = getSpeakerRange(speakerName) - - _textToSpeech(sayStr, screenreader, - pitch, rate, srange, - systemLanguage, espeak) - - -def _notificationReplyToPost(session, postId: str, - baseDir: str, nickname: str, password: str, - domain: str, port: int, httpPrefix: str, - cachedWebfingers: {}, personCache: {}, - debug: bool, subject: str, - screenreader: str, systemLanguage: str, - espeak) -> None: - """Use the notification client to send a reply to the most recent post - """ - if '://' not in postId: - return - toNickname = getNicknameFromActor(postId) - toDomain, toPort = getDomainFromActor(postId) - sayStr = 'Replying to ' + toNickname + '@' + toDomain - _sayCommand(sayStr, sayStr, - screenreader, systemLanguage, espeak) - sayStr = 'Type your reply message, then press Enter.' - _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) - replyMessage = input() - if not replyMessage: - sayStr = 'No reply was entered.' - _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) - return - replyMessage = replyMessage.strip() - if not replyMessage: - sayStr = 'No reply was entered.' - _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) - return - sayStr = 'You entered this reply:' - _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) - _sayCommand(replyMessage, replyMessage, screenreader, - systemLanguage, espeak) - sayStr = 'Send this reply, yes or no?' - _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) - yesno = input() - if 'y' not in yesno.lower(): - sayStr = 'Abandoning reply' - _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) - return - ccUrl = None - followersOnly = False - attach = None - mediaType = None - attachedImageDescription = None - isArticle = False - subject = None - commentsEnabled = True - sayStr = 'Sending reply' - _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) - if sendPostViaServer(__version__, - baseDir, session, nickname, password, - domain, port, - toNickname, toDomain, toPort, ccUrl, - httpPrefix, replyMessage, followersOnly, - commentsEnabled, attach, mediaType, - attachedImageDescription, - cachedWebfingers, personCache, isArticle, - debug, postId, postId, subject) == 0: - sayStr = 'Reply sent' - else: - sayStr = 'Reply failed' - _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) - - -def _notificationNewPost(session, - baseDir: str, nickname: str, password: str, - domain: str, port: int, httpPrefix: str, - cachedWebfingers: {}, personCache: {}, - debug: bool, - screenreader: str, systemLanguage: str, - espeak) -> None: - """Use the notification client to create a new post - """ - sayStr = 'Create new post' - _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) - sayStr = 'Type your post, then press Enter.' - _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) - newMessage = input() - if not newMessage: - sayStr = 'No post was entered.' - _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) - return - newMessage = newMessage.strip() - if not newMessage: - sayStr = 'No post was entered.' - _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) - return - sayStr = 'You entered this public post:' - _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) - _sayCommand(newMessage, newMessage, screenreader, systemLanguage, espeak) - sayStr = 'Send this post, yes or no?' - _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) - yesno = input() - if 'y' not in yesno.lower(): - sayStr = 'Abandoning new post' - _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) - return - ccUrl = None - followersOnly = False - attach = None - mediaType = None - attachedImageDescription = None - isArticle = False - subject = None - commentsEnabled = True - subject = None - sayStr = 'Sending' - _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) - if sendPostViaServer(__version__, - baseDir, session, nickname, password, - domain, port, - None, '#Public', port, ccUrl, - httpPrefix, newMessage, followersOnly, - commentsEnabled, attach, mediaType, - attachedImageDescription, - cachedWebfingers, personCache, isArticle, - debug, None, None, subject) == 0: - sayStr = 'Post sent' - else: - sayStr = 'Post failed' - _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) - - -def _getSpeakerJsonFromIndex(boxName: str, index: int) -> {}: - """Returns the json for the given post index - """ - homeDir = str(Path.home()) - if not os.path.isdir(homeDir + '/.config'): - os.mkdir(homeDir + '/.config') - if not os.path.isdir(homeDir + '/.config/epicyon'): - os.mkdir(homeDir + '/.config/epicyon') - msgDir = homeDir + '/.config/epicyon/' + boxName - if not os.path.isdir(msgDir): - os.mkdir(msgDir) - indexList = [] - for subdir, dirs, files in os.walk(msgDir): - for f in files: - if not f.endswith('.json'): - continue - indexList.append(f) - indexList.sort(reverse=True) - - index -= 1 - if index <= 0: - index = 0 - if len(indexList) <= index: - return None - - publishedYear = indexList[index].split('-')[0] - publishedMonth = indexList[index].split('-')[1] - speakerJsonFilename = \ - os.path.join(msgDir, - publishedYear + '/' + - publishedMonth + '/' + - indexList[index]) - if not os.path.isfile(speakerJsonFilename): - return None - return loadJson(speakerJsonFilename) - - -def _safeMessage(content: str) -> str: - """Removes anything potentially unsafe from a string - """ - return content.replace('`', '').replace('$(', '$ (') - - -def _readLocalBoxPost(boxName: str, index: int, - systemLanguage: str, - screenreader: str, espeak) -> {}: - """Reads a post from the given timeline - Returns the speaker json - """ - speakerJson = _getSpeakerJsonFromIndex(boxName, index) - if not speakerJson: - return - - nameStr = speakerJson['name'] - gender = 'They/Them' - if speakerJson.get('gender'): - gender = speakerJson['gender'] - - # append image description if needed - if not speakerJson.get('imageDescription'): - messageStr = speakerJson['say'] - else: - messageStr = speakerJson['say'] + '. ' + \ - speakerJson['imageDescription'] - - content = messageStr - if speakerJson.get('content'): - content = speakerJson['content'] - - sayStr = 'Reading ' + boxName + ' post ' + str(index) + '.' - sayStr2 = sayStr.replace(' dm ', ' DM ') - _sayCommand(sayStr, sayStr2, screenreader, systemLanguage, espeak) - - if speakerJson.get('id') and isPGPEncrypted(content): - sayStr = 'Encrypted message. Please enter your passphrase.' - _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) - content = pgpDecrypt(content, speakerJson['id']) - if isPGPEncrypted(content): - sayStr = 'Message could not be decrypted' - _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) - return - - content = _safeMessage(content) - messageStr = _safeMessage(messageStr) - - time.sleep(2) - - # say the speaker's name - _sayCommand(nameStr, nameStr, screenreader, - systemLanguage, espeak, - nameStr, gender) - - time.sleep(2) - - # speak the post content - _sayCommand(content, messageStr, screenreader, - systemLanguage, espeak, - nameStr, gender) - return speakerJson - - -def _showLocalBox(notifyJson: {}, boxName: str, - screenreader: str, systemLanguage: str, espeak, - startPostIndex=0, noOfPosts=10, - newReplies=False, - newDMs=False) -> bool: - """Shows locally stored posts for a given subdirectory - """ - indent = ' ' - homeDir = str(Path.home()) - if not os.path.isdir(homeDir + '/.config'): - os.mkdir(homeDir + '/.config') - if not os.path.isdir(homeDir + '/.config/epicyon'): - os.mkdir(homeDir + '/.config/epicyon') - msgDir = homeDir + '/.config/epicyon/' + boxName - if not os.path.isdir(msgDir): - os.mkdir(msgDir) - index = [] - for subdir, dirs, files in os.walk(msgDir): - for f in files: - if not f.endswith('.json'): - continue - index.append(f) - - # title - _clearScreen() - _showDesktopBanner() - - notificationIcons = '' - if notifyJson: - if notifyJson.get('followRequests'): - notificationIcons += ' 👤' - if newDMs: - notificationIcons += ' 📩' - if newReplies: - notificationIcons += ' 📨' - if notifyJson.get('calendar'): - notificationIcons += ' 📅' - if notifyJson.get('share'): - notificationIcons += ' 🤝' - if notifyJson.get('likedBy'): - if '##sent##' not in notifyJson['likedBy']: - notificationIcons += ' ❤' - titleStr = '\33[7m' + boxName.upper() + '\33[0m' - if notificationIcons: - while len(titleStr) < 95 - len(notificationIcons): - titleStr += ' ' - titleStr += notificationIcons - print(indent + titleStr + '\n') - - if not index: - boxStr = boxName - if boxName == 'dm': - boxStr = 'DM' - sayStr = indent + 'You have no ' + boxStr + ' posts yet.' - _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) - print('') - return False - - maxPostIndex = len(index) - index.sort(reverse=True) - ctr = 0 - for pos in range(startPostIndex, startPostIndex + noOfPosts): - if pos >= maxPostIndex: - break - publishedYear = index[pos].split('-')[0] - publishedMonth = index[pos].split('-')[1] - speakerJsonFilename = \ - os.path.join(msgDir, - publishedYear + '/' + - publishedMonth + '/' + index[pos]) - if not os.path.isfile(speakerJsonFilename): - continue - speakerJson = loadJson(speakerJsonFilename) - if not speakerJson.get('published'): - continue - published = speakerJson['published'].replace('T', ' ') - posStr = str(pos + 1) + '.' - while len(posStr) < 3: - posStr += ' ' - if speakerJson.get('name'): - udata = speakerJson['name'] - name = udata.encode("ascii", "ignore").decode().strip() - else: - name = '' - if len(name) > 16: - name = name[:16] - else: - while len(name) < 16: - name += ' ' - content = speakerJson['content'] - if isPGPEncrypted(content): - content = '🔒' + content - elif speakerJson.get('detectedLinks'): - if len(speakerJson['detectedLinks']) > 0: - content = '🔗' + content - if len(content) > 40: - content = content[:40] - else: - while len(content) < 40: - content += ' ' - print(indent + str(posStr) + ' | ' + name + ' | ' + - published + ' | ' + content) - ctr += 1 - - print('') - - # say the post number range - sayStr = indent + boxName + ' posts ' + str(startPostIndex + 1) + \ - ' to ' + str(startPostIndex + ctr) + '. ' - if newDMs and boxName != 'dm': - sayStr += \ - 'Use \33[3mshow dm\33[0m to view direct messages.' - elif newReplies and boxName != 'replies': - sayStr += \ - 'Use \33[3mshow replies\33[0m to view reply posts.' - else: - sayStr += \ - 'Use the \33[3mnext\33[0m and ' + \ - '\33[3mprev\33[0m commands to navigate.' - sayStr2 = sayStr.replace('\33[3m', '').replace('\33[0m', '') - sayStr2 = sayStr2.replace('show dm', 'show DM') - sayStr2 = sayStr2.replace('dm post', 'Direct message post') - _sayCommand(sayStr, sayStr2, screenreader, systemLanguage, espeak) - if notifyJson: - if notifyJson.get('followRequestsList'): - if len(notifyJson['followRequestsList']) > 0: - print('') - sayStr = indent + 'You have a follow request from ' + \ - '\33[7m' + \ - notifyJson['followRequestsList'][0].strip() + '\33[0m' - sayStr2 = sayStr.replace('\33[7m', '').replace('\33[0m', '') - _sayCommand(sayStr, sayStr2, - screenreader, systemLanguage, espeak) - sayStr = indent + 'Use the \33[3maccept\33[0m or ' + \ - '\33[3mreject\33[0m commands to respond.' - sayStr2 = sayStr.replace('\33[3m', '').replace('\33[0m', '') - _sayCommand(sayStr, sayStr2, - screenreader, systemLanguage, espeak) - print('') - return True - - -def _notificationNewDM(session, toHandle: str, - baseDir: str, nickname: str, password: str, - domain: str, port: int, httpPrefix: str, - cachedWebfingers: {}, personCache: {}, - debug: bool, - screenreader: str, systemLanguage: str, - espeak) -> None: - """Use the notification client to create a new direct message - which can include multiple destination handles - """ - if ' ' in toHandle: - handlesList = toHandle.split(' ') - elif ',' in toHandle: - handlesList = toHandle.split(',') - elif ';' in toHandle: - handlesList = toHandle.split(';') - else: - handlesList = [toHandle] - - for handle in handlesList: - handle = handle.strip() - _notificationNewDMbase(session, handle, - baseDir, nickname, password, - domain, port, httpPrefix, - cachedWebfingers, personCache, - debug, - screenreader, systemLanguage, - espeak) - - -def _storeMessage(speakerJson: {}, boxName: str) -> None: - """Stores a message in your home directory for later reading - """ - if not speakerJson.get('published'): - return - homeDir = str(Path.home()) - if not os.path.isdir(homeDir + '/.config'): - os.mkdir(homeDir + '/.config') - if not os.path.isdir(homeDir + '/.config/epicyon'): - os.mkdir(homeDir + '/.config/epicyon') - msgDir = homeDir + '/.config/epicyon/' + boxName - if not os.path.isdir(msgDir): - os.mkdir(msgDir) - publishedYear = speakerJson['published'].split('-')[0] - yearDir = msgDir + '/' + publishedYear - if not os.path.isdir(yearDir): - os.mkdir(yearDir) - publishedMonth = speakerJson['published'].split('-')[1] - monthDir = yearDir + '/' + publishedMonth - if not os.path.isdir(monthDir): - os.mkdir(monthDir) - - msgFilename = monthDir + '/' + speakerJson['published'] + '.json' - saveJson(speakerJson, msgFilename) - - -def _notificationNewDMbase(session, toHandle: str, - baseDir: str, nickname: str, password: str, - domain: str, port: int, httpPrefix: str, - cachedWebfingers: {}, personCache: {}, - debug: bool, - screenreader: str, systemLanguage: str, - espeak) -> None: - """Use the notification client to create a new direct message - """ - toPort = port - if '://' in toHandle: - toNickname = getNicknameFromActor(toHandle) - toDomain, toPort = getDomainFromActor(toHandle) - toHandle = toNickname + '@' + toDomain - else: - if toHandle.startswith('@'): - toHandle = toHandle[1:] - toNickname = toHandle.split('@')[0] - toDomain = toHandle.split('@')[1] - - sayStr = 'Create new direct message to ' + toHandle - _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) - sayStr = 'Type your direct message, then press Enter.' - _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) - newMessage = input() - if not newMessage: - sayStr = 'No direct message was entered.' - _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) - return - newMessage = newMessage.strip() - if not newMessage: - sayStr = 'No direct message was entered.' - _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) - return - sayStr = 'You entered this direct message to ' + toHandle + ':' - _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) - _sayCommand(newMessage, newMessage, screenreader, systemLanguage, espeak) - ccUrl = None - followersOnly = False - attach = None - mediaType = None - attachedImageDescription = None - isArticle = False - subject = None - commentsEnabled = True - subject = None - - # if there is a local PGP key then attempt to encrypt the DM - # using the PGP public key of the recipient - newMessageOriginal = newMessage - if hasLocalPGPkey(): - sayStr = \ - 'Local PGP key detected...' + \ - 'Fetching PGP public key for ' + toHandle - _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) - paddedMessage = newMessage - if len(paddedMessage) < 32: - # add some padding before and after - # This is to guard against cribs based on small messages, like "Hi" - for before in range(randint(1, 16)): - paddedMessage = ' ' + paddedMessage - for after in range(randint(1, 16)): - paddedMessage += ' ' - cipherText = \ - pgpEncryptToActor(paddedMessage, toHandle) - if not cipherText: - sayStr = \ - toHandle + ' has no PGP public key. ' + \ - 'Your message will be sent in clear text' - _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) - else: - newMessage = cipherText - sayStr = 'Message encrypted' - _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) - - sayStr = 'Send this direct message, yes or no?' - _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) - yesno = input() - if 'y' not in yesno.lower(): - sayStr = 'Abandoning new direct message' - _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) - return - - sayStr = 'Sending' - _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) - if sendPostViaServer(__version__, - baseDir, session, nickname, password, - domain, port, - toNickname, toDomain, toPort, ccUrl, - httpPrefix, newMessage, followersOnly, - commentsEnabled, attach, mediaType, - attachedImageDescription, - cachedWebfingers, personCache, isArticle, - debug, None, None, subject) == 0: - # store the DM locally - statusNumber, published = getStatusNumber() - postId = \ - httpPrefix + '://' + getFullDomain(domain, port) + \ - '/users/' + nickname + '/statuses/' + statusNumber - speakerJson = { - "name": nickname, - "summary": "", - "content": newMessageOriginal, - "say": newMessageOriginal, - "published": published, - "imageDescription": "", - "detectedLinks": [], - "id": postId, - "direct": True - } - _storeMessage(speakerJson, 'sent') - sayStr = 'Direct message sent' - else: - sayStr = 'Direct message failed' - _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) - - -def runNotificationsClient(baseDir: str, proxyType: str, httpPrefix: str, - nickname: str, domain: str, port: int, - password: str, screenreader: str, - systemLanguage: str, - notificationSounds: bool, - notificationType: str, - noKeyPress: bool, - storeInboxPosts: bool, - showNewPosts: bool, - debug: bool) -> None: - """Runs the notifications and screen reader client, - which announces new inbox items - """ - indent = ' ' - if showNewPosts: - indent = '' - - _clearScreen() - _showDesktopBanner() - - espeak = None - if screenreader: - if screenreader == 'espeak': - print('Setting up espeak') - from espeak import espeak - elif screenreader != 'picospeaker': - print(screenreader + ' is not a supported TTS system') - return - - sayStr = indent + 'Running ' + screenreader + ' for ' + \ - nickname + '@' + domain - _sayCommand(sayStr, sayStr, screenreader, - systemLanguage, espeak) - else: - print(indent + 'Running desktop notifications for ' + - nickname + '@' + domain) - if notificationSounds: - sayStr = indent + 'Notification sounds on' - else: - sayStr = indent + 'Notification sounds off' - _sayCommand(sayStr, sayStr, screenreader, - systemLanguage, espeak) - sayStr = indent + '/q or /quit to exit' - _sayCommand(sayStr, sayStr, screenreader, - systemLanguage, espeak) - - currTimeline = '' - currInboxIndex = 0 - if not showNewPosts: - print('') - currInboxIndex = 0 - _showLocalBox(None, 'inbox', - screenreader, systemLanguage, espeak, - currInboxIndex, 10) - currTimeline = 'inbox' - print('') - keyPress = _waitForKeypress(2, debug) - - originalScreenReader = screenreader - domainFull = getFullDomain(domain, port) - actor = httpPrefix + '://' + domainFull + '/users/' + nickname - prevSay = '' - prevCalendar = False - prevFollow = False - prevLike = '' - prevShare = False - dmSoundFilename = 'dm.ogg' - replySoundFilename = 'reply.ogg' - calendarSoundFilename = 'calendar.ogg' - followSoundFilename = 'follow.ogg' - likeSoundFilename = 'like.ogg' - shareSoundFilename = 'share.ogg' - player = 'ffplay' - nameStr = None - gender = None - messageStr = None - content = None - cachedWebfingers = {} - personCache = {} - currDMIndex = 0 - currRepliesIndex = 0 - currSentIndex = 0 - newRepliesExist = False - newDMsExist = False - currPostId = '' - while (1): - session = createSession(proxyType) - notifyJson = None - speakerJson = \ - getSpeakerFromServer(baseDir, session, nickname, password, - domain, port, httpPrefix, True, __version__) - if speakerJson: - if speakerJson.get('notify') and speakerJson.get('id'): - notifyJson = speakerJson['notify'] - title = 'Epicyon' - if speakerJson['notify'].get('title'): - title = speakerJson['notify']['title'] - soundsDir = 'theme/default/sounds' - if speakerJson['notify'].get('theme'): - if isinstance(speakerJson['notify']['theme'], str): - soundsDir = \ - 'theme/' + \ - speakerJson['notify']['theme'] + '/sounds' - if not os.path.isdir(soundsDir): - soundsDir = 'theme/default/sounds' - - indicatorDM = False - if speakerJson.get('direct'): - if speakerJson['direct'] is True: - indicatorDM = True - indicatorReplies = False - if speakerJson.get('replyToYou'): - if speakerJson['replyToYou'] is True: - indicatorReplies = True - - if indicatorDM: - if currPostId != speakerJson['id']: - if notificationSounds: - _playNotificationSound(soundsDir + '/' + - dmSoundFilename, player) - _desktopNotification(notificationType, title, - 'New direct message ' + - actor + '/dm') - elif indicatorReplies: - if currPostId != speakerJson['id']: - if notificationSounds: - _playNotificationSound(soundsDir + '/' + - replySoundFilename, - player) - _desktopNotification(notificationType, title, - 'New reply ' + - actor + '/tlreplies') - elif speakerJson['notify']['calendar'] != prevCalendar: - if speakerJson['notify']['calendar'] is True: - if notificationSounds: - _playNotificationSound(soundsDir + '/' + - calendarSoundFilename, - player) - _desktopNotification(notificationType, title, - 'New calendar event ' + - actor + '/calendar') - prevCalendar = speakerJson['notify']['calendar'] - elif speakerJson['notify']['followRequests'] != prevFollow: - if speakerJson['notify']['followRequests'] is True: - if notificationSounds: - _playNotificationSound(soundsDir + '/' + - followSoundFilename, - player) - _desktopNotification(notificationType, title, - 'New follow request ' + - actor + '/followers#buttonheader') - prevFollow = speakerJson['notify']['followRequests'] - elif speakerJson['notify']['likedBy'] != prevLike: - if '##sent##' not in speakerJson['notify']['likedBy']: - if notificationSounds: - _playNotificationSound(soundsDir + '/' + - likeSoundFilename, player) - _desktopNotification(notificationType, title, - 'New like ' + - speakerJson['notify']['likedBy']) - prevLike = speakerJson['notify']['likedBy'] - elif speakerJson['notify']['share'] != prevShare: - if speakerJson['notify']['share'] is True: - if notificationSounds: - _playNotificationSound(soundsDir + '/' + - shareSoundFilename, - player) - _desktopNotification(notificationType, title, - 'New shared item ' + - actor + '/shares') - prevShare = speakerJson['notify']['share'] - - if speakerJson.get('say'): - if speakerJson['say'] != prevSay: - if speakerJson.get('name'): - nameStr = speakerJson['name'] - gender = 'They/Them' - if speakerJson.get('gender'): - gender = speakerJson['gender'] - - # append image description if needed - if not speakerJson.get('imageDescription'): - messageStr = speakerJson['say'] - else: - messageStr = speakerJson['say'] + '. ' + \ - speakerJson['imageDescription'] - encryptedMessage = False - if speakerJson.get('id') and \ - isPGPEncrypted(messageStr): - encryptedMessage = True - - content = messageStr - if speakerJson.get('content'): - if not encryptedMessage: - content = speakerJson['content'] - else: - content = '🔒 Encrypted message' - - if showNewPosts: - # say the speaker's name - _sayCommand(nameStr, nameStr, screenreader, - systemLanguage, espeak, - nameStr, gender) - - time.sleep(2) - - # speak the post content - content = _safeMessage(content) - messageStr = _safeMessage(messageStr) - _sayCommand(content, messageStr, screenreader, - systemLanguage, espeak, - nameStr, gender) - - # store incoming post - speakerJson['decrypted'] = False - if speakerJson.get('replyToYou'): - newRepliesExist = True - _storeMessage(speakerJson, 'replies') - if speakerJson.get('direct'): - newDMsExist = True - _storeMessage(speakerJson, 'dm') - if storeInboxPosts: - _storeMessage(speakerJson, 'inbox') - - if not showNewPosts: - _clearScreen() - _showLocalBox(notifyJson, currTimeline, - None, systemLanguage, espeak, - currInboxIndex, 10, - newRepliesExist, - newDMsExist) - else: - print('') - - prevSay = speakerJson['say'] - if speakerJson.get('id'): - currPostId = speakerJson['id'] - - # wait for a while, or until a key is pressed - if noKeyPress: - time.sleep(10) - else: - keyPress = _waitForKeypress(30, debug) - if keyPress: - if keyPress.startswith('/'): - keyPress = keyPress[1:] - if keyPress == 'q' or keyPress == 'quit' or keyPress == 'exit': - sayStr = 'Quit' - _sayCommand(sayStr, sayStr, screenreader, - systemLanguage, espeak) - if screenreader: - keyPress = _waitForKeypress(2, debug) - break - elif keyPress.startswith('show dm'): - currDMIndex = 0 - _showLocalBox(notifyJson, 'dm', - screenreader, systemLanguage, espeak, - currDMIndex, 10, - newRepliesExist, newDMsExist) - currTimeline = 'dm' - newDMsExist = False - elif keyPress.startswith('show rep'): - currRepliesIndex = 0 - _showLocalBox(notifyJson, 'replies', - screenreader, systemLanguage, espeak, - currRepliesIndex, 10, - newRepliesExist, newDMsExist) - currTimeline = 'replies' - # Turn off the replies indicator - newRepliesExist = False - elif keyPress.startswith('show sen'): - currSentIndex = 0 - _showLocalBox(notifyJson, 'sent', - screenreader, systemLanguage, espeak, - currSentIndex, 10, - newRepliesExist, newDMsExist) - currTimeline = 'sent' - elif (keyPress == 'show' or keyPress.startswith('show in') or - keyPress == 'clear'): - currInboxIndex = 0 - _showLocalBox(notifyJson, 'inbox', - screenreader, systemLanguage, espeak, - currInboxIndex, 10, - newRepliesExist, newDMsExist) - currTimeline = 'inbox' - elif keyPress.startswith('next'): - if currTimeline == 'dm': - currDMIndex += 10 - _showLocalBox(notifyJson, 'dm', - screenreader, systemLanguage, espeak, - currDMIndex, 10, - newRepliesExist, newDMsExist) - elif currTimeline == 'replies': - currRepliesIndex += 10 - _showLocalBox(notifyJson, 'replies', - screenreader, systemLanguage, espeak, - currRepliesIndex, 10, - newRepliesExist, newDMsExist) - elif currTimeline == 'sent': - currSentIndex += 10 - _showLocalBox(notifyJson, 'sent', - screenreader, systemLanguage, espeak, - currSentIndex, 10, - newRepliesExist, newDMsExist) - elif currTimeline == 'inbox': - currInboxIndex += 10 - _showLocalBox(notifyJson, 'inbox', - screenreader, systemLanguage, espeak, - currInboxIndex, 10, - newRepliesExist, newDMsExist) - elif keyPress.startswith('prev'): - if currTimeline == 'dm': - currDMIndex -= 10 - if currDMIndex < 0: - currDMIndex = 0 - _showLocalBox(notifyJson, 'dm', - screenreader, systemLanguage, espeak, - currDMIndex, 10, - newRepliesExist, newDMsExist) - elif currTimeline == 'replies': - currRepliesIndex -= 10 - if currRepliesIndex < 0: - currRepliesIndex = 0 - _showLocalBox(notifyJson, 'replies', - screenreader, systemLanguage, espeak, - currRepliesIndex, 10, - newRepliesExist, newDMsExist) - elif currTimeline == 'sent': - currSentIndex -= 10 - if currSentIndex < 0: - currSentIndex = 0 - _showLocalBox(notifyJson, 'sent', - screenreader, systemLanguage, espeak, - currSentIndex, 10, - newRepliesExist, newDMsExist) - elif currTimeline == 'inbox': - currInboxIndex -= 10 - if currInboxIndex < 0: - currInboxIndex = 0 - _showLocalBox(notifyJson, 'inbox', - screenreader, systemLanguage, espeak, - currInboxIndex, 10, - newRepliesExist, newDMsExist) - elif keyPress.startswith('read ') or keyPress == 'read': - if keyPress == 'read': - postIndexStr = '1' - else: - postIndexStr = keyPress.split('read ')[1] - if postIndexStr.isdigit(): - postIndex = int(postIndexStr) - speakerJson = \ - _readLocalBoxPost(currTimeline, postIndex, - systemLanguage, screenreader, - espeak) - print('') - elif keyPress == 'reply' or keyPress == 'r': - if speakerJson.get('id'): - postId = speakerJson['id'] - subject = None - if speakerJson.get('summary'): - subject = speakerJson['summary'] - sessionReply = createSession(proxyType) - _notificationReplyToPost(sessionReply, postId, - baseDir, nickname, password, - domain, port, httpPrefix, - cachedWebfingers, personCache, - debug, subject, - screenreader, systemLanguage, - espeak) - print('') - elif (keyPress == 'post' or keyPress == 'p' or - keyPress == 'send' or - keyPress.startswith('dm ') or - keyPress.startswith('direct message ') or - keyPress.startswith('post ') or - keyPress.startswith('send ')): - sessionPost = createSession(proxyType) - if keyPress.startswith('dm ') or \ - keyPress.startswith('direct message ') or \ - keyPress.startswith('post ') or \ - keyPress.startswith('send '): - keyPress = keyPress.replace(' to ', ' ') - keyPress = keyPress.replace(' dm ', ' ') - keyPress = keyPress.replace(' DM ', ' ') - # direct message - toHandle = None - if keyPress.startswith('post '): - toHandle = keyPress.split('post ', 1)[1] - elif keyPress.startswith('send '): - toHandle = keyPress.split('send ', 1)[1] - elif keyPress.startswith('dm '): - toHandle = keyPress.split('dm ', 1)[1] - elif keyPress.startswith('direct message '): - toHandle = keyPress.split('direct message ', 1)[1] - if toHandle: - _notificationNewDM(sessionPost, toHandle, - baseDir, nickname, password, - domain, port, httpPrefix, - cachedWebfingers, personCache, - debug, - screenreader, systemLanguage, - espeak) - else: - # public post - _notificationNewPost(sessionPost, - baseDir, nickname, password, - domain, port, httpPrefix, - cachedWebfingers, personCache, - debug, - screenreader, systemLanguage, - espeak) - print('') - elif keyPress == 'like': - if speakerJson.get('id'): - sayStr = 'Liking post by ' + speakerJson['name'] - _sayCommand(sayStr, sayStr, - screenreader, - systemLanguage, espeak) - sessionLike = createSession(proxyType) - sendLikeViaServer(baseDir, sessionLike, - nickname, password, - domain, port, - httpPrefix, speakerJson['id'], - cachedWebfingers, personCache, - False, __version__) - print('') - elif keyPress == 'unlike' or keyPress == 'undo like': - if speakerJson.get('id'): - sayStr = 'Undoing like of post by ' + speakerJson['name'] - _sayCommand(sayStr, sayStr, - screenreader, - systemLanguage, espeak) - sessionUnlike = createSession(proxyType) - sendUndoLikeViaServer(baseDir, sessionUnlike, - nickname, password, - domain, port, - httpPrefix, speakerJson['id'], - cachedWebfingers, personCache, - False, __version__) - print('') - elif (keyPress == 'announce' or - keyPress == 'boost' or - keyPress == 'retweet'): - if speakerJson.get('id'): - postId = speakerJson['id'] - sayStr = 'Announcing post by ' + speakerJson['name'] - _sayCommand(sayStr, sayStr, - screenreader, - systemLanguage, espeak) - sessionAnnounce = createSession(proxyType) - sendAnnounceViaServer(baseDir, sessionAnnounce, - nickname, password, - domain, port, - httpPrefix, postId, - cachedWebfingers, personCache, - True, __version__) - print('') - elif keyPress.startswith('follow '): - followHandle = keyPress.replace('follow ', '').strip() - if followHandle.startswith('@'): - followHandle = followHandle[1:] - if '@' in followHandle or '://' in followHandle: - followNickname = getNicknameFromActor(followHandle) - followDomain, followPort = \ - getDomainFromActor(followHandle) - if followNickname and followDomain: - sayStr = 'Sending follow request to ' + \ - followNickname + '@' + followDomain - _sayCommand(sayStr, sayStr, - screenreader, systemLanguage, espeak) - sessionFollow = createSession(proxyType) - sendFollowRequestViaServer(baseDir, sessionFollow, - nickname, password, - domain, port, - followNickname, - followDomain, - followPort, - httpPrefix, - cachedWebfingers, - personCache, - debug, __version__) - else: - sayStr = followHandle + ' is not valid' - _sayCommand(sayStr, - screenreader, systemLanguage, espeak) - print('') - elif (keyPress.startswith('unfollow ') or - keyPress.startswith('stop following ')): - followHandle = keyPress.replace('unfollow ', '').strip() - followHandle = followHandle.replace('stop following ', '') - if followHandle.startswith('@'): - followHandle = followHandle[1:] - if '@' in followHandle or '://' in followHandle: - followNickname = getNicknameFromActor(followHandle) - followDomain, followPort = \ - getDomainFromActor(followHandle) - if followNickname and followDomain: - sayStr = 'Stop following ' + \ - followNickname + '@' + followDomain - _sayCommand(sayStr, sayStr, - screenreader, systemLanguage, espeak) - sessionUnfollow = createSession(proxyType) - sendUnfollowRequestViaServer(baseDir, sessionUnfollow, - nickname, password, - domain, port, - followNickname, - followDomain, - followPort, - httpPrefix, - cachedWebfingers, - personCache, - debug, __version__) - else: - sayStr = followHandle + ' is not valid' - _sayCommand(sayStr, sayStr, - screenreader, systemLanguage, espeak) - print('') - elif (keyPress == 'repeat' or keyPress == 'replay' or - keyPress == 'rp' or keyPress == 'again' or - keyPress == 'say again'): - if screenreader and nameStr and \ - gender and messageStr and content: - sayStr = 'Repeating ' + nameStr - _sayCommand(sayStr, sayStr, screenreader, - systemLanguage, espeak, - nameStr, gender) - time.sleep(2) - _sayCommand(content, messageStr, screenreader, - systemLanguage, espeak, - nameStr, gender) - print('') - elif (keyPress == 'sounds on' or - keyPress == 'sound on' or - keyPress == 'sound'): - sayStr = 'Notification sounds on' - _sayCommand(sayStr, sayStr, screenreader, - systemLanguage, espeak) - notificationSounds = True - elif (keyPress == 'sounds off' or - keyPress == 'sound off' or - keyPress == 'nosound'): - sayStr = 'Notification sounds off' - _sayCommand(sayStr, sayStr, screenreader, - systemLanguage, espeak) - notificationSounds = False - elif (keyPress == 'speak' or - keyPress == 'screen reader on' or - keyPress == 'speaker on' or - keyPress == 'talker on' or - keyPress == 'reader on'): - if originalScreenReader: - screenreader = originalScreenReader - sayStr = 'Screen reader on' - _sayCommand(sayStr, sayStr, screenreader, - systemLanguage, espeak) - else: - print('No --screenreader option was specified') - elif (keyPress == 'mute' or - keyPress == 'screen reader off' or - keyPress == 'speaker off' or - keyPress == 'talker off' or - keyPress == 'reader off'): - if originalScreenReader: - screenreader = None - sayStr = 'Screen reader off' - _sayCommand(sayStr, sayStr, originalScreenReader, - systemLanguage, espeak) - else: - print('No --screenreader option was specified') - elif keyPress.startswith('open'): - currIndex = 0 - if ' ' in keyPress: - postIndex = keyPress.split(' ')[-1].strip() - if postIndex.isdigit(): - currIndex = int(postIndex) - speakerJson = \ - _getSpeakerJsonFromIndex(currTimeline, currIndex) - if not speakerJson: - speakerJson = {} - linkOpened = False - if speakerJson.get('detectedLinks'): - if len(speakerJson['detectedLinks']) > 0: - for url in speakerJson['detectedLinks']: - if '://' in url: - webbrowser.open(url) - linkOpened = True - if linkOpened: - sayStr = 'Opened web links' - _sayCommand(sayStr, sayStr, originalScreenReader, - systemLanguage, espeak) - if not linkOpened: - sayStr = 'There are no web links to open.' - _sayCommand(sayStr, sayStr, originalScreenReader, - systemLanguage, espeak) - print('') - elif keyPress.startswith('accept'): - if notifyJson: - if notifyJson.get('followRequestsList'): - if len(notifyJson['followRequestsList']) > 0: - sayStr = 'Accepting follow request for ' + \ - notifyJson['followRequestsList'][0] - _sayCommand(sayStr, sayStr, originalScreenReader, - systemLanguage, espeak) - # TODO - sayStr = 'This command is not yet implemented' - _sayCommand(sayStr, sayStr, originalScreenReader, - systemLanguage, espeak) - print('') - elif keyPress.startswith('reject'): - if notifyJson: - if notifyJson.get('followRequestsList'): - if len(notifyJson['followRequestsList']) > 0: - sayStr = 'Rejecting follow request for ' + \ - notifyJson['followRequestsList'][0] - _sayCommand(sayStr, sayStr, originalScreenReader, - systemLanguage, espeak) - # TODO - sayStr = 'This command is not yet implemented' - _sayCommand(sayStr, sayStr, originalScreenReader, - systemLanguage, espeak) - print('') - elif keyPress.startswith('h'): - _desktopHelp() diff --git a/outbox.py b/outbox.py index 7726cf6fb..9243c7aa6 100644 --- a/outbox.py +++ b/outbox.py @@ -21,9 +21,13 @@ from utils import removeIdEnding from utils import getDomainFromActor from utils import dangerousMarkup from utils import isFeaturedWriter +from utils import loadJson +from utils import saveJson from blocking import isBlockedDomain from blocking import outboxBlock from blocking import outboxUndoBlock +from blocking import outboxMute +from blocking import outboxUndoMute from media import replaceYouTube from media import getMediaPath from media import createMediaDirs @@ -42,6 +46,123 @@ from shares import outboxShareUpload from shares import outboxUndoShareUpload +def _outboxPersonReceiveUpdate(recentPostsCache: {}, + baseDir: str, httpPrefix: str, + nickname: str, domain: str, port: int, + messageJson: {}, debug: bool) -> None: + """ Receive an actor update from c2s + For example, setting the PGP key from the desktop client + """ + # these attachments are updatable via c2s + updatableAttachments = ('PGP', 'OpenPGP', 'Email') + + if not messageJson.get('type'): + return + print("messageJson['type'] " + messageJson['type']) + if messageJson['type'] != 'Update': + return + if not messageJson.get('object'): + return + if not isinstance(messageJson['object'], dict): + if debug: + print('DEBUG: c2s actor update object is not dict') + return + if not messageJson['object'].get('type'): + if debug: + print('DEBUG: c2s actor update - no type') + return + if messageJson['object']['type'] != 'Person': + if debug: + print('DEBUG: not a c2s actor update') + return + if not messageJson.get('to'): + if debug: + print('DEBUG: c2s actor update has no "to" field') + return + if not messageJson.get('actor'): + if debug: + print('DEBUG: c2s actor update has no actor field') + return + if not messageJson.get('id'): + if debug: + print('DEBUG: c2s actor update has no id field') + return + actor = \ + httpPrefix + '://' + getFullDomain(domain, port) + '/users/' + nickname + if len(messageJson['to']) != 1: + if debug: + print('DEBUG: c2s actor update - to does not contain one actor ' + + messageJson['to']) + return + if messageJson['to'][0] != actor: + if debug: + print('DEBUG: c2s actor update - to does not contain actor ' + + messageJson['to'] + ' ' + actor) + return + if not messageJson['id'].startswith(actor + '#updates/'): + if debug: + print('DEBUG: c2s actor update - unexpected id ' + + messageJson['id']) + return + updatedActorJson = messageJson['object'] + # load actor from file + actorFilename = baseDir + '/accounts/' + nickname + '@' + domain + '.json' + if not os.path.isfile(actorFilename): + print('actorFilename not found: ' + actorFilename) + return + actorJson = loadJson(actorFilename) + if not actorJson: + return + actorChanged = False + # update fields within actor + if 'attachment' in updatedActorJson: + for newPropertyValue in updatedActorJson['attachment']: + if not newPropertyValue.get('name'): + continue + if newPropertyValue['name'] not in updatableAttachments: + continue + if not newPropertyValue.get('type'): + continue + if not newPropertyValue.get('value'): + continue + if newPropertyValue['type'] != 'PropertyValue': + continue + if 'attachment' in actorJson: + found = False + for attachIdx in range(len(actorJson['attachment'])): + if actorJson['attachment'][attachIdx]['type'] != \ + 'PropertyValue': + continue + if actorJson['attachment'][attachIdx]['name'] != \ + newPropertyValue['name']: + continue + else: + if actorJson['attachment'][attachIdx]['value'] != \ + newPropertyValue['value']: + actorJson['attachment'][attachIdx]['value'] = \ + newPropertyValue['value'] + actorChanged = True + found = True + break + if not found: + actorJson['attachment'].append({ + "name": newPropertyValue['name'], + "type": "PropertyValue", + "value": newPropertyValue['value'] + }) + actorChanged = True + # save actor to file + if actorChanged: + saveJson(actorJson, actorFilename) + if debug: + print('actor saved: ' + actorFilename) + if debug: + print('New attachment: ' + str(actorJson['attachment'])) + messageJson['object'] = actorJson + if debug: + print('DEBUG: actor update via c2s - ' + nickname + '@' + domain) + + def postMessageToOutbox(session, translate: {}, messageJson: {}, postToNickname: str, server, baseDir: str, httpPrefix: str, @@ -190,7 +311,8 @@ def postMessageToOutbox(session, translate: {}, permittedOutboxTypes = ('Create', 'Announce', 'Like', 'Follow', 'Undo', 'Update', 'Add', 'Remove', 'Block', 'Delete', - 'Delegate', 'Skill', 'Bookmark', 'Event') + 'Delegate', 'Skill', 'Add', 'Remove', 'Event', + 'Ignore') if messageJson['type'] not in permittedOutboxTypes: if debug: print('DEBUG: POST to outbox - ' + messageJson['type'] + @@ -396,6 +518,22 @@ def postMessageToOutbox(session, translate: {}, postToNickname, domain, port, messageJson, debug) + if debug: + print('DEBUG: handle mute requests') + outboxMute(baseDir, httpPrefix, + postToNickname, domain, + port, + messageJson, debug, + recentPostsCache) + + if debug: + print('DEBUG: handle undo mute requests') + outboxUndoMute(baseDir, httpPrefix, + postToNickname, domain, + port, + messageJson, debug, + recentPostsCache) + if debug: print('DEBUG: handle share uploads') outboxShareUpload(baseDir, httpPrefix, @@ -408,6 +546,13 @@ def postMessageToOutbox(session, translate: {}, postToNickname, domain, port, messageJson, debug) + if debug: + print('DEBUG: handle actor updates from c2s') + _outboxPersonReceiveUpdate(recentPostsCache, + baseDir, httpPrefix, + postToNickname, domain, port, + messageJson, debug) + if debug: print('DEBUG: sending c2s post to named addresses') if messageJson.get('to'): diff --git a/person.py b/person.py index 2d6be5a4d..d56c0fd5f 100644 --- a/person.py +++ b/person.py @@ -1106,6 +1106,8 @@ def getActorJson(handle: str, http: bool, gnunet: bool, debug: bool, quiet=False) -> {}: """Returns the actor json """ + if debug: + print('getActorJson for ' + handle) originalActor = handle if '/@' in handle or \ '/users/' in handle or \ @@ -1117,8 +1119,8 @@ def getActorJson(handle: str, http: bool, gnunet: bool, handle = handle.replace(prefix, '') handle = handle.replace('/@', '/users/') if not hasUsersPath(handle): - if not quiet: - print('Expected actor format: ' + + if not quiet or debug: + print('getActorJson: Expected actor format: ' + 'https://domain/@nick or https://domain/users/nick') return None if '/users/' in handle: @@ -1145,13 +1147,13 @@ def getActorJson(handle: str, http: bool, gnunet: bool, # format: @nick@domain if '@' not in handle: if not quiet: - print('Syntax: --actor nickname@domain') + print('getActorJson Syntax: --actor nickname@domain') return None if handle.startswith('@'): handle = handle[1:] if '@' not in handle: if not quiet: - print('Syntax: --actor nickname@domain') + print('getActorJsonSyntax: --actor nickname@domain') return None nickname = handle.split('@')[0] domain = handle.split('@')[1] @@ -1168,7 +1170,10 @@ def getActorJson(handle: str, http: bool, gnunet: bool, httpPrefix = 'gnunet' proxyType = 'gnunet' else: - httpPrefix = 'https' + if '127.0.' not in domain and '192.168.' not in domain: + httpPrefix = 'https' + else: + httpPrefix = 'http' session = createSession(proxyType) if nickname == 'inbox': nickname = domain @@ -1179,12 +1184,12 @@ def getActorJson(handle: str, http: bool, gnunet: bool, None, __version__, debug) if not wfRequest: if not quiet: - print('Unable to webfinger ' + handle) + print('getActorJson Unable to webfinger ' + handle) return None if not isinstance(wfRequest, dict): if not quiet: - print('Webfinger for ' + handle + ' did not return a dict. ' + - str(wfRequest)) + print('getActorJson Webfinger for ' + handle + + ' did not return a dict. ' + str(wfRequest)) return None if not quiet: @@ -1192,11 +1197,13 @@ def getActorJson(handle: str, http: bool, gnunet: bool, personUrl = None if wfRequest.get('errors'): - if not quiet: - print('wfRequest error: ' + str(wfRequest['errors'])) + if not quiet or debug: + print('getActorJson wfRequest error: ' + str(wfRequest['errors'])) if hasUsersPath(handle): personUrl = originalActor else: + if debug: + print('No users path in ' + handle) return None profileStr = 'https://www.w3.org/ns/activitystreams' @@ -1230,6 +1237,7 @@ def getActorJson(handle: str, http: bool, gnunet: bool, if personJson: if not quiet: pprint(personJson) + return personJson else: profileStr = 'https://www.w3.org/ns/activitystreams' asHeader = { @@ -1240,6 +1248,7 @@ def getActorJson(handle: str, http: bool, gnunet: bool, debug, __version__, httpPrefix, None) if not quiet: if personJson: + print('getActorJson returned actor') pprint(personJson) else: print('Failed to get ' + personUrl) diff --git a/pgp.py b/pgp.py index 5724127a4..9b18c8cc3 100644 --- a/pgp.py +++ b/pgp.py @@ -12,6 +12,12 @@ from pathlib import Path from person import getActorJson from utils import containsPGPPublicKey from utils import isPGPEncrypted +from utils import getFullDomain +from utils import getStatusNumber +from webfinger import webfingerHandle +from posts import getPersonBox +from auth import createBasicAuthHeader +from session import postJson def getEmailAddress(actorJson: {}) -> str: @@ -330,7 +336,7 @@ def _getPGPPublicKeyFromActor(handle: str, actorJson=None) -> str: public key specified """ if not actorJson: - actorJson = getActorJson(handle, False, False, True) + actorJson = getActorJson(handle, False, False, False, True) if not actorJson: return None if not actorJson.get('attachment'): @@ -395,3 +401,200 @@ def pgpDecrypt(content: str, fromHandle: str) -> str: return content decryptResult = decryptResult.decode('utf-8').strip() return decryptResult + + +def _pgpLocalPublicKeyId() -> str: + """Gets the local pgp public key ID + """ + cmdStr = \ + "gpgconf --list-options gpg | " + \ + "awk -F: '$1 == \"default-key\" {print $10}'" + proc = subprocess.Popen([cmdStr], + stdout=subprocess.PIPE, shell=True) + (result, err) = proc.communicate() + if err: + return None + if not result: + return None + if len(result) < 5: + return None + return result.decode('utf-8').replace('"', '').strip() + + +def _pgpLocalPublicKey() -> str: + """Gets the local pgp public key + """ + keyId = _pgpLocalPublicKeyId() + if not keyId: + return None + cmdStr = "gpg --armor --export " + keyId + proc = subprocess.Popen([cmdStr], + stdout=subprocess.PIPE, shell=True) + (result, err) = proc.communicate() + if err: + return None + if not result: + return None + return extractPGPPublicKey(result.decode('utf-8')) + + +def pgpPublicKeyUpload(baseDir: str, session, + nickname: str, password: str, + domain: str, port: int, + httpPrefix: str, + cachedWebfingers: {}, personCache: {}, + debug: bool, test: str) -> {}: + if debug: + print('pgpPublicKeyUpload') + + if not session: + if debug: + print('WARN: No session for pgpPublicKeyUpload') + return None + + if not test: + if debug: + print('Getting PGP public key') + PGPpubKey = _pgpLocalPublicKey() + if not PGPpubKey: + return None + PGPpubKeyId = _pgpLocalPublicKeyId() + else: + if debug: + print('Testing with PGP public key ' + test) + PGPpubKey = test + PGPpubKeyId = None + + domainFull = getFullDomain(domain, port) + if debug: + print('PGP test domain: ' + domainFull) + + handle = nickname + '@' + domainFull + + if debug: + print('Getting actor for ' + handle) + + actorJson = getActorJson(handle, False, False, debug, True) + if not actorJson: + if debug: + print('No actor returned for ' + handle) + return None + + if debug: + print('Actor for ' + handle + ' obtained') + + actor = httpPrefix + '://' + domainFull + '/users/' + nickname + handle = actor.replace('/users/', '/@') + + # check that this looks like the correct actor + if not actorJson.get('id'): + if debug: + print('Actor has no id') + return None + if not actorJson.get('url'): + if debug: + print('Actor has no url') + return None + if not actorJson.get('type'): + if debug: + print('Actor has no type') + return None + if actorJson['id'] != actor: + if debug: + print('Actor id is not ' + actor + + ' instead is ' + actorJson['id']) + return None + if actorJson['url'] != handle: + if debug: + print('Actor url is not ' + handle) + return None + if actorJson['type'] != 'Person': + if debug: + print('Actor type is not Person') + return None + + # set the pgp details + if PGPpubKeyId: + setPGPfingerprint(actorJson, PGPpubKeyId) + else: + if debug: + print('No PGP key Id. Continuing anyway.') + + if debug: + print('Setting PGP key within ' + actor) + setPGPpubKey(actorJson, PGPpubKey) + + # create an actor update + statusNumber, published = getStatusNumber() + actorUpdate = { + '@context': 'https://www.w3.org/ns/activitystreams', + 'id': actor + '#updates/' + statusNumber, + 'type': 'Update', + 'actor': actor, + 'to': [actor], + 'cc': [], + 'object': actorJson + } + if debug: + print('actor update is ' + str(actorUpdate)) + + # lookup the inbox for the To handle + wfRequest = \ + webfingerHandle(session, handle, httpPrefix, cachedWebfingers, + domain, __version__, debug) + if not wfRequest: + if debug: + print('DEBUG: pgp actor update webfinger failed for ' + + handle) + return None + if not isinstance(wfRequest, dict): + if debug: + print('WARN: Webfinger for ' + handle + + ' did not return a dict. ' + str(wfRequest)) + return None + + postToBox = 'outbox' + + # get the actor inbox for the To handle + (inboxUrl, pubKeyId, pubKey, + fromPersonId, sharedInbox, avatarUrl, + displayName) = getPersonBox(baseDir, session, wfRequest, personCache, + __version__, httpPrefix, nickname, + domain, postToBox, 52025) + + if not inboxUrl: + if debug: + print('DEBUG: No ' + postToBox + ' was found for ' + handle) + return None + if not fromPersonId: + if debug: + print('DEBUG: No actor was found for ' + handle) + return None + + authHeader = createBasicAuthHeader(nickname, password) + + headers = { + 'host': domain, + 'Content-type': 'application/json', + 'Authorization': authHeader + } + quiet = not debug + tries = 0 + while tries < 4: + postResult = \ + postJson(session, actorUpdate, [], inboxUrl, + headers, 5, quiet) + if postResult: + break + tries += 1 + + if postResult is None: + if debug: + print('DEBUG: POST pgp actor update failed for c2s to ' + + inboxUrl) + return None + + if debug: + print('DEBUG: c2s POST pgp actor update success') + + return actorUpdate diff --git a/posts.py b/posts.py index 7da14e9bd..74ea9133b 100644 --- a/posts.py +++ b/posts.py @@ -40,8 +40,6 @@ from utils import validPostDate from utils import getFullDomain from utils import getFollowersList from utils import isEvil -from utils import removeIdEnding -from utils import getCachedPostFilename from utils import getStatusNumber from utils import createPersonDir from utils import urlPermitted @@ -259,7 +257,7 @@ def getPersonBox(baseDir: str, session, wfRequest: {}, personJson = getJson(session, personUrl, asHeader, None, debug, projectVersion, httpPrefix, domain) if not personJson: - print('Unable to get actor') + print('Unable to get actor for ' + personUrl) return None, None, None, None, None, None, None boxJson = None if not personJson.get(boxName): @@ -1827,17 +1825,6 @@ def createReportPost(baseDir: str, if not postJsonObject: continue - # update the inbox index with the report filename - # indexFilename = baseDir+'/accounts/'+handle+'/inbox.index' - # indexEntry = \ - # removeIdEnding(postJsonObject['id']).replace('/','#') + '.json' - # if indexEntry not in open(indexFilename).read(): - # try: - # with open(indexFilename, 'a+') as fp: - # fp.write(indexEntry) - # except: - # pass - # save a notification file so that the moderator # knows something new has appeared newReportFile = baseDir + '/accounts/' + handle + '/.newReport' @@ -2056,11 +2043,11 @@ def sendPostViaServer(projectVersion: str, fromDomain, projectVersion, debug) if not wfRequest: if debug: - print('DEBUG: webfinger failed for ' + handle) + print('DEBUG: post webfinger failed for ' + handle) return 1 if not isinstance(wfRequest, dict): - print('WARN: Webfinger for ' + handle + ' did not return a dict. ' + - str(wfRequest)) + print('WARN: post webfinger for ' + handle + + ' did not return a dict. ' + str(wfRequest)) return 1 postToBox = 'outbox' @@ -2078,11 +2065,12 @@ def sendPostViaServer(projectVersion: str, 82796) if not inboxUrl: if debug: - print('DEBUG: No ' + postToBox + ' was found for ' + handle) + print('DEBUG: post no ' + postToBox + + ' was found for ' + handle) return 3 if not fromPersonId: if debug: - print('DEBUG: No actor was found for ' + handle) + print('DEBUG: post no actor was found for ' + handle) return 4 # Get the json for the c2s post, not saving anything to file @@ -2131,7 +2119,7 @@ def sendPostViaServer(projectVersion: str, inboxUrl, headers) if not postResult: if debug: - print('DEBUG: Failed to upload image') + print('DEBUG: post failed to upload image') # return 9 headers = { @@ -2142,7 +2130,7 @@ def sendPostViaServer(projectVersion: str, postDumps = json.dumps(postJsonObject) postResult = \ postJsonString(session, postDumps, [], - inboxUrl, headers, debug, 60, True) + inboxUrl, headers, debug, 5, True) if not postResult: if debug: print('DEBUG: POST failed for c2s to ' + inboxUrl) @@ -2364,7 +2352,7 @@ def sendSignedJson(postJsonObject: {}, session, baseDir: str, def addToField(activityType: str, postJsonObject: {}, debug: bool) -> ({}, bool): - """The Follow activity doesn't have a 'to' field and so one + """The Follow/Add/Remove activity doesn't have a 'to' field and so one needs to be added so that activity distribution happens in a consistent way Returns true if a 'to' field exists or was added """ @@ -2383,19 +2371,34 @@ def addToField(activityType: str, postJsonObject: {}, if postJsonObject['type'] == activityType: isSameType = True if debug: - print('DEBUG: "to" field assigned to Follow') + print('DEBUG: "to" field assigned to ' + activityType) toAddress = postJsonObject['object'] if '/statuses/' in toAddress: toAddress = toAddress.split('/statuses/')[0] postJsonObject['to'] = [toAddress] toFieldAdded = True elif isinstance(postJsonObject['object'], dict): - if postJsonObject['object'].get('type'): + # add a to field to bookmark add or remove + if postJsonObject.get('type') and \ + postJsonObject.get('actor') and \ + postJsonObject['object'].get('type'): + if postJsonObject['type'] == 'Add' or \ + postJsonObject['type'] == 'Remove': + if postJsonObject['object']['type'] == 'Document': + postJsonObject['to'] = \ + [postJsonObject['actor']] + postJsonObject['object']['to'] = \ + [postJsonObject['actor']] + toFieldAdded = True + + if not toFieldAdded and \ + postJsonObject['object'].get('type'): if postJsonObject['object']['type'] == activityType: isSameType = True if isinstance(postJsonObject['object']['object'], str): if debug: - print('DEBUG: "to" field assigned to Follow') + print('DEBUG: "to" field assigned to ' + + activityType) toAddress = postJsonObject['object']['object'] if '/statuses/' in toAddress: toAddress = toAddress.split('/statuses/')[0] @@ -2426,8 +2429,8 @@ def sendToNamedAddresses(session, baseDir: str, return if not postJsonObject.get('object'): return + isProfileUpdate = False if isinstance(postJsonObject['object'], dict): - isProfileUpdate = False # for actor updates there is no 'to' within the object if postJsonObject['object'].get('type') and postJsonObject.get('type'): if (postJsonObject['type'] == 'Update' and @@ -2510,6 +2513,16 @@ def sendToNamedAddresses(session, baseDir: str, toDomain, toPort = getDomainFromActor(address) if not toDomain: continue + # Don't send profile/actor updates to yourself + if isProfileUpdate: + domainFull = getFullDomain(domain, port) + toDomainFull = getFullDomain(toDomain, toPort) + if nickname == toNickname and \ + domainFull == toDomainFull: + if debug: + print('Not sending profile update to self. ' + + nickname + '@' + domainFull) + continue if debug: domainFull = getFullDomain(domain, port) toDomainFull = getFullDomain(toDomain, toPort) @@ -3245,7 +3258,7 @@ def _createBoxIndexed(recentPostsCache: {}, # created by individualPostAsHtml p['hasReplies'] = hasReplies - # Don't show likes, replies, DMs or shares (announces) to + # Don't show likes, replies, bookmarks, DMs or shares (announces) to # unauthorized viewers if not authorized: if p.get('object'): @@ -3260,6 +3273,8 @@ def _createBoxIndexed(recentPostsCache: {}, p['shares'] = {} if p['object'].get('bookmarks'): p['bookmarks'] = {} + if p['object'].get('ignores'): + p['ignores'] = {} boxItems['orderedItems'].append(p) @@ -4039,87 +4054,6 @@ def isMuted(baseDir: str, nickname: str, domain: str, postId: str) -> bool: return False -def mutePost(baseDir: str, nickname: str, domain: str, postId: str, - recentPostsCache: {}) -> None: - """ Mutes the given post - """ - postFilename = locatePost(baseDir, nickname, domain, postId) - if not postFilename: - return - postJsonObject = loadJson(postFilename) - if not postJsonObject: - return - - # remove cached post so that the muted version gets recreated - # without its content text and/or image - cachedPostFilename = \ - getCachedPostFilename(baseDir, nickname, domain, postJsonObject) - if cachedPostFilename: - if os.path.isfile(cachedPostFilename): - os.remove(cachedPostFilename) - - muteFile = open(postFilename + '.muted', 'w+') - if muteFile: - muteFile.write('\n') - muteFile.close() - print('MUTE: ' + postFilename + '.muted file added') - - # if the post is in the recent posts cache then mark it as muted - if recentPostsCache.get('index'): - postId = \ - removeIdEnding(postJsonObject['id']).replace('/', '#') - if postId in recentPostsCache['index']: - print('MUTE: ' + postId + ' is in recent posts cache') - if recentPostsCache['json'].get(postId): - postJsonObject['muted'] = True - recentPostsCache['json'][postId] = json.dumps(postJsonObject) - if recentPostsCache.get('html'): - if recentPostsCache['html'].get(postId): - del recentPostsCache['html'][postId] - print('MUTE: ' + postId + - ' marked as muted in recent posts memory cache') - - -def unmutePost(baseDir: str, nickname: str, domain: str, postId: str, - recentPostsCache: {}) -> None: - """ Unmutes the given post - """ - postFilename = locatePost(baseDir, nickname, domain, postId) - if not postFilename: - return - postJsonObject = loadJson(postFilename) - if not postJsonObject: - return - - muteFilename = postFilename + '.muted' - if os.path.isfile(muteFilename): - os.remove(muteFilename) - print('UNMUTE: ' + muteFilename + ' file removed') - - # remove cached post so that the muted version gets recreated - # with its content text and/or image - cachedPostFilename = \ - getCachedPostFilename(baseDir, nickname, domain, postJsonObject) - if cachedPostFilename: - if os.path.isfile(cachedPostFilename): - os.remove(cachedPostFilename) - - # if the post is in the recent posts cache then mark it as unmuted - if recentPostsCache.get('index'): - postId = \ - removeIdEnding(postJsonObject['id']).replace('/', '#') - if postId in recentPostsCache['index']: - print('UNMUTE: ' + postId + ' is in recent posts cache') - if recentPostsCache['json'].get(postId): - postJsonObject['muted'] = False - recentPostsCache['json'][postId] = json.dumps(postJsonObject) - if recentPostsCache.get('html'): - if recentPostsCache['html'].get(postId): - del recentPostsCache['html'][postId] - print('UNMUTE: ' + postId + - ' marked as unmuted in recent posts cache') - - def sendBlockViaServer(baseDir: str, session, fromNickname: str, password: str, fromDomain: str, fromPort: int, @@ -4156,11 +4090,11 @@ def sendBlockViaServer(baseDir: str, session, fromDomain, projectVersion, debug) if not wfRequest: if debug: - print('DEBUG: announce webfinger failed for ' + handle) + print('DEBUG: block webfinger failed for ' + handle) return 1 if not isinstance(wfRequest, dict): - print('WARN: Webfinger for ' + handle + ' did not return a dict. ' + - str(wfRequest)) + print('WARN: block Webfinger for ' + handle + + ' did not return a dict. ' + str(wfRequest)) return 1 postToBox = 'outbox' @@ -4175,11 +4109,11 @@ def sendBlockViaServer(baseDir: str, session, if not inboxUrl: if debug: - print('DEBUG: No ' + postToBox + ' was found for ' + handle) + print('DEBUG: block no ' + postToBox + ' was found for ' + handle) return 3 if not fromPersonId: if debug: - print('DEBUG: No actor was found for ' + handle) + print('DEBUG: block no actor was found for ' + handle) return 4 authHeader = createBasicAuthHeader(fromNickname, password) @@ -4192,7 +4126,7 @@ def sendBlockViaServer(baseDir: str, session, postResult = postJson(session, newBlockJson, [], inboxUrl, headers, 30, True) if not postResult: - print('WARN: Unable to post block') + print('WARN: block unable to post') if debug: print('DEBUG: c2s POST block success') @@ -4200,6 +4134,162 @@ def sendBlockViaServer(baseDir: str, session, return newBlockJson +def sendMuteViaServer(baseDir: str, session, + fromNickname: str, password: str, + fromDomain: str, fromPort: int, + httpPrefix: str, mutedUrl: str, + cachedWebfingers: {}, personCache: {}, + debug: bool, projectVersion: str) -> {}: + """Creates a mute via c2s + """ + if not session: + print('WARN: No session for sendMuteViaServer') + return 6 + + fromDomainFull = getFullDomain(fromDomain, fromPort) + + actor = httpPrefix + '://' + fromDomainFull + '/users/' + fromNickname + handle = actor.replace('/users/', '/@') + + newMuteJson = { + "@context": "https://www.w3.org/ns/activitystreams", + 'type': 'Ignore', + 'actor': actor, + 'to': [actor], + 'object': mutedUrl + } + + # lookup the inbox for the To handle + wfRequest = webfingerHandle(session, handle, httpPrefix, + cachedWebfingers, + fromDomain, projectVersion, debug) + if not wfRequest: + if debug: + print('DEBUG: mute webfinger failed for ' + handle) + return 1 + if not isinstance(wfRequest, dict): + print('WARN: mute Webfinger for ' + handle + + ' did not return a dict. ' + str(wfRequest)) + return 1 + + postToBox = 'outbox' + + # get the actor inbox for the To handle + (inboxUrl, pubKeyId, pubKey, + fromPersonId, sharedInbox, avatarUrl, + displayName) = getPersonBox(baseDir, session, wfRequest, + personCache, + projectVersion, httpPrefix, fromNickname, + fromDomain, postToBox, 72652) + + if not inboxUrl: + if debug: + print('DEBUG: mute no ' + postToBox + ' was found for ' + handle) + return 3 + if not fromPersonId: + if debug: + print('DEBUG: mute no actor was found for ' + handle) + return 4 + + authHeader = createBasicAuthHeader(fromNickname, password) + + headers = { + 'host': fromDomain, + 'Content-type': 'application/json', + 'Authorization': authHeader + } + postResult = postJson(session, newMuteJson, [], inboxUrl, + headers, 3, True) + if postResult is None: + print('WARN: mute unable to post') + + if debug: + print('DEBUG: c2s POST mute success') + + return newMuteJson + + +def sendUndoMuteViaServer(baseDir: str, session, + fromNickname: str, password: str, + fromDomain: str, fromPort: int, + httpPrefix: str, mutedUrl: str, + cachedWebfingers: {}, personCache: {}, + debug: bool, projectVersion: str) -> {}: + """Undoes a mute via c2s + """ + if not session: + print('WARN: No session for sendUndoMuteViaServer') + return 6 + + fromDomainFull = getFullDomain(fromDomain, fromPort) + + actor = httpPrefix + '://' + fromDomainFull + '/users/' + fromNickname + handle = actor.replace('/users/', '/@') + + undoMuteJson = { + "@context": "https://www.w3.org/ns/activitystreams", + 'type': 'Undo', + 'actor': actor, + 'to': [actor], + 'object': { + 'type': 'Ignore', + 'actor': actor, + 'to': [actor], + 'object': mutedUrl + } + } + + # lookup the inbox for the To handle + wfRequest = webfingerHandle(session, handle, httpPrefix, + cachedWebfingers, + fromDomain, projectVersion, debug) + if not wfRequest: + if debug: + print('DEBUG: undo mute webfinger failed for ' + handle) + return 1 + if not isinstance(wfRequest, dict): + print('WARN: undo mute Webfinger for ' + handle + + ' did not return a dict. ' + str(wfRequest)) + return 1 + + postToBox = 'outbox' + + # get the actor inbox for the To handle + (inboxUrl, pubKeyId, pubKey, + fromPersonId, sharedInbox, avatarUrl, + displayName) = getPersonBox(baseDir, session, wfRequest, + personCache, + projectVersion, httpPrefix, fromNickname, + fromDomain, postToBox, 72652) + + if not inboxUrl: + if debug: + print('DEBUG: undo mute no ' + postToBox + + ' was found for ' + handle) + return 3 + if not fromPersonId: + if debug: + print('DEBUG: undo mute no actor was found for ' + handle) + return 4 + + authHeader = createBasicAuthHeader(fromNickname, password) + + headers = { + 'host': fromDomain, + 'Content-type': 'application/json', + 'Authorization': authHeader + } + postResult = postJson(session, undoMuteJson, [], inboxUrl, + headers, 3, True) + if postResult is None: + print('WARN: undo mute unable to post') + + if debug: + print('DEBUG: c2s POST undo mute success') + + return undoMuteJson + + def sendUndoBlockViaServer(baseDir: str, session, fromNickname: str, password: str, fromDomain: str, fromPort: int, @@ -4240,11 +4330,11 @@ def sendUndoBlockViaServer(baseDir: str, session, fromDomain, projectVersion, debug) if not wfRequest: if debug: - print('DEBUG: announce webfinger failed for ' + handle) + print('DEBUG: unblock webfinger failed for ' + handle) return 1 if not isinstance(wfRequest, dict): - print('WARN: Webfinger for ' + handle + ' did not return a dict. ' + - str(wfRequest)) + print('WARN: unblock webfinger for ' + handle + + ' did not return a dict. ' + str(wfRequest)) return 1 postToBox = 'outbox' @@ -4258,11 +4348,12 @@ def sendUndoBlockViaServer(baseDir: str, session, if not inboxUrl: if debug: - print('DEBUG: No ' + postToBox + ' was found for ' + handle) + print('DEBUG: unblock no ' + postToBox + + ' was found for ' + handle) return 3 if not fromPersonId: if debug: - print('DEBUG: No actor was found for ' + handle) + print('DEBUG: unblock no actor was found for ' + handle) return 4 authHeader = createBasicAuthHeader(fromNickname, password) @@ -4275,10 +4366,10 @@ def sendUndoBlockViaServer(baseDir: str, session, postResult = postJson(session, newBlockJson, [], inboxUrl, headers, 30, True) if not postResult: - print('WARN: Unable to post block') + print('WARN: unblock unable to post') if debug: - print('DEBUG: c2s POST block success') + print('DEBUG: c2s POST unblock success') return newBlockJson @@ -4305,3 +4396,39 @@ def postIsMuted(baseDir: str, nickname: str, domain: str, if os.path.isfile(muteFilename): return True return False + + +def c2sBoxJson(baseDir: str, session, + nickname: str, password: str, + domain: str, port: int, + httpPrefix: str, + boxName: str, pageNumber: int, + debug: bool) -> {}: + """C2S Authenticated GET of posts for a timeline + """ + if not session: + print('WARN: No session for c2sBoxJson') + return None + + domainFull = getFullDomain(domain, port) + actor = httpPrefix + '://' + domainFull + '/users/' + nickname + + authHeader = createBasicAuthHeader(nickname, password) + + profileStr = 'https://www.w3.org/ns/activitystreams' + headers = { + 'host': domain, + 'Content-type': 'application/json', + 'Authorization': authHeader, + 'Accept': 'application/ld+json; profile="' + profileStr + '"' + } + + # GET json + url = actor + '/' + boxName + '?page=' + str(pageNumber) + boxJson = getJson(session, url, headers, None, + debug, __version__, httpPrefix, None) + + if boxJson is not None and debug: + print('DEBUG: GET c2sBoxJson success') + + return boxJson diff --git a/roles.py b/roles.py index 218ff7d3a..f198fe7f0 100644 --- a/roles.py +++ b/roles.py @@ -313,11 +313,11 @@ def sendRoleViaServer(baseDir: str, session, delegatorDomain, projectVersion, debug) if not wfRequest: if debug: - print('DEBUG: announce webfinger failed for ' + handle) + print('DEBUG: role webfinger failed for ' + handle) return 1 if not isinstance(wfRequest, dict): - print('WARN: Webfinger for ' + handle + ' did not return a dict. ' + - str(wfRequest)) + print('WARN: role webfinger for ' + handle + + ' did not return a dict. ' + str(wfRequest)) return 1 postToBox = 'outbox' @@ -334,11 +334,12 @@ def sendRoleViaServer(baseDir: str, session, if not inboxUrl: if debug: - print('DEBUG: No ' + postToBox + ' was found for ' + handle) + print('DEBUG: role no ' + postToBox + + ' was found for ' + handle) return 3 if not fromPersonId: if debug: - print('DEBUG: No actor was found for ' + handle) + print('DEBUG: role no actor was found for ' + handle) return 4 authHeader = createBasicAuthHeader(delegatorNickname, password) @@ -352,7 +353,7 @@ def sendRoleViaServer(baseDir: str, session, postJson(session, newRoleJson, [], inboxUrl, headers, 30, True) if not postResult: if debug: - print('DEBUG: POST announce failed for c2s to ' + inboxUrl) + print('DEBUG: POST role failed for c2s to ' + inboxUrl) # return 5 if debug: diff --git a/session.py b/session.py index f4d6e6dd7..16f7d0fe5 100644 --- a/session.py +++ b/session.py @@ -152,6 +152,12 @@ def postJson(session, postJsonObject: {}, federationList: [], session.post(url=inboxUrl, data=json.dumps(postJsonObject), headers=headers, timeout=timeoutSec) + except requests.Timeout as e: + if not quiet: + print('ERROR: postJson timeout ' + inboxUrl + ' ' + + json.dumps(postJsonObject) + ' ' + str(headers)) + print(e) + return '' except requests.exceptions.RequestException as e: if not quiet: print('ERROR: postJson requests failed ' + inboxUrl + ' ' + diff --git a/shares.py b/shares.py index 239f79a70..ecf59dfc1 100644 --- a/shares.py +++ b/shares.py @@ -361,11 +361,11 @@ def sendShareViaServer(baseDir, session, fromDomain, projectVersion, debug) if not wfRequest: if debug: - print('DEBUG: announce webfinger failed for ' + handle) + print('DEBUG: share webfinger failed for ' + handle) return 1 if not isinstance(wfRequest, dict): - print('WARN: Webfinger for ' + handle + ' did not return a dict. ' + - str(wfRequest)) + print('WARN: share webfinger for ' + handle + + ' did not return a dict. ' + str(wfRequest)) return 1 postToBox = 'outbox' @@ -381,11 +381,12 @@ def sendShareViaServer(baseDir, session, if not inboxUrl: if debug: - print('DEBUG: No ' + postToBox + ' was found for ' + handle) + print('DEBUG: share no ' + postToBox + + ' was found for ' + handle) return 3 if not fromPersonId: if debug: - print('DEBUG: No actor was found for ' + handle) + print('DEBUG: share no actor was found for ' + handle) return 4 authHeader = createBasicAuthHeader(fromNickname, password) @@ -409,7 +410,7 @@ def sendShareViaServer(baseDir, session, postJson(session, newShareJson, [], inboxUrl, headers, 30, True) if not postResult: if debug: - print('DEBUG: POST announce failed for c2s to ' + inboxUrl) + print('DEBUG: POST share failed for c2s to ' + inboxUrl) # return 5 if debug: @@ -460,11 +461,11 @@ def sendUndoShareViaServer(baseDir: str, session, fromDomain, projectVersion, debug) if not wfRequest: if debug: - print('DEBUG: announce webfinger failed for ' + handle) + print('DEBUG: unshare webfinger failed for ' + handle) return 1 if not isinstance(wfRequest, dict): - print('WARN: Webfinger for ' + handle + ' did not return a dict. ' + - str(wfRequest)) + print('WARN: unshare webfinger for ' + handle + + ' did not return a dict. ' + str(wfRequest)) return 1 postToBox = 'outbox' @@ -480,11 +481,12 @@ def sendUndoShareViaServer(baseDir: str, session, if not inboxUrl: if debug: - print('DEBUG: No '+postToBox+' was found for ' + handle) + print('DEBUG: unshare no ' + postToBox + + ' was found for ' + handle) return 3 if not fromPersonId: if debug: - print('DEBUG: No actor was found for ' + handle) + print('DEBUG: unshare no actor was found for ' + handle) return 4 authHeader = createBasicAuthHeader(fromNickname, password) @@ -499,11 +501,11 @@ def sendUndoShareViaServer(baseDir: str, session, headers, 30, True) if not postResult: if debug: - print('DEBUG: POST announce failed for c2s to ' + inboxUrl) + print('DEBUG: POST unshare failed for c2s to ' + inboxUrl) # return 5 if debug: - print('DEBUG: c2s POST undo share success') + print('DEBUG: c2s POST unshare success') return undoShareJson diff --git a/skills.py b/skills.py index 267c43f37..0609bfd5a 100644 --- a/skills.py +++ b/skills.py @@ -126,11 +126,11 @@ def sendSkillViaServer(baseDir: str, session, nickname: str, password: str, domain, projectVersion, debug) if not wfRequest: if debug: - print('DEBUG: announce webfinger failed for ' + handle) + print('DEBUG: skill webfinger failed for ' + handle) return 1 if not isinstance(wfRequest, dict): - print('WARN: Webfinger for ' + handle + ' did not return a dict. ' + - str(wfRequest)) + print('WARN: skill webfinger for ' + handle + + ' did not return a dict. ' + str(wfRequest)) return 1 postToBox = 'outbox' @@ -145,11 +145,12 @@ def sendSkillViaServer(baseDir: str, session, nickname: str, password: str, if not inboxUrl: if debug: - print('DEBUG: No ' + postToBox + ' was found for ' + handle) + print('DEBUG: skill no ' + postToBox + + ' was found for ' + handle) return 3 if not fromPersonId: if debug: - print('DEBUG: No actor was found for ' + handle) + print('DEBUG: skill no actor was found for ' + handle) return 4 authHeader = createBasicAuthHeader(nickname, password) @@ -164,7 +165,7 @@ def sendSkillViaServer(baseDir: str, session, nickname: str, password: str, headers, 30, True) if not postResult: if debug: - print('DEBUG: POST announce failed for c2s to ' + inboxUrl) + print('DEBUG: POST skill failed for c2s to ' + inboxUrl) # return 5 if debug: diff --git a/speaker.py b/speaker.py index 2ec228b95..d6d66fbca 100644 --- a/speaker.py +++ b/speaker.py @@ -10,8 +10,6 @@ import os import html import random import urllib.parse -from auth import createBasicAuthHeader -from session import getJson from utils import isDM from utils import isReply from utils import camelCaseSplit @@ -22,7 +20,6 @@ from utils import getDisplayName from utils import removeHtml from utils import loadJson from utils import saveJson -from utils import getFullDomain from utils import isPGPEncrypted from content import htmlReplaceQuoteMarks @@ -168,8 +165,10 @@ def speakerReplaceLinks(sayText: str, translate: {}, Instead of reading out potentially very long and meaningless links """ text = sayText + text = text.replace('?v=', '__v=') for ch in speakerRemoveChars: text = text.replace(ch, ' ') + text = text.replace('__v=', '?v=') replacements = {} wordsList = text.split(' ') if translate.get('Linked'): @@ -253,38 +252,6 @@ def _removeEmojiFromText(sayText: str) -> str: return sayText.replace(' ', ' ').strip() -def getSpeakerFromServer(baseDir: str, session, - nickname: str, password: str, - domain: str, port: int, - httpPrefix: str, - debug: bool, projectVersion: str) -> {}: - """Returns some json which contains the latest inbox - entry in a minimal format suitable for a text-to-speech reader - """ - if not session: - print('WARN: No session for getSpeakerFromServer') - return 6 - - domainFull = getFullDomain(domain, port) - - authHeader = createBasicAuthHeader(nickname, password) - - headers = { - 'host': domain, - 'Content-type': 'application/json', - 'Authorization': authHeader - } - - url = \ - httpPrefix + '://' + \ - domainFull + '/users/' + nickname + '/speaker' - - speakerJson = \ - getJson(session, url, headers, None, debug, - __version__, httpPrefix, domain, 20, True) - return speakerJson - - def _speakerEndpointJson(displayName: str, summary: str, content: str, sayContent: str, imageDescription: str, @@ -405,6 +372,30 @@ def getSSMLbox(baseDir: str, path: str, instanceTitle, gender) +def speakableText(baseDir: str, content: str, translate: {}) -> (str, []): + """Convert the given text to a speakable version + which includes changes for prononciation + """ + if isPGPEncrypted(content): + return content, [] + + # replace some emoji before removing html + if ' <3' in content: + content = content.replace(' <3', ' ' + translate['heart']) + content = removeHtml(htmlReplaceQuoteMarks(content)) + detectedLinks = [] + content = speakerReplaceLinks(content, translate, detectedLinks) + # replace all double spaces + while ' ' in content: + content = content.replace(' ', ' ') + content = content.replace(' . ', '. ').strip() + sayContent = _speakerPronounce(baseDir, content, translate) + # replace all double spaces + while ' ' in sayContent: + sayContent = sayContent.replace(' ', ' ') + return sayContent.replace(' . ', '. ').strip(), detectedLinks + + def _postToSpeakerJson(baseDir: str, httpPrefix: str, nickname: str, domain: str, domainFull: str, postJsonObject: {}, personCache: {}, diff --git a/tests.py b/tests.py index a3ba76b63..f66d6bb6f 100644 --- a/tests.py +++ b/tests.py @@ -53,6 +53,7 @@ from utils import getFollowersOfPerson from utils import removeHtml from utils import dangerousMarkup from pgp import extractPGPPublicKey +from pgp import pgpPublicKeyUpload from utils import containsPGPPublicKey from follow import followerOfPerson from follow import unfollowAccount @@ -1574,7 +1575,7 @@ def testClientToServer(): sessionAlice = createSession(proxyType) followersOnly = False - attachedImageFilename = baseDir+'/img/logo.png' + attachedImageFilename = baseDir + '/img/logo.png' mediaType = getAttachmentMediaType(attachedImageFilename) attachedImageDescription = 'Logo' isArticle = False @@ -1910,6 +1911,22 @@ def testActorParsing(): def testWebLinks(): print('testWebLinks') + exampleText = \ + "

Aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + \ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + \ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + \ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + \ + " #turbot #haddock

" + resultText = removeLongWords(exampleText, 40, []) + assert resultText == "

Aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + \ + " #turbot " + \ + "#haddock

" + exampleText = \ '

@foo Some ' + \ @@ -2701,6 +2718,8 @@ def testFirstParagraphFromString(): '

This is a test

' + \ '

This is another paragraph

' resultStr = firstParagraphFromString(testStr) + if resultStr != 'This is a test': + print(resultStr) assert resultStr == 'This is a test' testStr = 'Testing without html' @@ -3447,6 +3466,122 @@ def testExtractPGPPublicKey(): assert result == pubKey +def testUpdateActor(): + print('Testing update of actor properties') + + global testServerAliceRunning + testServerAliceRunning = False + + httpPrefix = 'http' + proxyType = None + federationList = [] + + baseDir = os.getcwd() + if os.path.isdir(baseDir + '/.tests'): + shutil.rmtree(baseDir + '/.tests') + os.mkdir(baseDir + '/.tests') + + # create the server + aliceDir = baseDir + '/.tests/alice' + aliceDomain = '127.0.0.11' + alicePort = 61792 + aliceSendThreads = [] + bobAddress = '127.0.0.84:6384' + + global thrAlice + if thrAlice: + while thrAlice.is_alive(): + thrAlice.stop() + time.sleep(1) + thrAlice.kill() + + thrAlice = \ + threadWithTrace(target=createServerAlice, + args=(aliceDir, aliceDomain, alicePort, bobAddress, + federationList, False, False, + aliceSendThreads), + daemon=True) + + thrAlice.start() + assert thrAlice.is_alive() is True + + # wait for server to be running + ctr = 0 + while not testServerAliceRunning: + time.sleep(1) + ctr += 1 + if ctr > 60: + break + print('Alice online: ' + str(testServerAliceRunning)) + + print('\n\n*******************************************************') + print('Alice updates her PGP key') + + sessionAlice = createSession(proxyType) + cachedWebfingers = {} + personCache = {} + password = 'alicepass' + outboxPath = aliceDir + '/accounts/alice@' + aliceDomain + '/outbox' + actorFilename = aliceDir + '/accounts/' + 'alice@' + aliceDomain + '.json' + assert os.path.isfile(actorFilename) + assert len([name for name in os.listdir(outboxPath) + if os.path.isfile(os.path.join(outboxPath, name))]) == 0 + pubKey = \ + '-----BEGIN PGP PUBLIC KEY BLOCK-----\n\n' + \ + 'mDMEWZBueBYJKwYBBAHaRw8BAQdAKx1t6wL0RTuU6/' + \ + 'IBjngMbVJJ3Wg/3UW73/PV\n' + \ + 'I47xKTS0IUJvYiBNb3R0cmFtIDxib2JAZnJlZWRvb' + \ + 'WJvbmUubmV0PoiQBBMWCAA4\n' + \ + 'FiEEmruCwAq/OfgmgEh9zCU2GR+nwz8FAlmQbngCG' + \ + 'wMFCwkIBwMFFQoJCAsFFgID\n' + \ + 'AQACHgECF4AACgkQzCU2GR+nwz/9sAD/YgsHnVszH' + \ + 'Nz1zlVc5EgY1ByDupiJpHj0\n' + \ + 'XsLYk3AbNRgBALn45RqgD4eWHpmOriH09H5Rc5V9i' + \ + 'N4+OiGUn2AzJ6oHuDgEWZBu\n' + \ + 'eBIKKwYBBAGXVQEFAQEHQPRBG2ZQJce475S3e0Dxe' + \ + 'b0Fz5WdEu2q3GYLo4QG+4Ry\n' + \ + 'AwEIB4h4BBgWCAAgFiEEmruCwAq/OfgmgEh9zCU2G' + \ + 'R+nwz8FAlmQbngCGwwACgkQ\n' + \ + 'zCU2GR+nwz+OswD+JOoyBku9FzuWoVoOevU2HH+bP' + \ + 'OMDgY2OLnST9ZSyHkMBAMcK\n' + \ + 'fnaZ2Wi050483Sj2RmQRpb99Dod7rVZTDtCqXk0J\n' + \ + '=gv5G\n' + \ + '-----END PGP PUBLIC KEY BLOCK-----' + actorUpdate = \ + pgpPublicKeyUpload(aliceDir, sessionAlice, + 'alice', password, + aliceDomain, alicePort, + httpPrefix, + cachedWebfingers, personCache, + True, pubKey) + print('actor update result: ' + str(actorUpdate)) + assert actorUpdate + + # load alice actor + print('Loading actor: ' + actorFilename) + actorJson = loadJson(actorFilename) + assert actorJson + if len(actorJson['attachment']) == 0: + print("actorJson['attachment'] has no contents") + assert len(actorJson['attachment']) > 0 + propertyFound = False + for propertyValue in actorJson['attachment']: + if propertyValue['name'] == 'PGP': + print('PGP property set within attachment') + assert pubKey in propertyValue['value'] + propertyFound = True + assert propertyFound + + # stop the server + thrAlice.kill() + thrAlice.join() + assert thrAlice.is_alive() is False + + os.chdir(baseDir) + if os.path.isdir(baseDir + '/.tests'): + shutil.rmtree(baseDir + '/.tests') + + def runAllTests(): print('Running tests...') testFunctions() diff --git a/utils.py b/utils.py index 35d42a4e8..ee51074fb 100644 --- a/utils.py +++ b/utils.py @@ -13,6 +13,7 @@ import shutil import datetime import json import idna +import locale from pprint import pprint from calendar import monthrange from followingCalendar import addPersonToCalendar @@ -252,6 +253,7 @@ def removeHtml(content: str) -> str: if '<' not in content: return content removing = False + content = content.replace('', '"').replace('', '"') result = '' for ch in content: @@ -261,6 +263,7 @@ def removeHtml(content: str) -> str: removing = False elif not removing: result += ch + result = result.replace(' ', ' ').strip() return result @@ -1357,7 +1360,9 @@ def _isReservedName(nickname: str) -> bool: 'accounts', 'channels', 'profile', 'u', 'updates', 'repeat', 'announce', 'shares', 'fonts', 'icons', 'avatars', - 'welcome', 'helpimages') + 'welcome', 'helpimages', + 'bookmark', 'bookmarks', 'tlbookmarks', + 'ignores') if nickname in reservedNames: return True return False @@ -2150,3 +2155,30 @@ def isPGPEncrypted(content: str) -> bool: if '--END PGP MESSAGE--' in content: return True return False + + +def loadTranslationsFromFile(baseDir: str, language: str) -> ({}, str): + """Returns the translations dictionary + """ + if not os.path.isdir(baseDir + '/translations'): + print('ERROR: translations directory not found') + return + if not language: + systemLanguage = locale.getdefaultlocale()[0] + else: + systemLanguage = language + if not systemLanguage: + systemLanguage = 'en' + if '_' in systemLanguage: + systemLanguage = systemLanguage.split('_')[0] + while '/' in systemLanguage: + systemLanguage = systemLanguage.split('/')[1] + if '.' in systemLanguage: + systemLanguage = systemLanguage.split('.')[0] + translationsFile = baseDir + '/translations/' + \ + systemLanguage + '.json' + if not os.path.isfile(translationsFile): + systemLanguage = 'en' + translationsFile = baseDir + '/translations/' + \ + systemLanguage + '.json' + return loadJson(translationsFile), systemLanguage