__filename__ = "conversation.py" __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.5.0" __maintainer__ = "Bob Mottram" __email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Timeline" import os from utils import has_object_dict from utils import acct_dir from utils import remove_id_ending from utils import text_in_file from utils import locate_post from utils import load_json from utils import harmless_markup from utils import get_attributed_to from utils import get_reply_to from utils import resembles_url from keys import get_instance_actor_key from session import get_json from session import get_json_valid def _get_conversation_filename(base_dir: str, nickname: str, domain: str, post_json_object: {}) -> str: """Returns the conversation filename Due to lack of AP specification maintenance, a conversation can also be referred to as a thread or (confusingly) "context" """ if not has_object_dict(post_json_object): return None if not post_json_object['object'].get('conversation') and \ not post_json_object['object'].get('thread') and \ not post_json_object['object'].get('context'): return None if not post_json_object['object'].get('id'): return None conversation_dir = acct_dir(base_dir, nickname, domain) + '/conversation' if not os.path.isdir(conversation_dir): os.mkdir(conversation_dir) if post_json_object['object'].get('conversation'): conversation_id = post_json_object['object']['conversation'] elif post_json_object['object'].get('context'): conversation_id = post_json_object['object']['context'] else: conversation_id = post_json_object['object']['thread'] if not isinstance(conversation_id, str): return None conversation_id = conversation_id.replace('/', '#') return conversation_dir + '/' + conversation_id def update_conversation(base_dir: str, nickname: str, domain: str, post_json_object: {}) -> bool: """Adds a post to a conversation index in the /conversation subdirectory """ conversation_filename = \ _get_conversation_filename(base_dir, nickname, domain, post_json_object) if not conversation_filename: return False post_id = remove_id_ending(post_json_object['object']['id']) if not os.path.isfile(conversation_filename): try: with open(conversation_filename, 'w+', encoding='utf-8') as fp_conv: fp_conv.write(post_id + '\n') return True except OSError: print('EX: update_conversation ' + 'unable to write to ' + conversation_filename) elif not text_in_file(post_id + '\n', conversation_filename): try: with open(conversation_filename, 'a+', encoding='utf-8') as fp_conv: fp_conv.write(post_id + '\n') return True except OSError: print('EX: update_conversation 2 ' + 'unable to write to ' + conversation_filename) return False def mute_conversation(base_dir: str, nickname: str, domain: str, conversation_id: str) -> None: """Mutes the given conversation """ if not isinstance(conversation_id, str): return conversation_dir = acct_dir(base_dir, nickname, domain) + '/conversation' conversation_filename = \ conversation_dir + '/' + conversation_id.replace('/', '#') if not os.path.isfile(conversation_filename): return if os.path.isfile(conversation_filename + '.muted'): return try: with open(conversation_filename + '.muted', 'w+', encoding='utf-8') as fp_conv: fp_conv.write('\n') except OSError: print('EX: unable to write mute ' + conversation_filename) def unmute_conversation(base_dir: str, nickname: str, domain: str, conversation_id: str) -> None: """Unmutes the given conversation """ if not isinstance(conversation_id, str): return conversation_dir = acct_dir(base_dir, nickname, domain) + '/conversation' conversation_filename = \ conversation_dir + '/' + conversation_id.replace('/', '#') if not os.path.isfile(conversation_filename): return if not os.path.isfile(conversation_filename + '.muted'): return try: os.remove(conversation_filename + '.muted') except OSError: print('EX: unmute_conversation unable to delete ' + conversation_filename + '.muted') def _get_replies_to_post(post_json_object: {}, signing_priv_key_pem: str, session, as_header, debug: bool, http_prefix: str, base_dir: str, nickname: str, domain: str, depth: int, ids: []) -> []: """Returns a list of reply posts to the given post as json """ result = [] post_obj = post_json_object if has_object_dict(post_json_object): post_obj = post_json_object['object'] if not post_obj.get('replies'): return result # get the replies collection url replies_collection_id = None if isinstance(post_obj['replies'], dict): if post_obj['replies'].get('id'): replies_collection_id = post_obj['replies']['id'] elif isinstance(post_obj['replies'], str): replies_collection_id = post_obj['replies'] if replies_collection_id: if debug: print('DEBUG: get_replies_to_post replies_collection_id ' + str(replies_collection_id)) replies_collection = \ get_json(signing_priv_key_pem, session, replies_collection_id, as_header, None, debug, __version__, http_prefix, domain) if not get_json_valid(replies_collection): return result if debug: print('DEBUG: get_replies_to_post replies_collection ' + str(replies_collection)) # get the list of replies if not replies_collection.get('first'): return result if not isinstance(replies_collection['first'], dict): return result if not replies_collection['first'].get('items'): if not replies_collection['first'].get('next'): return result items_list = [] if replies_collection['first'].get('items'): items_list = replies_collection['first']['items'] if not items_list: # if there are no items try the next one next_page_id = replies_collection['first']['next'] if not isinstance(next_page_id, str): return result replies_collection = \ get_json(signing_priv_key_pem, session, next_page_id, as_header, None, debug, __version__, http_prefix, domain) if debug: print('DEBUG: get_replies_to_post next replies_collection ' + str(replies_collection)) if not get_json_valid(replies_collection): return result if not replies_collection.get('items'): return result if not isinstance(replies_collection['items'], list): return result items_list = replies_collection['items'] if debug: print('DEBUG: get_replies_to_post items_list ' + str(items_list)) if not isinstance(items_list, list): return result # check each item in the list for item in items_list: # download the item if needed if isinstance(item, str): if resembles_url(item): if debug: print('Downloading conversation item ' + item) item_dict = \ get_json(signing_priv_key_pem, session, item, as_header, None, debug, __version__, http_prefix, domain) if not get_json_valid(item_dict): continue item = item_dict if not isinstance(item, dict): continue if not has_object_dict(item): if not item.get('attributedTo'): continue attrib_str = get_attributed_to(item['attributedTo']) if not attrib_str: continue if not item.get('published'): continue if not item.get('id'): continue if not isinstance(item['id'], str): continue if not item.get('to'): continue if not isinstance(item['to'], list): continue if 'cc' not in item: continue if not isinstance(item['cc'], list): continue wrapped_post = { "@context": [ 'https://www.w3.org/ns/activitystreams', 'https://w3id.org/security/v1' ], 'id': item['id'] + '/activity', 'type': 'Create', 'actor': attrib_str, 'published': item['published'], 'to': item['to'], 'cc': item['cc'], 'object': item } item = wrapped_post if not item['object'].get('published'): continue # render harmless any dangerous markup harmless_markup(item) # keep a list of ids encountered, to avoid circularity reply_post_id = None if item.get('id'): if isinstance(item['id'], str): reply_post_id = item['id'] if reply_post_id in ids: continue ids.append(reply_post_id) # add it to the list result.append(item) update_conversation(base_dir, nickname, domain, item) if depth < 10 and reply_post_id: result += \ _get_replies_to_post(item, signing_priv_key_pem, session, as_header, debug, http_prefix, base_dir, nickname, domain, depth + 1, ids) return result def download_conversation_posts(authorized: bool, session, http_prefix: str, base_dir: str, nickname: str, domain: str, post_id: str, debug: bool) -> []: """Downloads all posts for a conversation and returns a list of the json objects """ if '://' not in post_id: return [] profile_str = 'https://www.w3.org/ns/activitystreams' as_header = { 'Accept': 'application/ld+json; profile="' + profile_str + '"' } conversation_view = [] signing_priv_key_pem = get_instance_actor_key(base_dir, domain) post_id = remove_id_ending(post_id) post_filename = \ locate_post(base_dir, nickname, domain, post_id) post_json_object = None if authorized: if post_filename: post_json_object = load_json(post_filename) else: post_json_object = \ get_json(signing_priv_key_pem, session, post_id, as_header, None, debug, __version__, http_prefix, domain) if debug: if not get_json_valid(post_json_object): print(post_id + ' returned no json') if post_json_object: update_conversation(base_dir, nickname, domain, post_json_object) # get any replies replies_to_post = [] if get_json_valid(post_json_object): replies_to_post = \ _get_replies_to_post(post_json_object, signing_priv_key_pem, session, as_header, debug, http_prefix, base_dir, nickname, domain, 0, []) ids = [] while get_json_valid(post_json_object): if not isinstance(post_json_object, dict): break if not has_object_dict(post_json_object): if not post_json_object.get('id'): break if not isinstance(post_json_object['id'], str): break if not post_json_object.get('attributedTo'): if debug: print(str(post_json_object)) print(post_json_object['id'] + ' has no attributedTo') break attrib_str = get_attributed_to(post_json_object['attributedTo']) if not attrib_str: break if not post_json_object.get('published'): if debug: print(str(post_json_object)) print(post_json_object['id'] + ' has no published date') break if not post_json_object.get('to'): if debug: print(str(post_json_object)) print(post_json_object['id'] + ' has no "to" list') break if not isinstance(post_json_object['to'], list): break if 'cc' not in post_json_object: if debug: print(str(post_json_object)) print(post_json_object['id'] + ' has no "cc" list') break if not isinstance(post_json_object['cc'], list): break wrapped_post = { "@context": [ 'https://www.w3.org/ns/activitystreams', 'https://w3id.org/security/v1' ], 'id': post_json_object['id'] + '/activity', 'type': 'Create', 'actor': attrib_str, 'published': post_json_object['published'], 'to': post_json_object['to'], 'cc': post_json_object['cc'], 'object': post_json_object } post_json_object = wrapped_post if not post_json_object['object'].get('published'): break # avoid any circularity in previous conversation posts if post_json_object.get('id'): if isinstance(post_json_object['id'], str): if post_json_object['id'] in ids: break ids.append(post_json_object['id']) # render harmless any dangerous markup harmless_markup(post_json_object) conversation_view = [post_json_object] + conversation_view update_conversation(base_dir, nickname, domain, post_json_object) if not authorized: # only show a single post to non-authorized viewers break post_id = get_reply_to(post_json_object['object']) if not post_id: if debug: print(post_id + ' is not a reply') break post_id = remove_id_ending(post_id) post_filename = \ locate_post(base_dir, nickname, domain, post_id) post_json_object = None if post_filename: post_json_object = load_json(post_filename) else: if authorized: post_json_object = \ get_json(signing_priv_key_pem, session, post_id, as_header, None, debug, __version__, http_prefix, domain) if debug: if get_json_valid(post_json_object): print(post_id + ' returned no json') return conversation_view + replies_to_post def conversation_tag_to_convthread_id(tag: str) -> str: """Converts a converation tag, such as tag:domain,2024-09-28:objectId=647832678:objectType=Conversation into a convthread id such as 20240928647832678 """ if not isinstance(tag, str): return '' convthread_id = '' for tag_chr in tag: if tag_chr.isdigit(): convthread_id += tag_chr return convthread_id def convthread_id_to_conversation_tag(domain: str, convthread_id: str) -> str: """Converts a convthread id such as 20240928647832678 into a converation tag, such as tag:domain,2024-09-28:objectId=647832678:objectType=Conversation """ if len(convthread_id) < 10: return '' year = convthread_id[:4] month = convthread_id[4:][:2] day = convthread_id[6:][:2] post_id = convthread_id[8:] conversation_id = \ 'tag:' + domain + ',' + year + '-' + month + '-' + day + \ ':objectId=' + post_id + ':objectType=Conversation' return conversation_id def post_id_to_convthread_id(post_id: str, published: str) -> str: """Converts a post ID into a conversation thread ID """ if '/statuses/' not in post_id or len(published) < 10: return post_id date_prefix = published[:10].replace('-', '') convthread_id = post_id.replace('/statuses/', '/thread/' + date_prefix) return convthread_id