reticulum-meshchat/web.py

474 lines
18 KiB
Python
Raw Normal View History

2024-04-29 01:09:06 +12:00
#!/usr/bin/env python
import argparse
2024-04-29 20:26:45 +12:00
import http
2024-04-29 01:09:06 +12:00
import json
2024-04-29 22:06:06 +12:00
import mimetypes
import os
2024-04-29 01:09:06 +12:00
import RNS
import LXMF
import asyncio
import websockets
import base64
2024-04-29 20:26:45 +12:00
class ReticulumWebChat:
2024-04-29 01:09:06 +12:00
def __init__(self, webchat_config_file, reticulum_config_dir, identity: RNS.Identity):
2024-04-29 23:16:54 +12:00
# default values before loading config
self.display_name = "Anonymous Peer"
# load config
self.config_file = webchat_config_file or "storage/config.json"
2024-04-29 23:16:54 +12:00
self.load_config()
2024-04-29 20:26:45 +12:00
# init reticulum
self.reticulum = RNS.Reticulum(reticulum_config_dir)
2024-04-29 20:26:45 +12:00
self.identity = identity
2024-04-29 01:09:06 +12:00
2024-04-29 20:26:45 +12:00
# init lxmf router
self.message_router = LXMF.LXMRouter(identity=self.identity, storagepath="storage/lxmf")
2024-04-29 01:09:06 +12:00
2024-04-29 20:26:45 +12:00
# register lxmf identity
self.local_lxmf_destination = self.message_router.register_delivery_identity(self.identity)
2024-04-29 20:26:45 +12:00
# set a callback for when an lxmf message is received
self.message_router.register_delivery_callback(self.on_lxmf_delivery)
2024-04-29 20:26:45 +12:00
# set a callback for when an lxmf announce is received
RNS.Transport.register_announce_handler(LXMFAnnounceHandler(self.on_lxmf_announce_received))
2024-04-29 20:26:45 +12:00
# remember websocket clients
self.websocket_clients = []
2024-04-29 23:16:54 +12:00
def load_config(self):
# default config
config = {
}
# attempt to load config and override default values
try:
with open(self.config_file, 'r') as f:
custom_config = json.load(f)
config |= custom_config
# config is broken, fallback to defaults
except:
print("failed to load config, defaults will be used")
# update display name from config
if "display_name" in config:
self.display_name = config["display_name"]
# return loaded config
return config
def save_config(self):
# build config
config = {
"display_name": self.display_name,
}
# attempt to save config
try:
with open(self.config_file, 'w') as f:
json.dump(config, f, indent=4)
# config is broken, fallback to defaults
except:
print("failed to save config")
2024-04-29 20:26:45 +12:00
async def run(self, host, port):
2024-04-29 20:26:45 +12:00
# start websocket server
async with websockets.serve(self.on_websocket_client_connected, host, port, process_request=self.process_request):
print("ReticulumWebChat server running at http://{}:{}".format(host, port))
await asyncio.Future() # run forever
2024-04-29 01:09:06 +12:00
2024-04-29 20:26:45 +12:00
# handle serving custom http paths
async def process_request(self, path, request_headers):
2024-04-29 22:06:06 +12:00
2024-04-29 20:26:45 +12:00
# serve index.html
if path == "/":
2024-04-30 15:56:07 +12:00
with open("public/index.html") as f:
2024-04-29 20:26:45 +12:00
file_content = f.read()
return http.HTTPStatus.OK, [
('Content-Type', 'text/html')
], file_content.encode("utf-8")
2024-04-29 22:06:06 +12:00
# serve anything in public folder
public_file_path = os.path.join("public", path.lstrip("/"))
if os.path.isfile(public_file_path):
mime_type, _ = mimetypes.guess_type(public_file_path)
with open(public_file_path, "rb") as f:
file_content = f.read()
return http.HTTPStatus.OK, [
('Content-Type', mime_type)
], file_content
2024-04-29 20:26:45 +12:00
# by default, websocket is always served, but we only want it to be available at /ws
# so we will return 404 for everything other than /ws
2024-04-29 22:06:06 +12:00
if path != "/ws":
2024-04-29 20:26:45 +12:00
return http.HTTPStatus.NOT_FOUND, [
('Content-Type', 'text/html')
], b"Not Found"
2024-04-29 20:26:45 +12:00
# handle new client connecting to websocket
async def on_websocket_client_connected(self, client):
2024-04-29 20:26:45 +12:00
# add client to connected clients list
self.websocket_clients.append(client)
2024-04-29 01:09:06 +12:00
2024-04-29 22:28:34 +12:00
# send config to all clients
2024-04-29 23:43:28 +12:00
await self.send_config_to_websocket_clients()
2024-04-29 22:28:34 +12:00
2024-04-29 20:26:45 +12:00
# handle client messages until disconnected
while True:
try:
message = await client.recv()
data = json.loads(message)
await self.on_websocket_data_received(client, data)
except (websockets.ConnectionClosed, websockets.ConnectionClosedOK, websockets.ConnectionClosedError):
2024-04-29 20:26:45 +12:00
# client disconnected, we can stop looping
break
except Exception as e:
# ignore errors while handling message
print("failed to process client message")
print(e)
2024-04-29 01:09:06 +12:00
2024-04-29 20:26:45 +12:00
# loop finished, client is no longer connected
self.websocket_clients.remove(client)
2024-04-29 01:09:06 +12:00
2024-04-29 20:26:45 +12:00
# handle data received from websocket client
async def on_websocket_data_received(self, client, data):
2024-04-29 01:09:06 +12:00
2024-04-29 20:26:45 +12:00
# get type from client data
_type = data["type"]
2024-04-29 01:09:06 +12:00
2024-04-29 23:16:54 +12:00
# handle updating config
if _type == "config.set":
# send lxmf message to destination
config = data["config"]
# update display name in state
if "display_name" in config and config["display_name"] != "":
self.display_name = config["display_name"]
print("updated display name to: " + self.display_name)
# save config
self.save_config()
# send config to websocket clients
2024-04-29 23:43:28 +12:00
await self.send_config_to_websocket_clients()
2024-04-29 23:16:54 +12:00
2024-04-29 20:26:45 +12:00
# handle sending an lxmf message
2024-04-29 23:16:54 +12:00
elif _type == "lxmf.delivery":
2024-04-29 01:09:06 +12:00
2024-04-29 20:26:45 +12:00
# send lxmf message to destination
destination_hash = data["destination_hash"]
message = data["message"]
await self.send_message(destination_hash, message)
2024-04-29 01:09:06 +12:00
2024-04-29 20:26:45 +12:00
# # TODO: send response to client when marked as delivered?
# await client.send(json.dumps({
# "type": "lxmf.sent",
# }))
2024-04-29 01:09:06 +12:00
2024-04-29 20:26:45 +12:00
# handle sending an announce
elif _type == "announce":
2024-04-29 01:09:06 +12:00
2024-04-29 20:26:45 +12:00
# send announce for lxmf
self.local_lxmf_destination.announce(app_data=self.display_name.encode("utf-8"))
2024-04-29 01:09:06 +12:00
2024-04-29 20:26:45 +12:00
# unhandled type
else:
print("unhandled client message type: " + _type)
2024-04-29 01:09:06 +12:00
# broadcast provided data to all connected websocket clients
2024-04-29 23:43:28 +12:00
async def websocket_broadcast(self, data):
2024-04-29 20:26:45 +12:00
for websocket_client in self.websocket_clients:
2024-04-29 23:43:28 +12:00
await websocket_client.send(data)
2024-04-29 22:28:34 +12:00
# broadcasts config to all websocket clients
2024-04-29 23:43:28 +12:00
async def send_config_to_websocket_clients(self):
await self.websocket_broadcast(json.dumps({
2024-04-29 22:28:34 +12:00
"type": "config",
"config": {
2024-04-29 23:16:54 +12:00
"display_name": self.display_name,
2024-04-29 22:28:34 +12:00
"identity_hash": self.identity.hexhash,
"lxmf_address_hash": self.local_lxmf_destination.hexhash,
},
}))
2024-04-29 01:09:06 +12:00
# convert an lxmf message to a dictionary, for sending over websocket
def convert_lxmf_message_to_dict(self, lxmf_message: LXMF.LXMessage):
# handle fields
fields = {}
message_fields = lxmf_message.get_fields()
for field_type in message_fields:
value = message_fields[field_type]
# handle file attachments field
if field_type == LXMF.FIELD_FILE_ATTACHMENTS:
# process file attachments
file_attachments = []
for file_attachment in value:
file_name = file_attachment[0]
file_bytes = base64.b64encode(file_attachment[1]).decode("utf-8")
file_attachments.append({
"file_name": file_name,
"file_bytes": file_bytes,
})
# add to fields
fields["file_attachments"] = file_attachments
# handle image field
if field_type == LXMF.FIELD_IMAGE:
image_type = value[0]
image_bytes = base64.b64encode(value[1]).decode("utf-8")
fields["image"] = {
"image_type": image_type,
"image_bytes": image_bytes,
}
return {
"hash": lxmf_message.hash.hex(),
"source_hash": lxmf_message.source_hash.hex(),
"destination_hash": lxmf_message.destination_hash.hex(),
"state": self.convert_lxmf_state_to_string(lxmf_message),
"progress": lxmf_message.progress,
"content": lxmf_message.content.decode('utf-8'),
"fields": fields,
}
# convert lxmf state to a human friendly string
def convert_lxmf_state_to_string(self, lxmf_message: LXMF.LXMessage):
# convert state to string
lxmf_message_state = "unknown"
if lxmf_message.state == LXMF.LXMessage.DRAFT:
lxmf_message_state = "draft"
elif lxmf_message.state == LXMF.LXMessage.OUTBOUND:
lxmf_message_state = "outbound"
elif lxmf_message.state == LXMF.LXMessage.SENDING:
lxmf_message_state = "sending"
elif lxmf_message.state == LXMF.LXMessage.SENT:
lxmf_message_state = "sent"
elif lxmf_message.state == LXMF.LXMessage.DELIVERED:
lxmf_message_state = "delivered"
elif lxmf_message.state == LXMF.LXMessage.FAILED:
lxmf_message_state = "failed"
return lxmf_message_state
2024-04-29 20:26:45 +12:00
# handle an lxmf delivery from reticulum
2024-04-29 23:43:28 +12:00
# NOTE: cant be async, as Reticulum doesn't await it
2024-04-29 20:26:45 +12:00
def on_lxmf_delivery(self, message):
try:
2024-04-29 01:09:06 +12:00
2024-04-29 20:26:45 +12:00
# get message data
message_content = message.content.decode('utf-8')
source_hash_text = RNS.hexrep(message.source_hash, delimit=False)
fields = {}
message_fields = message.get_fields()
for field_type in message_fields:
value = message_fields[field_type]
# handle file attachments field
if field_type == LXMF.FIELD_FILE_ATTACHMENTS:
# process file attachments
file_attachments = []
for file_attachment in value:
file_name = file_attachment[0]
file_bytes = base64.b64encode(file_attachment[1]).decode("utf-8")
file_attachments.append({
"file_name": file_name,
"file_bytes": file_bytes,
})
# add to fields
fields["file_attachments"] = file_attachments
# handle image field
if field_type == LXMF.FIELD_IMAGE:
image_type = value[0]
image_bytes = base64.b64encode(value[1]).decode("utf-8")
fields["image"] = {
"image_type": image_type,
"image_bytes": image_bytes,
}
# send received lxmf message data to all websocket clients
2024-04-29 23:43:28 +12:00
asyncio.run(self.websocket_broadcast(json.dumps({
2024-04-29 20:26:45 +12:00
"type": "lxmf.delivery",
"lxmf_message": self.convert_lxmf_message_to_dict(message),
2024-04-29 23:43:28 +12:00
})))
2024-04-29 01:09:06 +12:00
2024-04-29 20:26:45 +12:00
except Exception as e:
# do nothing on error
print("lxmf_delivery error: {}".format(e))
2024-04-29 01:09:06 +12:00
# handle delivery status update for an outbound lxmf message
def on_lxmf_sending_state_updated(self, lxmf_message):
# send lxmf message state to all websocket clients
asyncio.run(self.websocket_broadcast(json.dumps({
"type": "lxmf_message_state_updated",
"lxmf_message": self.convert_lxmf_message_to_dict(lxmf_message),
})))
# handle delivery failed for an outbound lxmf message
def on_lxmf_sending_failed(self, lxmf_message):
# just pass this on, we don't need to do anything special
self.on_lxmf_sending_state_updated(lxmf_message)
2024-04-29 20:26:45 +12:00
# handle sending an lxmf message to reticulum
async def send_message(self, destination_hash, message_content):
2024-04-29 01:09:06 +12:00
2024-04-29 20:26:45 +12:00
try:
2024-04-29 01:09:06 +12:00
2024-04-29 20:26:45 +12:00
# convert destination hash to bytes
destination_hash = bytes.fromhex(destination_hash)
2024-04-29 01:09:06 +12:00
# FIXME: can this be removed, and just rely on the router to check paths?
2024-04-29 20:26:45 +12:00
# find destination identity from hash
destination_identity = RNS.Identity.recall(destination_hash)
if destination_identity is None:
2024-04-29 01:09:06 +12:00
2024-04-29 20:26:45 +12:00
# we don't know the path/identity for this destination hash, we will request it
RNS.Transport.request_path(destination_hash)
2024-04-29 01:09:06 +12:00
2024-04-29 20:26:45 +12:00
# we have to bail out of sending, since we don't have the path yet
return
2024-04-29 01:09:06 +12:00
2024-04-29 20:26:45 +12:00
# create destination for recipients lxmf delivery address
lxmf_destination = RNS.Destination(destination_identity, RNS.Destination.OUT, RNS.Destination.SINGLE, "lxmf", "delivery")
2024-04-29 01:09:06 +12:00
2024-04-29 20:26:45 +12:00
# create lxmf message
lxmf_message = LXMF.LXMessage(lxmf_destination, self.local_lxmf_destination, message_content, desired_method=LXMF.LXMessage.DIRECT)
lxmf_message.try_propagation_on_fail = True
lxmf_message.register_delivery_callback(self.on_lxmf_sending_state_updated)
lxmf_message.register_failed_callback(self.on_lxmf_sending_failed)
2024-04-29 01:09:06 +12:00
2024-04-29 20:26:45 +12:00
# send lxmf message to be routed to destination
self.message_router.handle_outbound(lxmf_message)
# send outbound lxmf message to websocket (after passing to router so hash is available)
await self.websocket_broadcast(json.dumps({
"type": "lxmf_outbound_message_created",
"lxmf_message": self.convert_lxmf_message_to_dict(lxmf_message),
}))
2024-04-29 01:09:06 +12:00
2024-04-29 20:26:45 +12:00
except:
# FIXME send error to websocket?
print("failed to send lxmf message")
2024-04-29 01:09:06 +12:00
2024-04-29 20:26:45 +12:00
# handle an announce received from reticulum, for an lxmf address
2024-04-29 23:43:28 +12:00
# NOTE: cant be async, as Reticulum doesn't await it
2024-04-29 20:26:45 +12:00
def on_lxmf_announce_received(self, destination_hash, announced_identity, app_data):
2024-04-29 20:26:45 +12:00
# log received announce
RNS.log("Received an announce from " + RNS.prettyhexrep(destination_hash))
2024-04-29 20:26:45 +12:00
# parse app data
parsed_app_data = None
if app_data is not None:
parsed_app_data = app_data.decode("utf-8")
2024-04-29 20:26:45 +12:00
# send received lxmf announce to all websocket clients
2024-04-29 23:43:28 +12:00
asyncio.run(self.websocket_broadcast(json.dumps({
2024-04-29 20:26:45 +12:00
"type": "announce",
"destination_hash": destination_hash.hex(),
"app_data": parsed_app_data,
2024-04-29 23:43:28 +12:00
})))
2024-04-29 20:26:45 +12:00
# an announce handler for lxmf.delivery aspect that just forwards to a provided callback
class LXMFAnnounceHandler:
def __init__(self, received_announce_callback):
self.aspect_filter = "lxmf.delivery"
self.received_announce_callback = received_announce_callback
# we will just pass the received announce back to the provided callback
def received_announce(self, destination_hash, announced_identity, app_data):
try:
# handle received announce
self.received_announce_callback(destination_hash, announced_identity, app_data)
except:
# ignore failure to handle received announce
pass
2024-04-29 20:26:45 +12:00
async def main():
# parse command line args
parser = argparse.ArgumentParser(description="ReticulumWebChat")
parser.add_argument("--host", nargs='?', default="0.0.0.0", type=str, help="The address the web server should listen on.")
parser.add_argument("--port", nargs='?', default="8000", type=int, help="The port the web server should listen on.")
parser.add_argument("--identity-file", type=str, help="Path to a Reticulum Identity file to use as your LXMF address.")
parser.add_argument("--identity-base64", type=str, help="A base64 encoded Reticulum Identity to use as your LXMF address.")
parser.add_argument("--generate-identity-file", type=str, help="Generates and saves a new Reticulum Identity to the provided file path and then exits.")
parser.add_argument("--generate-identity-base64", action='store_true', help="Outputs a randomly generated Reticulum Identity as base64 and then exits.")
parser.add_argument("--reticulum-config-dir", type=str, help="Path to a Reticulum config directory for the RNS stack to use (e.g: ~/.reticulum)")
parser.add_argument("--webchat-config-file", type=str, help="Path to a ReticulumWebChat config file for saving user preferences.")
2024-04-29 20:26:45 +12:00
args = parser.parse_args()
# util to generate reticulum identity and save to file without using rnid
if args.generate_identity_file is not None:
# do not overwrite existing files, otherwise user could lose existing keys
if os.path.exists(args.generate_identity_file):
print("DANGER: the provided identity file path already exists, not overwriting!")
return
# generate a new identity and save to provided file path
identity = RNS.Identity(create_keys=True)
with open(args.generate_identity_file, "wb") as file:
file.write(identity.get_private_key())
print("A new Reticulum Identity has been saved to: {}".format(args.generate_identity_file))
return
# util to generate reticulum identity as base64 without using rnid
2024-04-30 21:48:15 +12:00
if args.generate_identity_base64 is True:
identity = RNS.Identity(create_keys=True)
print(base64.b64encode(identity.get_private_key()).decode("utf-8"))
return
2024-04-29 20:26:45 +12:00
# use provided identity, or fallback to a random one
if args.identity_file is not None:
identity = RNS.Identity(create_keys=False)
identity.load(args.identity_file)
print("Reticulum Identity has been loaded from file.")
print(identity)
elif args.identity_base64 is not None:
identity = RNS.Identity(create_keys=False)
identity.load_private_key(base64.b64decode(args.identity_base64))
print("Reticulum Identity has been loaded from base64.")
print(identity)
else:
identity = RNS.Identity(create_keys=True)
print("Reticulum Identity has been randomly generated.")
print(identity)
2024-04-29 20:26:45 +12:00
# init app
reticulum_webchat = ReticulumWebChat(args.webchat_config_file, args.reticulum_config_dir, identity)
2024-04-29 20:26:45 +12:00
await reticulum_webchat.run(args.host, args.port)
2024-04-29 01:09:06 +12:00
if __name__ == "__main__":
2024-04-29 20:26:45 +12:00
asyncio.run(main())