NomadNet/nomadnet/Conversation.py

423 lines
16 KiB
Python

import os
import RNS
import LXMF
import shutil
import nomadnet
from nomadnet.Directory import DirectoryEntry
class Conversation:
cached_conversations = {}
unread_conversations = {}
created_callback = None
aspect_filter = "lxmf.delivery"
@staticmethod
def received_announce(destination_hash, announced_identity, app_data):
app = nomadnet.NomadNetworkApp.get_shared_instance()
if not destination_hash in app.ignored_list:
destination_hash_text = RNS.hexrep(destination_hash, delimit=False)
# Check if the announced destination is in
# our list of conversations
if destination_hash_text in [e[0] for e in Conversation.conversation_list(app)]:
if app.directory.find(destination_hash):
if Conversation.created_callback != None:
Conversation.created_callback()
else:
if Conversation.created_callback != None:
Conversation.created_callback()
# This reformats the new v0.5.0 announce data back to the expected format
# for nomadnets storage and other handling functions.
dn = LXMF.display_name_from_app_data(app_data)
app_data = b""
if dn != None:
app_data = dn.encode("utf-8")
# Add the announce to the directory announce
# stream logger
app.directory.lxmf_announce_received(destination_hash, app_data)
else:
RNS.log("Ignored announce from "+RNS.prettyhexrep(destination_hash), RNS.LOG_DEBUG)
@staticmethod
def query_for_peer(source_hash):
try:
RNS.Transport.request_path(bytes.fromhex(source_hash))
except Exception as e:
RNS.log("Error while querying network for peer identity. The contained exception was: "+str(e), RNS.LOG_ERROR)
@staticmethod
def ingest(lxmessage, app, originator = False, delegate = None):
if originator:
source_hash = lxmessage.destination_hash
else:
source_hash = lxmessage.source_hash
source_hash_path = RNS.hexrep(source_hash, delimit=False)
conversation_path = app.conversationpath + "/" + source_hash_path
if not os.path.isdir(conversation_path):
os.makedirs(conversation_path)
if Conversation.created_callback != None:
Conversation.created_callback()
ingested_path = lxmessage.write_to_directory(conversation_path)
if RNS.hexrep(source_hash, delimit=False) in Conversation.cached_conversations:
conversation = Conversation.cached_conversations[RNS.hexrep(source_hash, delimit=False)]
conversation.scan_storage()
if not source_hash in Conversation.unread_conversations:
Conversation.unread_conversations[source_hash] = True
try:
dirname = RNS.hexrep(source_hash, delimit=False)
open(app.conversationpath + "/" + dirname + "/unread", 'a').close()
except Exception as e:
pass
if Conversation.created_callback != None:
Conversation.created_callback()
return ingested_path
@staticmethod
def conversation_list(app):
conversations = []
for dirname in os.listdir(app.conversationpath):
if len(dirname) == RNS.Identity.TRUNCATED_HASHLENGTH//8*2 and os.path.isdir(app.conversationpath + "/" + dirname):
try:
source_hash_text = dirname
source_hash = bytes.fromhex(dirname)
app_data = RNS.Identity.recall_app_data(source_hash)
display_name = app.directory.display_name(source_hash)
unread = False
if source_hash in Conversation.unread_conversations:
unread = True
elif os.path.isfile(app.conversationpath + "/" + dirname + "/unread"):
Conversation.unread_conversations[source_hash] = True
unread = True
if display_name == None and app_data:
display_name = LXMF.display_name_from_app_data(app_data)
if display_name == None:
sort_name = ""
else:
sort_name = display_name
trust_level = app.directory.trust_level(source_hash, display_name)
entry = (source_hash_text, display_name, trust_level, sort_name, unread)
conversations.append(entry)
except Exception as e:
RNS.log("Error while loading conversation "+str(dirname)+", skipping it. The contained exception was: "+str(e), RNS.LOG_ERROR)
conversations.sort(key=lambda e: (-e[2], e[3], e[0]), reverse=False)
return conversations
@staticmethod
def cache_conversation(conversation):
Conversation.cached_conversations[conversation.source_hash] = conversation
@staticmethod
def delete_conversation(source_hash_path, app):
conversation_path = app.conversationpath + "/" + source_hash_path
try:
if os.path.isdir(conversation_path):
shutil.rmtree(conversation_path)
except Exception as e:
RNS.log("Could not remove conversation at "+str(conversation_path)+". The contained exception was: "+str(e), RNS.LOG_ERROR)
def __init__(self, source_hash, app, initiator=False):
self.app = app
self.source_hash = source_hash
self.send_destination = None
self.messages = []
self.messages_path = app.conversationpath + "/" + source_hash
self.messages_load_time = None
self.source_known = False
self.source_trusted = False
self.source_blocked = False
self.unread = False
self.__changed_callback = None
if not RNS.Identity.recall(bytes.fromhex(self.source_hash)):
RNS.Transport.request_path(bytes.fromhex(source_hash))
self.source_identity = RNS.Identity.recall(bytes.fromhex(self.source_hash))
if self.source_identity:
self.source_known = True
self.send_destination = RNS.Destination(self.source_identity, RNS.Destination.OUT, RNS.Destination.SINGLE, "lxmf", "delivery")
if initiator:
if not os.path.isdir(self.messages_path):
os.makedirs(self.messages_path)
if Conversation.created_callback != None:
Conversation.created_callback()
self.scan_storage()
self.trust_level = app.directory.trust_level(bytes.fromhex(self.source_hash))
Conversation.cache_conversation(self)
def scan_storage(self):
old_len = len(self.messages)
self.messages = []
for filename in os.listdir(self.messages_path):
if len(filename) == RNS.Identity.HASHLENGTH//8*2:
message_path = self.messages_path + "/" + filename
self.messages.append(ConversationMessage(message_path))
new_len = len(self.messages)
if new_len > old_len:
self.unread = True
if self.__changed_callback != None:
self.__changed_callback(self)
def purge_failed(self):
purged_messages = []
for conversation_message in self.messages:
if conversation_message.get_state() == LXMF.LXMessage.FAILED:
purged_messages.append(conversation_message)
conversation_message.purge()
for purged_message in purged_messages:
self.messages.remove(purged_message)
def clear_history(self):
purged_messages = []
for conversation_message in self.messages:
purged_messages.append(conversation_message)
conversation_message.purge()
for purged_message in purged_messages:
self.messages.remove(purged_message)
def register_changed_callback(self, callback):
self.__changed_callback = callback
def send(self, content="", title=""):
if self.send_destination:
dest = self.send_destination
source = self.app.lxmf_destination
desired_method = LXMF.LXMessage.DIRECT
if self.app.directory.preferred_delivery(dest.hash) == DirectoryEntry.PROPAGATED:
if self.app.message_router.get_outbound_propagation_node() != None:
desired_method = LXMF.LXMessage.PROPAGATED
else:
if not self.app.message_router.delivery_link_available(dest.hash) and RNS.Identity.current_ratchet_id(dest.hash) != None:
RNS.log(f"Have ratchet for {RNS.prettyhexrep(dest.hash)}, requesting opportunistic delivery of message", RNS.LOG_DEBUG)
desired_method = LXMF.LXMessage.OPPORTUNISTIC
dest_is_trusted = False
if self.app.directory.trust_level(dest.hash) == DirectoryEntry.TRUSTED:
dest_is_trusted = True
lxm = LXMF.LXMessage(dest, source, content, title=title, desired_method=desired_method, include_ticket=dest_is_trusted)
lxm.register_delivery_callback(self.message_notification)
lxm.register_failed_callback(self.message_notification)
if self.app.message_router.get_outbound_propagation_node() != None:
lxm.try_propagation_on_fail = self.app.try_propagation_on_fail
self.app.message_router.handle_outbound(lxm)
message_path = Conversation.ingest(lxm, self.app, originator=True)
self.messages.append(ConversationMessage(message_path))
return True
else:
RNS.log("Destination is not known, cannot create LXMF Message.", RNS.LOG_VERBOSE)
return False
def paper_output(self, content="", title="", mode="print_qr"):
if self.send_destination:
try:
dest = self.send_destination
source = self.app.lxmf_destination
desired_method = LXMF.LXMessage.PAPER
lxm = LXMF.LXMessage(dest, source, content, title=title, desired_method=desired_method)
if mode == "print_qr":
qr_code = lxm.as_qr()
qr_tmp_path = self.app.tmpfilespath+"/"+str(RNS.hexrep(lxm.hash, delimit=False))
qr_code.save(qr_tmp_path)
print_result = self.app.print_file(qr_tmp_path)
os.unlink(qr_tmp_path)
if print_result:
message_path = Conversation.ingest(lxm, self.app, originator=True)
self.messages.append(ConversationMessage(message_path))
return print_result
elif mode == "save_qr":
qr_code = lxm.as_qr()
qr_save_path = self.app.downloads_path+"/LXM_"+str(RNS.hexrep(lxm.hash, delimit=False)+".png")
qr_code.save(qr_save_path)
message_path = Conversation.ingest(lxm, self.app, originator=True)
self.messages.append(ConversationMessage(message_path))
return qr_save_path
elif mode == "save_uri":
lxm_uri = lxm.as_uri()+"\n"
uri_save_path = self.app.downloads_path+"/LXM_"+str(RNS.hexrep(lxm.hash, delimit=False)+".txt")
with open(uri_save_path, "wb") as f:
f.write(lxm_uri.encode("utf-8"))
message_path = Conversation.ingest(lxm, self.app, originator=True)
self.messages.append(ConversationMessage(message_path))
return uri_save_path
elif mode == "return_uri":
return lxm.as_uri()
except Exception as e:
RNS.log("An error occurred while generating paper message, the contained exception was: "+str(e), RNS.LOG_ERROR)
return False
else:
RNS.log("Destination is not known, cannot create LXMF Message.", RNS.LOG_VERBOSE)
return False
def message_notification(self, message):
if message.state == LXMF.LXMessage.FAILED and hasattr(message, "try_propagation_on_fail") and message.try_propagation_on_fail:
if hasattr(message, "stamp_generation_failed") and message.stamp_generation_failed == True:
RNS.log(f"Could not send {message} due to a stamp generation failure", RNS.LOG_ERROR)
else:
RNS.log("Direct delivery of "+str(message)+" failed. Retrying as propagated message.", RNS.LOG_VERBOSE)
message.try_propagation_on_fail = None
message.delivery_attempts = 0
if hasattr(message, "next_delivery_attempt"):
del message.next_delivery_attempt
message.packed = None
message.desired_method = LXMF.LXMessage.PROPAGATED
self.app.message_router.handle_outbound(message)
else:
message_path = Conversation.ingest(message, self.app, originator=True)
def __str__(self):
string = self.source_hash
# TODO: Remove this
# if self.source_identity:
# if self.source_identity.app_data:
# # TODO: Sanitise for viewing, or just clean this
# string += " | "+self.source_identity.app_data.decode("utf-8")
return string
class ConversationMessage:
def __init__(self, file_path):
self.file_path = file_path
self.loaded = False
self.timestamp = None
self.lxm = None
def load(self):
try:
self.lxm = LXMF.LXMessage.unpack_from_file(open(self.file_path, "rb"))
self.loaded = True
self.timestamp = self.lxm.timestamp
self.sort_timestamp = os.path.getmtime(self.file_path)
if self.lxm.state > LXMF.LXMessage.GENERATING and self.lxm.state < LXMF.LXMessage.SENT:
found = False
for pending in nomadnet.NomadNetworkApp.get_shared_instance().message_router.pending_outbound:
if pending.hash == self.lxm.hash:
found = True
for pending_id in nomadnet.NomadNetworkApp.get_shared_instance().message_router.pending_deferred_stamps:
if pending_id == self.lxm.hash:
found = True
if not found:
self.lxm.state = LXMF.LXMessage.FAILED
except Exception as e:
RNS.log("Error while loading LXMF message "+str(self.file_path)+" from disk. The contained exception was: "+str(e), RNS.LOG_ERROR)
def unload(self):
self.loaded = False
self.lxm = None
def purge(self):
self.unload()
if os.path.isfile(self.file_path):
os.unlink(self.file_path)
def get_timestamp(self):
if not self.loaded:
self.load()
return self.timestamp
def get_title(self):
if not self.loaded:
self.load()
return self.lxm.title_as_string()
def get_content(self):
if not self.loaded:
self.load()
return self.lxm.content_as_string()
def get_hash(self):
if not self.loaded:
self.load()
return self.lxm.hash
def get_state(self):
if not self.loaded:
self.load()
return self.lxm.state
def get_transport_encryption(self):
if not self.loaded:
self.load()
return self.lxm.transport_encryption
def get_transport_encrypted(self):
if not self.loaded:
self.load()
return self.lxm.transport_encrypted
def signature_validated(self):
if not self.loaded:
self.load()
return self.lxm.signature_validated
def get_signature_description(self):
if self.signature_validated():
return "Signature Verified"
else:
if self.lxm.unverified_reason == LXMF.LXMessage.SOURCE_UNKNOWN:
return "Unknown Origin"
elif self.lxm.unverified_reason == LXMF.LXMessage.SIGNATURE_INVALID:
return "Invalid Signature"
else:
return "Unknown signature validation failure"