436 lines
17 KiB
Python
Executable File
436 lines
17 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Multi-room chat met Server-Sent Events. Stdlib only, single file, 3.6+.
|
|
|
|
Run:
|
|
python3 sse-chat.py --port 8082
|
|
|
|
Open http://127.0.0.1:8082/ in twee browsers en chat. Geen JS-deps:
|
|
alleen EventSource + fetch.
|
|
|
|
Spec (dit bestand is de spec):
|
|
|
|
Endpoint Method Statusen Beschrijving
|
|
/api/rooms GET 200 Kamers + aantallen
|
|
/api/rooms POST 201/400 Kamer aanmaken {"room"}
|
|
/api/rooms/{room}/messages GET 200/404 History (?since=&limit=)
|
|
/api/rooms/{room}/messages POST 201/400/404 Bericht {"nick","text"}
|
|
/events?room={room} GET 200/404 SSE-stream (id/event/retry)
|
|
/ GET 200 Embedded UI
|
|
/healthz GET 200 {"ok":true,...}
|
|
|
|
SSE-events: "message" (JSON-bericht), "hello" (laatste id bij connect).
|
|
Reconnect: Last-Event-ID-header of ?since= wordt gerespecteerd, gemiste
|
|
berichten worden nagezonden. Heartbeat: ": ping" elke 15 s.
|
|
|
|
Kamernamen: ^[a-z0-9-]{1,32}$. Standaardkamers: lobby, random, tech.
|
|
|
|
Caps: MAX_ROOMS 50, MAX_HISTORY 200/room, MAX_NICK 32, MAX_TEXT 2000,
|
|
MAX_BODY 8 KiB, HEARTBEAT 15 s.
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import socketserver
|
|
import threading
|
|
import time
|
|
from collections import deque
|
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
try:
|
|
from queue import Queue, Empty
|
|
except ImportError: # Python 2 fallback, nooit gebruikt maar goedkoop
|
|
from Queue import Queue, Empty
|
|
|
|
ROOM_RE = re.compile(r"^[a-z0-9-]{1,32}$")
|
|
|
|
|
|
class ChatError(Exception):
|
|
def __init__(self, status, message):
|
|
super(ChatError, self).__init__(message)
|
|
self.status = status
|
|
self.message = message
|
|
|
|
|
|
class ChatStore(object):
|
|
"""Kamers, history en SSE-abonnees. Alles achter een RLock."""
|
|
|
|
MAX_ROOMS = 50
|
|
MAX_HISTORY = 200
|
|
MAX_SUBSCRIBERS = 500
|
|
DEFAULT_ROOMS = ("lobby", "random", "tech")
|
|
|
|
def __init__(self):
|
|
self._lock = threading.RLock()
|
|
self._rooms = {}
|
|
self._subs = {}
|
|
for room in self.DEFAULT_ROOMS:
|
|
self._rooms[room] = {"messages": deque(maxlen=self.MAX_HISTORY),
|
|
"next_id": 1}
|
|
|
|
@staticmethod
|
|
def _now():
|
|
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
|
|
def rooms(self):
|
|
with self._lock:
|
|
return [{"room": name, "messages": len(info["messages"])}
|
|
for name, info in sorted(self._rooms.items())]
|
|
|
|
def create_room(self, room):
|
|
if not isinstance(room, str) or not ROOM_RE.match(room):
|
|
raise ChatError(400, "bad room (want [a-z0-9-]{1,32})")
|
|
with self._lock:
|
|
if room in self._rooms:
|
|
raise ChatError(409, "room exists")
|
|
if len(self._rooms) >= self.MAX_ROOMS:
|
|
raise ChatError(507, "too many rooms")
|
|
self._rooms[room] = {"messages": deque(maxlen=self.MAX_HISTORY),
|
|
"next_id": 1}
|
|
return {"room": room, "messages": 0}
|
|
|
|
def _require(self, room):
|
|
if room not in self._rooms:
|
|
raise ChatError(404, "no such room")
|
|
return self._rooms[room]
|
|
|
|
def history(self, room, since=0, limit=100):
|
|
with self._lock:
|
|
info = self._require(room)
|
|
items = [m for m in info["messages"] if m["id"] > since]
|
|
return items[-limit:]
|
|
|
|
def post(self, room, nick, text):
|
|
if not isinstance(nick, str) or not 1 <= len(nick.strip()) <= 32:
|
|
raise ChatError(400, "nick must be 1..32 chars")
|
|
if not isinstance(text, str) or not 1 <= len(text.strip()) <= 2000:
|
|
raise ChatError(400, "text must be 1..2000 chars")
|
|
with self._lock:
|
|
info = self._require(room)
|
|
msg = {"id": info["next_id"], "room": room,
|
|
"nick": nick.strip()[:32], "text": text.strip()[:2000],
|
|
"ts": self._now()}
|
|
info["next_id"] += 1
|
|
info["messages"].append(msg)
|
|
subs = list(self._subs.get(room, []))
|
|
for queue in subs:
|
|
try:
|
|
queue.put_nowait(msg)
|
|
except Exception:
|
|
pass
|
|
return msg
|
|
|
|
def subscribe(self, room):
|
|
with self._lock:
|
|
self._require(room)
|
|
total = sum(len(v) for v in self._subs.values())
|
|
if total >= self.MAX_SUBSCRIBERS:
|
|
raise ChatError(503, "too many subscribers")
|
|
queue = Queue(maxsize=200)
|
|
self._subs.setdefault(room, []).append(queue)
|
|
return queue
|
|
|
|
def unsubscribe(self, room, queue):
|
|
with self._lock:
|
|
subs = self._subs.get(room, [])
|
|
if queue in subs:
|
|
subs.remove(queue)
|
|
|
|
|
|
class ChatHandler(BaseHTTPRequestHandler):
|
|
server_version = "TaiChat/1.0"
|
|
protocol_version = "HTTP/1.1"
|
|
MAX_BODY = 8 * 1024
|
|
HEARTBEAT = 15
|
|
|
|
def log_message(self, fmt, *args):
|
|
pass
|
|
|
|
def _send(self, code, body=b"", ctype="text/plain; charset=utf-8"):
|
|
if isinstance(body, str):
|
|
body = body.encode("utf-8")
|
|
self.send_response(code)
|
|
self.send_header("Content-Type", ctype)
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
if self.command != "HEAD" and body:
|
|
self.wfile.write(body)
|
|
|
|
def _json(self, code, obj):
|
|
self._send(code, json.dumps(obj), "application/json")
|
|
|
|
def _read_json(self):
|
|
try:
|
|
length = int(self.headers.get("Content-Length", "0") or "0")
|
|
except ValueError:
|
|
raise ChatError(400, "bad Content-Length")
|
|
if length < 0 or length > self.MAX_BODY:
|
|
raise ChatError(413, "body too large")
|
|
raw = self.rfile.read(length) if length else b""
|
|
try:
|
|
data = json.loads((raw or b"{}").decode("utf-8"))
|
|
except (UnicodeDecodeError, ValueError):
|
|
raise ChatError(400, "body is not valid JSON")
|
|
if not isinstance(data, dict):
|
|
raise ChatError(400, "body must be a JSON object")
|
|
return data
|
|
|
|
def _events(self, room, since):
|
|
store = self.server.chat # type: ChatStore
|
|
try:
|
|
queue = store.subscribe(room)
|
|
except ChatError as exc:
|
|
self._json(exc.status, {"error": exc.message})
|
|
return
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "text/event-stream")
|
|
self.send_header("Cache-Control", "no-cache")
|
|
self.send_header("Connection", "keep-alive")
|
|
self.send_header("X-Accel-Buffering", "no")
|
|
self.end_headers()
|
|
try:
|
|
self._sse("hello", {"room": room, "since": since})
|
|
for msg in store.history(room, since=since, limit=200):
|
|
self._sse("message", msg, msg["id"])
|
|
while True:
|
|
try:
|
|
msg = queue.get(timeout=self.HEARTBEAT)
|
|
except Empty:
|
|
self.wfile.write(b": ping\n\n")
|
|
self.wfile.flush()
|
|
continue
|
|
if msg["id"] <= since:
|
|
continue
|
|
since = msg["id"]
|
|
self._sse("message", msg, msg["id"])
|
|
except (BrokenPipeError, ConnectionResetError, OSError):
|
|
pass
|
|
finally:
|
|
store.unsubscribe(room, queue)
|
|
|
|
def _sse(self, event, obj, msg_id=None):
|
|
lines = ["event: %s" % event]
|
|
if msg_id is not None:
|
|
lines.append("id: %d" % msg_id)
|
|
lines.append("retry: 3000")
|
|
for chunk in json.dumps(obj).split("\n"):
|
|
lines.append("data: %s" % chunk)
|
|
self.wfile.write(("\n".join(lines) + "\n\n").encode("utf-8"))
|
|
self.wfile.flush()
|
|
|
|
def _handle(self):
|
|
store = self.server.chat # type: ChatStore
|
|
parsed = urlparse(self.path)
|
|
parts = [p for p in parsed.path.split("/") if p]
|
|
method = self.command
|
|
if not parts:
|
|
if method != "GET":
|
|
raise ChatError(405, "method not allowed")
|
|
self._send(200, CHAT_HTML, "text/html; charset=utf-8")
|
|
return
|
|
if parts == ["healthz"]:
|
|
rooms = store.rooms()
|
|
self._json(200, {"ok": True, "rooms": len(rooms),
|
|
"messages": sum(r["messages"] for r in rooms)})
|
|
return
|
|
if parts == ["events"] and method == "GET":
|
|
qs = parse_qs(parsed.query)
|
|
room = qs.get("room", ["lobby"])[0]
|
|
since = qs.get("since", ["0"])[0]
|
|
last_id = self.headers.get("Last-Event-ID", "").strip()
|
|
if last_id.isdigit():
|
|
since = last_id
|
|
try:
|
|
since = max(0, int(since))
|
|
except ValueError:
|
|
raise ChatError(400, "bad since")
|
|
self._events(room, since)
|
|
return
|
|
if parts[0] != "api" or len(parts) < 2 or parts[1] != "rooms":
|
|
raise ChatError(404, "unknown endpoint")
|
|
if len(parts) == 2:
|
|
if method == "GET":
|
|
self._json(200, {"rooms": store.rooms()})
|
|
elif method == "POST":
|
|
data = self._read_json()
|
|
self._json(201, store.create_room(data.get("room", "")))
|
|
else:
|
|
raise ChatError(405, "method not allowed")
|
|
return
|
|
room = parts[2]
|
|
if len(parts) == 4 and parts[3] == "messages":
|
|
if method == "GET":
|
|
qs = parse_qs(parsed.query)
|
|
try:
|
|
since = max(0, int(qs.get("since", ["0"])[0]))
|
|
limit = min(max(int(qs.get("limit", ["100"])[0]), 1), 200)
|
|
except ValueError:
|
|
raise ChatError(400, "bad since/limit")
|
|
self._json(200, {"room": room, "messages": store.history(
|
|
room, since=since, limit=limit)})
|
|
elif method == "POST":
|
|
data = self._read_json()
|
|
self._json(201, store.post(room, data.get("nick", ""),
|
|
data.get("text", "")))
|
|
else:
|
|
raise ChatError(405, "method not allowed")
|
|
return
|
|
raise ChatError(404, "unknown endpoint")
|
|
|
|
def _dispatch(self):
|
|
try:
|
|
self._handle()
|
|
except ChatError as exc:
|
|
self._json(exc.status, {"error": exc.message})
|
|
except BrokenPipeError:
|
|
pass
|
|
|
|
do_GET = _dispatch
|
|
do_HEAD = _dispatch
|
|
do_POST = _dispatch
|
|
|
|
|
|
CHAT_HTML = """<!DOCTYPE html>
|
|
<html lang="nl"><head><meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>Chat</title>
|
|
<style>
|
|
*{box-sizing:border-box}
|
|
body{font-family:system-ui,-apple-system,"Segoe UI",sans-serif;margin:0;
|
|
color:#1a1a1a;display:flex;height:100vh}
|
|
aside{width:12em;flex-shrink:0;border-right:1px solid #ddd;padding:1em;
|
|
overflow-y:auto}
|
|
aside h2{font-size:.85em;text-transform:uppercase;letter-spacing:.04em;
|
|
color:#666;margin:0 0 .6em}
|
|
aside ul{list-style:none;margin:0;padding:0}
|
|
aside li button{width:100%;text-align:left;padding:.5em .6em;border:none;
|
|
background:none;font-size:1em;border-radius:6px;cursor:pointer}
|
|
aside li button[aria-current=true]{background:#1a1a1a;color:#fff}
|
|
aside form{display:flex;gap:.4em;margin-top:.8em}
|
|
aside form input{flex:1;min-width:0;padding:.4em;font-size:.9em;
|
|
border:1px solid #ddd;border-radius:6px}
|
|
main{flex:1;display:flex;flex-direction:column;min-width:0}
|
|
header{padding:.7em 1em;border-bottom:1px solid #ddd;display:flex;
|
|
align-items:center;gap:.6em}
|
|
header h1{font-size:1.1em;margin:0}
|
|
header .status{font-size:.8em;color:#666;margin-left:auto}
|
|
.roomselect{display:none;padding:.6em 1em;border-bottom:1px solid #ddd}
|
|
.roomselect select{width:100%;padding:.5em;font-size:1em}
|
|
#log{flex:1;overflow-y:auto;padding:1em;margin:0;list-style:none}
|
|
#log li{margin-bottom:.55em;overflow-wrap:break-word}
|
|
#log .nick{font-weight:700}
|
|
#log .ts{color:#888;font-size:.8em;margin-left:.5em}
|
|
#log .sys{color:#666;font-style:italic}
|
|
form.send{display:flex;gap:.5em;padding:.8em;border-top:1px solid #ddd}
|
|
form.send input{padding:.6em;font-size:1em;border:1px solid #ddd;
|
|
border-radius:6px}
|
|
form.send #nick{width:7em;flex-shrink:0}
|
|
form.send #text{flex:1;min-width:0}
|
|
@media(max-width:40em){
|
|
aside{display:none}
|
|
.roomselect{display:block}
|
|
form.send #nick{width:5em}
|
|
}
|
|
</style></head><body>
|
|
<aside><h2>Kamers</h2><ul id="rooms"></ul>
|
|
<form id="newroom"><input id="roomname" maxlength="32" placeholder="nieuwe-kamer"
|
|
pattern="[a-z0-9-]{1,32}"><button type="submit">+</button></form></aside>
|
|
<main>
|
|
<header><h1 id="title">#lobby</h1><span class="status" id="status">verbinden...</span></header>
|
|
<div class="roomselect"><select id="roompick" aria-label="Kamer"></select></div>
|
|
<ul id="log"></ul>
|
|
<form class="send" id="send"><input id="nick" maxlength="32" placeholder="naam"
|
|
autocomplete="off"><input id="text" maxlength="2000" placeholder="bericht..."
|
|
autocomplete="off"><button type="submit">Stuur</button></form>
|
|
</main>
|
|
<script>
|
|
"use strict";
|
|
var room="lobby",es=null,lastId=0;
|
|
function esc(s){return s.replace(/[&<>"]/g,function(c){
|
|
return {"&":"&","<":"<",">":">",'"':"""}[c];});}
|
|
function addMsg(m){
|
|
lastId=Math.max(lastId,m.id);
|
|
var li=document.createElement("li");
|
|
var d=new Date(m.ts.replace("Z","+00:00"));
|
|
var t=("0"+d.getHours()).slice(-2)+":"+("0"+d.getMinutes()).slice(-2);
|
|
li.innerHTML='<span class="nick">'+esc(m.nick)+"</span> "+
|
|
esc(m.text)+'<span class="ts">'+t+"</span>";
|
|
var log=document.getElementById("log");
|
|
log.appendChild(li);log.scrollTop=log.scrollHeight;}
|
|
function sys(t){var li=document.createElement("li");
|
|
li.innerHTML='<span class="sys">'+esc(t)+"</span>";
|
|
var log=document.getElementById("log");
|
|
log.appendChild(li);log.scrollTop=log.scrollHeight;}
|
|
function connect(){
|
|
if(es)es.close();
|
|
document.getElementById("log").innerHTML="";lastId=0;
|
|
document.getElementById("status").textContent="verbinden...";
|
|
es=new EventSource("events?room="+encodeURIComponent(room));
|
|
es.addEventListener("hello",function(){
|
|
document.getElementById("status").textContent="live";});
|
|
es.addEventListener("message",function(e){addMsg(JSON.parse(e.data));});
|
|
es.onerror=function(){
|
|
document.getElementById("status").textContent="opnieuw verbinden...";};}
|
|
function loadRooms(){
|
|
fetch("api/rooms").then(function(r){return r.json();}).then(function(d){
|
|
var ul=document.getElementById("rooms");ul.innerHTML="";
|
|
var pick=document.getElementById("roompick");pick.innerHTML="";
|
|
d.rooms.forEach(function(r){
|
|
var li=document.createElement("li");
|
|
var b=document.createElement("button");b.textContent="#"+r.room;
|
|
b.setAttribute("aria-current",r.room===room?"true":"false");
|
|
b.addEventListener("click",function(){setRoom(r.room);});
|
|
li.appendChild(b);ul.appendChild(li);
|
|
var o=document.createElement("option");o.value=r.room;
|
|
o.textContent="#"+r.room+" ("+r.messages+")";
|
|
if(r.room===room)o.selected=true;pick.appendChild(o);});});}
|
|
function setRoom(r){room=r;
|
|
document.getElementById("title").textContent="#"+r;connect();loadRooms();}
|
|
document.getElementById("roompick").addEventListener("change",function(e){
|
|
setRoom(e.target.value);});
|
|
document.getElementById("newroom").addEventListener("submit",function(e){
|
|
e.preventDefault();var i=document.getElementById("roomname");
|
|
if(!i.value)return;
|
|
fetch("api/rooms",{method:"POST",headers:{"Content-Type":"application/json"},
|
|
body:JSON.stringify({room:i.value})}).then(function(r){
|
|
if(r.ok){i.value="";loadRooms();}else{r.json().then(function(d){
|
|
alert(d.error);});}});});
|
|
document.getElementById("send").addEventListener("submit",function(e){
|
|
e.preventDefault();
|
|
var n=document.getElementById("nick"),t=document.getElementById("text");
|
|
if(!n.value.trim()||!t.value.trim())return;
|
|
fetch("api/rooms/"+encodeURIComponent(room)+"/messages",{method:"POST",
|
|
headers:{"Content-Type":"application/json"},
|
|
body:JSON.stringify({nick:n.value,text:t.value})}).then(function(r){
|
|
if(r.ok){t.value="";}else{r.json().then(function(d){sys(d.error);});}});});
|
|
loadRooms();connect();
|
|
setInterval(loadRooms,30000);
|
|
</script></body></html>
|
|
"""
|
|
|
|
|
|
class ChatServer(socketserver.ThreadingMixIn, HTTPServer):
|
|
daemon_threads = True
|
|
allow_reuse_address = True
|
|
|
|
|
|
def main(argv=None):
|
|
parser = argparse.ArgumentParser(
|
|
description="Multi-room SSE chat (stdlib only).")
|
|
parser.add_argument("--host", default="127.0.0.1")
|
|
parser.add_argument("--port", type=int, default=8082)
|
|
args = parser.parse_args(argv)
|
|
server = ChatServer((args.host, args.port), ChatHandler)
|
|
server.chat = ChatStore()
|
|
print("chat: http://%s:%d/" % (args.host, args.port))
|
|
try:
|
|
server.serve_forever()
|
|
except KeyboardInterrupt:
|
|
pass
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|
|
|