initial commit

This commit is contained in:
liamcottle
2024-04-29 01:09:06 +12:00
commit 983d4a3790
3 changed files with 416 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
storage/

266
index.html Normal file
View File

@@ -0,0 +1,266 @@
<html>
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>Reticulum WebChat</title>
<!-- tailwind css -->
<script src="https://cdn.tailwindcss.com?plugins=forms"></script>
<!-- scripts -->
<script src="https://unpkg.com/vue@3"></script>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/moment@2.29.1/moment.min.js"></script>
</head>
<body class="bg-gray-100">
<div id="app" class="h-full flex flex-col">
<!-- header -->
<div class="flex bg-white p-2 border-gray-300 border-b">
<div class="max-w-xl mx-auto flex w-full">
<div class="flex my-auto border border-gray-300 rounded-md w-10 h-10 mr-3 shadow">
<div class="flex mx-auto my-auto">
<img class="w-9 h-9" src="https://reticulum.network/gfx/reticulum_logo_512.png"/>
</div>
</div>
<div class="my-auto">
<div class="font-bold">Reticulum WebChat</div>
<div class="text-sm">Developed by <a target="_blank" href="https://liamcottle.com" class="text-blue-500">Liam Cottle</a></div>
</div>
<div class="flex my-auto ml-auto mr-0 sm:mr-2 space-x-1 sm:space-x-2">
<a @click="sendAnnounce" href="javascript:void(0)" class="rounded-full">
<div class="flex text-gray-700 bg-gray-100 hover:bg-gray-200 px-2 py-1 rounded-full">
<div>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.288 15.038a5.25 5.25 0 0 1 7.424 0M5.106 11.856c3.807-3.808 9.98-3.808 13.788 0M1.924 8.674c5.565-5.565 14.587-5.565 20.152 0M12.53 18.22l-.53.53-.53-.53a.75.75 0 0 1 1.06 0Z" />
</svg>
</div>
<div class="my-auto mx-1 text-sm">Announce</div>
</div>
</a>
</div>
</div>
</div>
<!-- chat items -->
<div id="messages" class="h-full overflow-y-scroll px-3 sm:px-0">
<div class="max-w-xl mx-auto">
<div v-if="messages.length > 0" class="flex flex-col space-y-3 py-4">
<div v-for="message of messages">
<div class="flex space-x-2 border border-gray-300 rounded-lg shadow px-2 py-1.5 bg-white">
<div>
<!-- error -->
<div v-if="message.source_hash === 'error'" class="bg-red-500 text-white rounded-full p-1 shadow-md">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
</svg>
</div>
<!-- user -->
<div v-else class="bg-blue-500 text-white rounded-full p-1 shadow-md">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
</svg>
</div>
</div>
<div>
<div class="font-semibold leading-5">
<span v-if="message.is_outbound">You</span>
<span v-else-if="message.source_hash === 'error'">Error</span>
<span v-else>@<{{ message.source_hash }}></span>
</div>
<div v-if="message.type === 'text'" style="white-space:pre-wrap;word-wrap:break-word;font-family:inherit;">{{ message.text }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- send message -->
<div class="bg-white border-gray-300 border-t p-2">
<div class="max-w-xl mx-auto">
<!-- message composer -->
<div>
<textarea id="message-input" :readonly="isSendingMessage" v-model="newMessageText" @keydown.enter.exact.native.prevent="onEnterPressed" @keydown.enter.shift.exact.native.prevent="onShiftEnterPressed" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5" rows="3" placeholder="Send a message..."></textarea>
<div class="flex">
<button @click="sendMessage" type="button" class="ml-auto mt-2 my-auto inline-flex items-center gap-x-1 rounded-md bg-blue-500 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500">
Send
</button>
</div>
</div>
</div>
</div>
</div>
<script>
Vue.createApp({
data() {
return {
isWebsocketConnected: false,
newMessageText: "",
isSendingMessage: false,
autoScrollOnNewMessage: true,
messages: [],
};
},
mounted: function() {
this.connectWebsocket();
},
methods: {
connectWebsocket: function() {
// connect to websocket
this.ws = new WebSocket("ws://localhost:8000");
this.ws.addEventListener('open', () => {
this.isWebsocketConnected = true;
});
this.ws.addEventListener('close', () => {
this.isWebsocketConnected = false;
});
// handle data from reticulum
this.ws.onmessage = (message) => {
const json = JSON.parse(message.data);
switch(json.type){
case 'lxmf.delivery': {
this.messages.push({
"type": "text",
"source_hash": json.source_hash,
"text": json.message,
})
if(this.autoScrollOnNewMessage){
this.scrollMessagesToBottom();
}
break;
}
}
};
},
disconnectWebsocket: function() {
if(this.ws){
this.ws.close();
}
},
scrollMessagesToBottom: function() {
Vue.nextTick(() => {
const container = document.getElementById("messages");
container.scrollTop = container.scrollHeight;
});
},
async sendAnnounce() {
// do nothing if not connected to websocket
if(!this.isWebsocketConnected){
alert("Not connected to WebSocket!");
return;
}
try {
// ask reticulum to announce
this.ws.send(JSON.stringify({
"type": "announce",
}));
} catch(e) {
console.error(e);
}
},
async sendMessage() {
// do nothing if empty message
const messageText = this.newMessageText.trim();
if(messageText == null || messageText === ""){
return;
}
// do nothing if not connected to websocket
if(!this.isWebsocketConnected){
alert("Not connected to WebSocket!");
return;
}
this.isSendingMessage = true;
try {
// send message to reticulum via websocket
this.ws.send(JSON.stringify({
"type": "lxmf.delivery",
"destination_hash": "42973ba338620b6319384fef3ae4f0d8", // FIXME
"message": messageText,
}));
// add sent message to ui
this.messages.push({
"is_outbound": true,
"source_hash": "todo", // FIXME
"type": "text",
"text": messageText,
});
// clear message input
this.newMessageText = "";
} catch(e) {
console.error(e);
this.messages.push({
"source_hash": "error",
"type": "text",
"text": e.message ?? e ?? "Unknown Error...",
});
} finally {
this.isSendingMessage = false;
}
// scroll to bottom
this.scrollMessagesToBottom();
},
addNewLine: function() {
this.newMessageText += "\n";
},
onEnterPressed: function() {
// add new line on mobile
if(this.isMobile){
this.addNewLine();
return;
}
// send message on desktop
this.sendMessage();
},
onShiftEnterPressed: function() {
this.addNewLine();
},
},
computed: {
isMobile() {
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
},
},
}).mount('#app');
</script>
</body>
</html>

149
web.py Normal file
View File

@@ -0,0 +1,149 @@
#!/usr/bin/env python
import json
import RNS
import LXMF
import asyncio
import websockets
import base64
# init reticulum
reticulum = RNS.Reticulum(None)
# create a new identity and log as base64
identity = RNS.Identity()
print(base64.b64encode(identity.get_private_key()))
# init lxmf router
message_router = LXMF.LXMRouter(identity=identity, storagepath="storage/lxmf")
# register lxmf identity
local_lxmf_destination = message_router.register_delivery_identity(identity, display_name="ReticulumWebChat")
# global reference to all connected websocket clients
websocket_clients = []
async def main():
# set a callback for when an lxmf message is received
message_router.register_delivery_callback(lxmf_delivery)
# start websocket server
async with websockets.serve(on_websocket_client_connected, "", 8000):
await asyncio.Future() # run forever
# handle websocket messages
async def on_websocket_client_connected(client):
# add client to connected clients list
websocket_clients.append(client)
# handle client messages until disconnected
while True:
try:
message = await client.recv()
data = json.loads(message)
await on_data(client, data)
except websockets.ConnectionClosedOK:
# client disconnected, we can stop looping
break
except Exception as e:
# ignore errors while handling message
print("failed to process client message")
print(e)
# loop finished, client is no longer connected
websocket_clients.remove(client)
async def on_data(client, data):
# get type from client data
_type = data["type"]
# handle sending an lxmf message
if _type == "lxmf.delivery":
# send lxmf message to destination
destination_hash = data["destination_hash"]
message = data["message"]
send_message(destination_hash, message)
# # TODO: send response to client when marked as delivered?
# await client.send(json.dumps({
# "type": "lxmf.sent",
# }))
# handle sending an announce
elif _type == "announce":
# send announce for lxmf
local_lxmf_destination.announce()
# unhandled type
else:
print("unhandled client message type: " + _type)
def websocket_broadcast(data):
# broadcast provided data to all connected websocket clients
for websocket_client in websocket_clients:
asyncio.run(websocket_client.send(data))
def lxmf_delivery(message):
try:
# get message data
message_content = message.content.decode('utf-8')
source_hash_text = RNS.hexrep(message.source_hash, delimit=False)
# send received lxmf message data to all websocket clients
websocket_broadcast(json.dumps({
"type": "lxmf.delivery",
"source_hash": source_hash_text,
"message": message_content,
}))
except Exception as e:
# do nothing on error
print(e)
def send_message(destination_hash, message_content):
try:
# convert destination hash to bytes
destination_hash = bytes.fromhex(destination_hash)
# find destination identity from hash
destination_identity = RNS.Identity.recall(destination_hash)
if destination_identity is None:
# we don't know the path/identity for this destination hash, we will request it
RNS.Transport.request_path(destination_hash)
# we have to bail out of sending, since we don't have the path yet
return
# create destination for recipients lxmf delivery address
lxmf_destination = RNS.Destination(destination_identity, RNS.Destination.OUT, RNS.Destination.SINGLE, "lxmf", "delivery")
# create lxmf message
lxm = LXMF.LXMessage(lxmf_destination, local_lxmf_destination, message_content, desired_method=LXMF.LXMessage.DIRECT)
lxm.try_propagation_on_fail = True
# send lxmf message to be routed to destination
message_router.handle_outbound(lxm)
except:
# FIXME send error to websocket?
print("failed to send lxmf message")
if __name__ == "__main__":
asyncio.run(main())