Skip to content

Commit

Permalink
switch backend to starlette + broadcaster
Browse files Browse the repository at this point in the history
  • Loading branch information
iiPythonx committed Jul 20, 2024
1 parent e3b08f3 commit 765d06a
Show file tree
Hide file tree
Showing 17 changed files with 141 additions and 63 deletions.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<h1 align = "center">Nightwatch</h1>
<div align = "center">

![Python](https://img.shields.io/badge/Python-%3E=%203.10-4b8bbe?style=for-the-badge&logo=python&logoColor=white)
![Python](https://img.shields.io/badge/Python-%3E=%203.11-4b8bbe?style=for-the-badge&logo=python&logoColor=white)
![Rust](https://img.shields.io/badge/Rust-%3E=%201.60-221f1e?style=for-the-badge&logo=rust&logoColor=white)

The chatting application to end all chatting applications.
Expand All @@ -19,7 +19,7 @@ Here are two of the standard clients for you to choose from:
- Full Desktop App ([based on tauri](https://tauri.app/))
- Download the latest release for your system from [here](https://github.com/iiPythonx/nightwatch/releases/latest).
- Alternatively, run it manually:
- Follow the instructions from [Tauri prerequisites](https://tauri.app/v1/guides/getting-started/prerequisites) (including installing [Rust](https://rust-lang.org)).
- Follow the instructions from [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/).
- Install the Tauri CLI with `cd nightwatch/desktop && bun i`.
- Launch via `bun run tauri dev`.

Expand Down
20 changes: 18 additions & 2 deletions nightwatch/auth/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,26 @@ def __init__(self) -> None:
# Handle errors
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
error = exc.errors()[0] # Just focus on one
error, message = exc.errors()[0], str(exc) # Just focus on one
match error["type"]:
case "too_short" | "string_too_short":
message = f"{error['loc'][1].capitalize()} should be at least {error['ctx']['min_length']} characters."

case "too_long" | "string_too_long":
message = f"{error['loc'][1].capitalize()} should be {error['ctx']['max_length']} characters at most."

case "missing":
message = f"{error['loc'][1].capitalize()} needs to be specified."

case "string_type":
message = f"{error['loc'][1].capitalize()} must be a valid string."

case "url_type" | "url_scheme" | "url_parsing":
message = f"{error['loc'][1].capitalize()} must be a valid URL."

return JSONResponse(
status_code = 422,
content = {"code": 422, "data": error["msg"].lower().replace("value", error["loc"][1])}
content = {"code": 422, "data": message}
)

# Routing
Expand Down
2 changes: 1 addition & 1 deletion nightwatch/client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ def connect_loop(host: str, port: int, username: str) -> None:
ws = ORJSONWebSocket(ws)

# Handle identification payload
ws.send({"type": "identify", "data": {"name": username, "color": config["user.color"]}})
ws.send({"type": "identify", "data": {"name": username, "color": config["client.user_color"]}})
response = ws.recv()
if response["type"] == "error":
exit(f"\nCould not connect to {destination}. Additional details:\n{response['data']['text']}")
Expand Down
5 changes: 5 additions & 0 deletions nightwatch/desktop/src/css/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ ul.nav {
}

/* Custom element colors based on Soda */
#authServer {
cursor: pointer;
color: var(--sd-cherry-blossom-d25);
}

a.text-primary-emphasis {
color: var(--sd-sky-d20) !important;
}
Expand Down
7 changes: 3 additions & 4 deletions nightwatch/desktop/src/frames/auth/login.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<form style = "width: 380px; background-color: var(--sd-black-d5);" class = "p-3 rounded">
<form style = "width: 380px; background-color: var(--sd-black-d5);" class = "p-3 rounded" data-nightwatch-action = "login">
<h5>Account Login</h5>
<hr>
<div class = "mb-3">
Expand All @@ -8,19 +8,18 @@ <h5>Account Login</h5>
<div class = "mb-4">
<label for = "passwordInput" class = "form-label">Password</label>
<input type = "password" class = "form-control" id = "passwordInput" aria-describedby = "authServerNotice">
<div id = "authServerNotice" class = "form-text">Logging in on: <span style = "color: var(--sd-cherry-blossom-d25);">auth.iipython.dev</span>.</div>
<div id = "authServerNotice" class = "form-text">Logging in on: <span id = "authServer" onclick = "load_frame_as_modal('settings');"></span>.</div>
</div>
<div class = "row align-items-center">
<div class = "col">
<a role = "button" class = "text-primary-emphasis" onclick = "load_frame('auth/signup');">Create account</a>
</div>
<div class = "col">
<div class = "d-flex justify-content-end gap-2">
<button class = "btn btn-secondary" onclick = "load_frame_as_modal('settings');">Settings</button>
<button class = "btn btn-secondary" onclick = "load_frame_as_modal('settings');" type = "button">Settings</button>
<button class = "btn btn-primary" type = "submit">Continue</button>
</div>
</div>
</div>
</form>
<script>window.__NWAUTHMETHOD__ = nightwatch.login;</script>
<script src = "/js/frames/auth.js"></script>
7 changes: 3 additions & 4 deletions nightwatch/desktop/src/frames/auth/signup.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<form style = "width: 380px; background-color: var(--sd-black-d5);" class = "p-3 rounded">
<form style = "width: 380px; background-color: var(--sd-black-d5);" class = "p-3 rounded" data-nightwatch-action = "signup">
<h5>Account Creation</h5>
<hr>
<div class = "mb-3">
Expand All @@ -8,19 +8,18 @@ <h5>Account Creation</h5>
<div class = "mb-4">
<label for = "passwordInput" class = "form-label">Password</label>
<input type = "password" class = "form-control" id = "passwordInput" aria-describedby = "authServerNotice">
<div id = "authServerNotice" class = "form-text">Signing up on: <span style = "color: var(--sd-cherry-blossom-d25);">auth.iipython.dev</span>.</div>
<div id = "authServerNotice" class = "form-text">Signing up on: <span id = "authServer" onclick = "load_frame_as_modal('settings');"></span>.</div>
</div>
<div class = "row align-items-center">
<div class = "col">
<a role = "button" class = "text-primary-emphasis" onclick = "load_frame('auth/login');">Login instead</a>
</div>
<div class = "col">
<div class = "d-flex justify-content-end gap-2">
<button class = "btn btn-secondary" onclick = "load_frame_as_modal('settings');">Settings</button>
<button class = "btn btn-secondary" onclick = "load_frame_as_modal('settings');" type = "button">Settings</button>
<button class = "btn btn-primary" type = "submit">Continue</button>
</div>
</div>
</div>
</form>
<script>window.__NWAUTHMETHOD__ = nightwatch.signup;</script>
<script src = "/js/frames/auth.js"></script>
5 changes: 3 additions & 2 deletions nightwatch/desktop/src/frames/errors/login.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@ <h5>Login Failure</h5>
<hr>
<p>
The authentication server you use (${nightwatch.auth_server}) has failed to authenticate you. <br>
Error details: <code>${nightwatch._error.message}</code>.
Error details: <code>${nightwatch._error.message}</code>
</p>
<div class = "d-flex justify-content-end gap-2">
<button class = "btn btn-secondary" onclick = "load_frame_as_modal('settings');">Settings</button>
<button class = "btn btn-danger" onclick = "main_login();">Retry</button>
<button class = "btn btn-danger" onclick = "load_frame('auth/login');">Relogin</button>
</div>
`;
delete nightwatch._error;
})();
</script>
1 change: 1 addition & 0 deletions nightwatch/desktop/src/frames/errors/network.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,6 @@ <h5>Network Failure</h5>
<button class = "btn btn-danger" onclick = "main_login();">Retry</button>
</div>
`;
delete nightwatch._error;
})();
</script>
4 changes: 2 additions & 2 deletions nightwatch/desktop/src/frames/server/add.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
<label for = "addressInput" class = "form-label"><b>Address</b></label>
<div class = "input-group mb-3">
<span class = "input-group-text">wss://</span>
<input type = "text" class = "form-control" id = "addressInput">
<input type = "text" class = "form-control" id = "addressInput" autofocus>
</div>
</div>
<div class = "d-flex justify-content-end gap-2">
<button class = "btn btn-secondary" onclick = "close_modal();">Back</button>
<button class = "btn btn-secondary" onclick = "close_modal();" type = "button">Back</button>
<button class = "btn btn-primary" type = "submit">Add Server +</button>
</div>
</form>
Expand Down
31 changes: 21 additions & 10 deletions nightwatch/desktop/src/js/frames/auth.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,22 @@
document.querySelector("form").addEventListener("submit", async (e) => {
e.preventDefault();
(() => {
const form = document.querySelector("form"), button = document.querySelector("button[type = submit]");
form.addEventListener("submit", async (e) => {
e.preventDefault();
button.innerHTML = `<div class = "spinner-border spinner-border-sm" role = "status"></div>`;

// Handle authentication
const response = await window.__NWAUTHMETHOD__(
document.getElementById("usernameInput").value,
document.getElementById("passwordInput").value
)
if (response.code !== 200) return notifier.alert(response.data);
load_frame("welcome");
});
// Handle authentication
const response = await nightwatch[form.getAttribute("data-nightwatch-action")](
document.getElementById("usernameInput").value,
document.getElementById("passwordInput").value
)
if (response.code !== 200) {
button.innerText = "Continue";
return notifier.alert(response.data);
}

await nightwatch.authenticate();
if (nightwatch._error) return load_frame(`errors/${nightwatch._error.type}`);
load_frame("welcome");
});
document.getElementById("authServer").innerText = nightwatch.auth_server;
})();
13 changes: 12 additions & 1 deletion nightwatch/desktop/src/js/nightwatch.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,20 @@ class Nightwatch {
this.auth_server = "auth.iipython.dev";
}

// Handle servers
async add_server(server) {
try {
const info = await (await fetch(`https://${server.replace("/gateway", "/info")}`)).json();
return {
type: "success",
data: info
}
} catch (error) { return { type: "fail" }; }
}

// Handle local authentication
async authenticate() {
this.token = localStorage.getItem("token");
this.token = this.token || localStorage.getItem("token");
if (!this.token) return;

// Fetch user data
Expand Down
68 changes: 48 additions & 20 deletions nightwatch/server/__init__.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
# Copyright (c) 2024 iiPython

# Modules
from http import HTTPStatus

import anyio
import orjson
from pydantic import ValidationError
from websockets import WebSocketCommonProtocol, Headers
from websockets.exceptions import ConnectionClosedError

from starlette.requests import Request
from starlette.routing import Route, WebSocketRoute
from starlette.responses import JSONResponse, PlainTextResponse
from starlette.websockets import WebSocket
from starlette.applications import Starlette

from .utils import broadcast
from .utils.commands import registry
from .utils.constant import Constant
from .utils.websocket import NightwatchClient
Expand All @@ -19,31 +23,42 @@ class NightwatchStateManager():
def __init__(self) -> None:
self.clients = {}

def add_client(self, client: WebSocketCommonProtocol) -> None:
def add_client(self, client: WebSocket) -> None:
self.clients[client] = None

def remove_client(self, client: WebSocketCommonProtocol) -> None:
def remove_client(self, client: WebSocket) -> None:
if client in self.clients:
del self.clients[client]

state = NightwatchStateManager()

# Handle API
async def process_api(path: str, request_headers: Headers) -> tuple[HTTPStatus, list, bytes]:
if path == "/info":
return HTTPStatus.OK, [], orjson.dumps({
"name": Constant.SERVER_NAME,
"version": Constant.SERVER_VERSION,
"icon": Constant.SERVER_ICON
})
async def route_home(request: Request) -> PlainTextResponse:
return PlainTextResponse("Nightwatch is running.")

return HTTPStatus.NOT_FOUND, [], b"Not Found\n"
async def route_info(request: Request) -> JSONResponse:
return JSONResponse({
"name": Constant.SERVER_NAME,
"version": Constant.SERVER_VERSION,
"icon": Constant.SERVER_ICON
})

# Socket entrypoint
async def connection(websocket: WebSocketCommonProtocol) -> None:
async def route_gateway(websocket: WebSocket) -> None:
await websocket.accept()

client = NightwatchClient(state, websocket)
async with anyio.create_task_group() as task_group:
async def run_chatroom_ws_receiver() -> None:
await chatroom_ws_receiver(websocket, client)
task_group.cancel_scope.cancel()

task_group.start_soon(run_chatroom_ws_receiver)
await chatroom_ws_sender(websocket)

async def chatroom_ws_receiver(websocket: WebSocket, client: NightwatchClient) -> None:
try:
client = NightwatchClient(state, websocket)
async for message in websocket:
async for message in websocket.iter_text():
message = orjson.loads(message)
if message.get("type") not in registry.commands:
await client.send("error", text = "Specified command type does not exist or is missing.")
Expand All @@ -67,7 +82,20 @@ async def connection(websocket: WebSocketCommonProtocol) -> None:
except orjson.JSONDecodeError:
log.warn("ws", "Failed to decode JSON from client.")

except ConnectionClosedError:
log.info("ws", "Client disconnected.")

state.remove_client(websocket)
log.info("ws", "Client disconnected.")

async def chatroom_ws_sender(websocket: WebSocket) -> None:
async with broadcast.subscribe("general") as sub:
async for event in sub: # type: ignore
await websocket.send_text(event.message)

app = Starlette(
routes = [
Route("/", route_home),
Route("/info", route_info),
WebSocketRoute("/gateway", route_gateway)
],
on_startup = [broadcast.connect],
on_shutdown = [broadcast.disconnect]
)
12 changes: 5 additions & 7 deletions nightwatch/server/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,19 @@

# Modules
import os
import asyncio

from websockets.server import serve
import uvicorn

from . import connection, process_api
from . import app

from nightwatch import __version__
from nightwatch.logging import log

# Entrypoint
async def main() -> None:
def main() -> None:
host, port = os.getenv("HOST", "localhost"), int(os.getenv("PORT", 8000))
log.info("ws", f"Nightwatch v{__version__} running on ws://{host}:{port}/")
async with serve(connection, host, port, process_request = process_api):
await asyncio.Future()
uvicorn.run(app, host = host, port = port, log_level = "info")

if __name__ == "__main__":
asyncio.run(main())
main()
7 changes: 7 additions & 0 deletions nightwatch/server/utils/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Copyright (c) 2024 iiPython

# Modules
from broadcaster import Broadcast

# Setup broadcast connection
broadcast = Broadcast("memory://")
7 changes: 3 additions & 4 deletions nightwatch/server/utils/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,8 @@
from typing import Callable

import orjson
import websockets

from . import models
from . import models, broadcast
from .constant import Constant
from .websocket import NightwatchClient

Expand Down Expand Up @@ -42,7 +41,7 @@ async def command_identify(state, client: NightwatchClient, data: models.Identif
client.identified = True

await client.send("server", name = Constant.SERVER_NAME, online = len(state.clients))
websockets.broadcast(state.clients, orjson.dumps({
await broadcast.publish("general", orjson.dumps({
"type": "message",
"data": {"text": f"{data.name} joined the chatroom.", "user": Constant.SERVER_USER}
}).decode())
Expand All @@ -52,7 +51,7 @@ async def command_message(state, client: NightwatchClient, data: models.MessageM
if not client.identified:
return await client.send("error", text = "You must identify before sending a message.")

websockets.broadcast(state.clients, orjson.dumps({
await broadcast.publish("general", orjson.dumps({
"type": "message",
"data": {"text": data.text, "user": client.user_data}
}).decode())
Expand Down
Loading

0 comments on commit 765d06a

Please sign in to comment.