v12: group chat with optional passwords
- /group/<name> URL creates/joins a group (auto-created on first visit) - / and /chat redirect to /group/lobby (default group) - Each group has isolated history, clients, and SSE streams - /setpass <password> sets a password for the current group - /clearpass removes the password - Password prompt modal in web UI, stored in sessionStorage - SSE sends auth-fail event if wrong password, triggers re-prompt - Group name shown as tag in header - TCP clients use lobby group by default Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
405
chat.py
405
chat.py
@@ -25,7 +25,7 @@ import html
|
||||
import urllib.parse
|
||||
|
||||
PORT = 9999
|
||||
VERSION = "11"
|
||||
VERSION = "12"
|
||||
TUNNEL_TARGETS = {
|
||||
"parspack": ("185.208.174.152", 22),
|
||||
"mequ": ("188.213.68.133", 2022),
|
||||
@@ -36,9 +36,6 @@ MAX_TOTAL_STORAGE = 50 * 1024 * 1024 # 50 MB total
|
||||
|
||||
# ── Server ──────────────────────────────────────────────────────────────
|
||||
|
||||
clients: dict[asyncio.StreamWriter, str] = {} # TCP clients
|
||||
sse_queues: list[asyncio.Queue] = [] # web clients
|
||||
history: list[dict] = []
|
||||
uploaded_files: dict[str, bytes] = {} # file_id -> raw bytes (insertion order)
|
||||
total_file_bytes = 0
|
||||
|
||||
@@ -53,31 +50,52 @@ def store_file(file_id: str, data: bytes):
|
||||
total_file_bytes -= len(uploaded_files.pop(oldest_id))
|
||||
|
||||
|
||||
class Group:
|
||||
"""A chat group with its own history, clients, and optional password."""
|
||||
def __init__(self, name: str):
|
||||
self.name = name
|
||||
self.password: str | None = None
|
||||
self.history: list[dict] = []
|
||||
self.clients: dict[asyncio.StreamWriter, str] = {} # TCP clients
|
||||
self.sse_queues: list[asyncio.Queue] = []
|
||||
|
||||
async def broadcast(msg: dict):
|
||||
history.append(msg)
|
||||
line = json.dumps(msg) + "\n"
|
||||
async def broadcast(self, msg: dict):
|
||||
self.history.append(msg)
|
||||
line = json.dumps(msg) + "\n"
|
||||
|
||||
# TCP clients
|
||||
dead = []
|
||||
for w in clients:
|
||||
try:
|
||||
w.write(line.encode())
|
||||
await w.drain()
|
||||
except Exception:
|
||||
dead.append(w)
|
||||
for w in dead:
|
||||
clients.pop(w, None)
|
||||
dead = []
|
||||
for w in self.clients:
|
||||
try:
|
||||
w.write(line.encode())
|
||||
await w.drain()
|
||||
except Exception:
|
||||
dead.append(w)
|
||||
for w in dead:
|
||||
self.clients.pop(w, None)
|
||||
|
||||
# SSE web clients
|
||||
dead_q = []
|
||||
for q in sse_queues:
|
||||
try:
|
||||
q.put_nowait(msg)
|
||||
except Exception:
|
||||
dead_q.append(q)
|
||||
for q in dead_q:
|
||||
sse_queues.remove(q)
|
||||
dead_q = []
|
||||
for q in self.sse_queues:
|
||||
try:
|
||||
q.put_nowait(msg)
|
||||
except Exception:
|
||||
dead_q.append(q)
|
||||
for q in dead_q:
|
||||
self.sse_queues.remove(q)
|
||||
|
||||
|
||||
# All groups keyed by name. Auto-created on first access.
|
||||
groups: dict[str, Group] = {}
|
||||
DEFAULT_GROUP = "lobby"
|
||||
|
||||
|
||||
def get_group(name: str) -> Group:
|
||||
"""Get or create a group by name."""
|
||||
name = name.lower().strip()
|
||||
if not name:
|
||||
name = DEFAULT_GROUP
|
||||
if name not in groups:
|
||||
groups[name] = Group(name)
|
||||
return groups[name]
|
||||
|
||||
|
||||
# ── HTML / JS chat page ────────────────────────────────────────────────
|
||||
@@ -139,10 +157,23 @@ CHAT_HTML = r"""<!DOCTYPE html>
|
||||
#install-bar button { background: #e94560; border: none; color: #fff; padding: 6px 16px;
|
||||
border-radius: 4px; cursor: pointer; margin: 0 4px; }
|
||||
#install-bar .dismiss { background: transparent; color: #666; }
|
||||
#pw-overlay { display:none; position:fixed; inset:0; background:rgba(0,0,0,0.8);
|
||||
z-index:100; align-items:center; justify-content:center; }
|
||||
#pw-overlay.show { display:flex; }
|
||||
#pw-box { background:#16213e; border:1px solid #444; border-radius:8px; padding:24px;
|
||||
text-align:center; max-width:300px; width:90%; }
|
||||
#pw-box h3 { margin-bottom:12px; color:#e0e0e0; }
|
||||
#pw-box input { width:100%; padding:10px; background:#0f3460; border:1px solid #444;
|
||||
color:#e0e0e0; border-radius:4px; margin-bottom:10px; font-size:16px; }
|
||||
#pw-box button { padding:8px 20px; background:#e94560; border:none; color:#fff;
|
||||
border-radius:4px; cursor:pointer; }
|
||||
#pw-box .pw-err { color:#e94560; font-size:0.85em; margin-bottom:8px; display:none; }
|
||||
#group-tag { background:#e94560; color:#fff; padding:2px 8px; border-radius:10px;
|
||||
font-size:0.75em; margin-left:6px; }
|
||||
@media (max-width: 500px) {
|
||||
.msg { font-size: 13px; }
|
||||
.ts { font-size: 11px; }
|
||||
#input { font-size: 16px; /* prevents iOS zoom */ }
|
||||
#input { font-size: 16px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
@@ -152,9 +183,18 @@ CHAT_HTML = r"""<!DOCTYPE html>
|
||||
<button id="install-btn">Install</button>
|
||||
<button class="dismiss" id="install-dismiss">Later</button>
|
||||
</div>
|
||||
<div id="pw-overlay">
|
||||
<div id="pw-box">
|
||||
<h3>This group is password protected</h3>
|
||||
<div class="pw-err" id="pw-err">Wrong password</div>
|
||||
<input type="password" id="pw-input" placeholder="Enter password…" autocomplete="off">
|
||||
<button id="pw-btn">Join</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="header">
|
||||
<input id="name" placeholder="Name" value="" autocomplete="off">
|
||||
<span id="header-info">Shift+Enter newline</span>
|
||||
<span id="group-tag"></span>
|
||||
<span id="header-info">Shift+Enter newline · /setpass <pw> · /clearpass</span>
|
||||
</div>
|
||||
<div id="messages"></div>
|
||||
<div id="bottom">
|
||||
@@ -164,13 +204,20 @@ CHAT_HTML = r"""<!DOCTYPE html>
|
||||
<button id="send">▶</button>
|
||||
</div>
|
||||
<script>
|
||||
const GROUP = '%%GROUP%%';
|
||||
const HAS_PASSWORD = %%HAS_PASSWORD%%;
|
||||
const BASE = '/group/' + GROUP;
|
||||
|
||||
const $msg = document.getElementById('messages');
|
||||
const $input = document.getElementById('input');
|
||||
const $name = document.getElementById('name');
|
||||
const $send = document.getElementById('send');
|
||||
const $file = document.getElementById('file-input');
|
||||
|
||||
$name.value = 'user' + Math.floor(Math.random() * 1000);
|
||||
document.getElementById('group-tag').textContent = GROUP;
|
||||
document.title = GROUP + ' - Chat';
|
||||
|
||||
$name.value = localStorage.getItem('chat-name') || ('user' + Math.floor(Math.random() * 1000));
|
||||
|
||||
const USER_COLORS = [
|
||||
'#e6a23c', '#f56c9d', '#67c7eb', '#b39ddb',
|
||||
@@ -255,6 +302,7 @@ function addMsg(data) {
|
||||
function send() {
|
||||
const text = $input.value.trimEnd();
|
||||
const name = $name.value.trim() || 'anon';
|
||||
localStorage.setItem('chat-name', name);
|
||||
if (!text) return;
|
||||
// Local commands
|
||||
if (text === '/colors' || text === '/color') {
|
||||
@@ -263,7 +311,28 @@ function send() {
|
||||
$input.style.height = 'auto';
|
||||
return;
|
||||
}
|
||||
fetch('/chat/send', {
|
||||
if (text.startsWith('/setpass ')) {
|
||||
const pw = text.substring(9).trim();
|
||||
fetch(BASE + '/setpass', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: 'password=' + encodeURIComponent(pw)
|
||||
}).then(() => addMsg({ts:Date.now()/1000, user:'***', text:'Password set for this group.'}));
|
||||
$input.value = '';
|
||||
$input.style.height = 'auto';
|
||||
return;
|
||||
}
|
||||
if (text === '/clearpass') {
|
||||
fetch(BASE + '/setpass', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: 'password='
|
||||
}).then(() => addMsg({ts:Date.now()/1000, user:'***', text:'Password cleared.'}));
|
||||
$input.value = '';
|
||||
$input.style.height = 'auto';
|
||||
return;
|
||||
}
|
||||
fetch(BASE + '/send', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: 'name=' + encodeURIComponent(name) + '&text=' + encodeURIComponent(text)
|
||||
@@ -294,7 +363,7 @@ $file.onchange = function() {
|
||||
const fd = new FormData();
|
||||
fd.append('name', name);
|
||||
fd.append('file', f);
|
||||
fetch('/chat/upload', { method: 'POST', body: fd });
|
||||
fetch(BASE + '/upload', { method: 'POST', body: fd });
|
||||
this.value = '';
|
||||
};
|
||||
|
||||
@@ -333,14 +402,49 @@ window.addEventListener('focus', function() {
|
||||
document.title = baseTitle;
|
||||
});
|
||||
|
||||
// SSE
|
||||
const es = new EventSource('/chat/events');
|
||||
es.onmessage = function(e) {
|
||||
const data = JSON.parse(e.data);
|
||||
addMsg(data);
|
||||
notify(data);
|
||||
// Password gate
|
||||
let sessionPass = sessionStorage.getItem('pw-' + GROUP) || '';
|
||||
|
||||
function startSSE() {
|
||||
const url = BASE + '/events' + (sessionPass ? '?password=' + encodeURIComponent(sessionPass) : '');
|
||||
const es = new EventSource(url);
|
||||
es.onmessage = function(e) {
|
||||
// Check for auth error
|
||||
const data = JSON.parse(e.data);
|
||||
if (data._auth === 'fail') {
|
||||
es.close();
|
||||
showPasswordPrompt();
|
||||
return;
|
||||
}
|
||||
addMsg(data);
|
||||
notify(data);
|
||||
};
|
||||
es.onerror = function() {
|
||||
addMsg({ts: Date.now()/1000, user: '***', text: 'Connection lost. Retrying…'});
|
||||
};
|
||||
}
|
||||
|
||||
function showPasswordPrompt() {
|
||||
document.getElementById('pw-overlay').classList.add('show');
|
||||
document.getElementById('pw-input').focus();
|
||||
}
|
||||
|
||||
document.getElementById('pw-btn').onclick = function() {
|
||||
sessionPass = document.getElementById('pw-input').value;
|
||||
sessionStorage.setItem('pw-' + GROUP, sessionPass);
|
||||
document.getElementById('pw-overlay').classList.remove('show');
|
||||
startSSE();
|
||||
};
|
||||
es.onerror = function() { addMsg({ts: Date.now()/1000, user: '***', text: 'Connection lost. Retrying…'}); };
|
||||
document.getElementById('pw-input').onkeydown = function(e) {
|
||||
if (e.key === 'Enter') document.getElementById('pw-btn').click();
|
||||
};
|
||||
|
||||
// Check password requirement and start
|
||||
if (HAS_PASSWORD && !sessionPass) {
|
||||
showPasswordPrompt();
|
||||
} else {
|
||||
startSSE();
|
||||
}
|
||||
|
||||
// PWA install prompt
|
||||
let deferredPrompt = null;
|
||||
@@ -365,18 +469,10 @@ if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js').catch(function(){});
|
||||
}
|
||||
|
||||
// Mobile keyboard handling: scroll to bottom when keyboard opens
|
||||
if (/Mobi|Android|iPhone/i.test(navigator.userAgent)) {
|
||||
window.visualViewport && window.visualViewport.addEventListener('resize', function() {
|
||||
$msg.scrollTop = $msg.scrollHeight;
|
||||
});
|
||||
// Save name to localStorage
|
||||
const savedName = localStorage.getItem('chat-name');
|
||||
if (savedName) $name.value = savedName;
|
||||
$name.addEventListener('change', function() {
|
||||
localStorage.setItem('chat-name', this.value);
|
||||
});
|
||||
}
|
||||
// Mobile
|
||||
window.visualViewport && window.visualViewport.addEventListener('resize', function() {
|
||||
$msg.scrollTop = $msg.scrollHeight;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -626,86 +722,128 @@ async def handle_http(reader, writer, first_line):
|
||||
writer.close()
|
||||
return
|
||||
|
||||
# GET / or /chat — serve the web UI
|
||||
# GET / or /chat → redirect to /group/lobby
|
||||
if method == "GET" and path in ("/", "/chat", "/chat/"):
|
||||
resp = CHAT_HTML.encode()
|
||||
writer.write(b"HTTP/1.1 200 OK\r\n")
|
||||
writer.write(b"Content-Type: text/html; charset=utf-8\r\n")
|
||||
writer.write(f"Content-Length: {len(resp)}\r\n".encode())
|
||||
writer.write(b"\r\n")
|
||||
writer.write(resp)
|
||||
writer.write(b"HTTP/1.1 302 Found\r\nLocation: /group/lobby\r\nContent-Length: 0\r\n\r\n")
|
||||
await writer.drain()
|
||||
writer.close()
|
||||
return
|
||||
|
||||
# GET /chat/events — SSE stream
|
||||
if method == "GET" and path == "/chat/events":
|
||||
writer.write(b"HTTP/1.1 200 OK\r\n")
|
||||
writer.write(b"Content-Type: text/event-stream\r\n")
|
||||
writer.write(b"Cache-Control: no-cache\r\n")
|
||||
writer.write(b"Connection: keep-alive\r\n")
|
||||
writer.write(b"X-Accel-Buffering: no\r\n")
|
||||
writer.write(b"Access-Control-Allow-Origin: *\r\n")
|
||||
writer.write(b"\r\n")
|
||||
await writer.drain()
|
||||
# ── Group routes: /group/<name>[/action] ──
|
||||
if path.startswith("/group/"):
|
||||
parts = path[7:].strip("/").split("/", 1) # strip "/group/"
|
||||
group_name = urllib.parse.unquote(parts[0]) if parts[0] else DEFAULT_GROUP
|
||||
action = parts[1] if len(parts) > 1 else ""
|
||||
grp = get_group(group_name)
|
||||
|
||||
for msg in history:
|
||||
writer.write(f"data: {json.dumps(msg)}\n\n".encode())
|
||||
await writer.drain()
|
||||
|
||||
q: asyncio.Queue = asyncio.Queue()
|
||||
sse_queues.append(q)
|
||||
try:
|
||||
while True:
|
||||
msg = await q.get()
|
||||
writer.write(f"data: {json.dumps(msg)}\n\n".encode())
|
||||
await writer.drain()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
if q in sse_queues:
|
||||
sse_queues.remove(q)
|
||||
# GET /group/<name> — serve the web UI
|
||||
if method == "GET" and action == "":
|
||||
has_pw = "true" if grp.password else "false"
|
||||
resp = CHAT_HTML.replace("%%GROUP%%", group_name).replace("%%HAS_PASSWORD%%", has_pw).encode()
|
||||
writer.write(b"HTTP/1.1 200 OK\r\n")
|
||||
writer.write(b"Content-Type: text/html; charset=utf-8\r\n")
|
||||
writer.write(f"Content-Length: {len(resp)}\r\n".encode())
|
||||
writer.write(b"\r\n")
|
||||
writer.write(resp)
|
||||
await writer.drain()
|
||||
writer.close()
|
||||
return
|
||||
return
|
||||
|
||||
# POST /chat/send — send a message (supports multiline via JSON text field)
|
||||
if method == "POST" and path == "/chat/send":
|
||||
params = urllib.parse.parse_qs(body.decode())
|
||||
name = params.get("name", ["anon"])[0]
|
||||
text = params.get("text", [""])[0].strip()
|
||||
if text:
|
||||
await broadcast({"ts": time.time(), "user": name, "text": text})
|
||||
writer.write(b"HTTP/1.1 204 No Content\r\n\r\n")
|
||||
await writer.drain()
|
||||
writer.close()
|
||||
return
|
||||
# GET /group/<name>/events — SSE stream
|
||||
if method == "GET" and action.startswith("events"):
|
||||
# Check password
|
||||
query = ""
|
||||
if "?" in action:
|
||||
query = action.split("?", 1)[1]
|
||||
qs = urllib.parse.parse_qs(query)
|
||||
pw = qs.get("password", [""])[0]
|
||||
if grp.password and pw != grp.password:
|
||||
writer.write(b"HTTP/1.1 200 OK\r\n")
|
||||
writer.write(b"Content-Type: text/event-stream\r\n")
|
||||
writer.write(b"Cache-Control: no-cache\r\n")
|
||||
writer.write(b"X-Accel-Buffering: no\r\n")
|
||||
writer.write(b"\r\n")
|
||||
writer.write(f"data: {json.dumps({'_auth': 'fail'})}\n\n".encode())
|
||||
await writer.drain()
|
||||
writer.close()
|
||||
return
|
||||
|
||||
# POST /chat/upload — file upload (multipart/form-data)
|
||||
if method == "POST" and path == "/chat/upload":
|
||||
ct = headers.get("content-type", "")
|
||||
if "multipart/form-data" in ct and "boundary=" in ct:
|
||||
boundary = ct.split("boundary=")[1].strip()
|
||||
fields = parse_multipart(body, boundary)
|
||||
name = fields.get("name", (None, b"anon"))[1]
|
||||
if isinstance(name, bytes):
|
||||
name = name.decode()
|
||||
file_entry = fields.get("file")
|
||||
if file_entry and file_entry[0]:
|
||||
filename = file_entry[0]
|
||||
file_data = file_entry[1]
|
||||
if len(file_data) <= MAX_FILE_SIZE:
|
||||
file_id = hashlib.sha256(file_data + str(time.time()).encode()).hexdigest()[:16]
|
||||
store_file(file_id, file_data)
|
||||
await broadcast({
|
||||
"ts": time.time(), "user": name,
|
||||
"text": f"[file: {filename}]",
|
||||
"file_id": file_id, "filename": filename,
|
||||
"file_size": len(file_data)
|
||||
})
|
||||
writer.write(b"HTTP/1.1 204 No Content\r\n\r\n")
|
||||
await writer.drain()
|
||||
writer.close()
|
||||
return
|
||||
writer.write(b"HTTP/1.1 200 OK\r\n")
|
||||
writer.write(b"Content-Type: text/event-stream\r\n")
|
||||
writer.write(b"Cache-Control: no-cache\r\n")
|
||||
writer.write(b"Connection: keep-alive\r\n")
|
||||
writer.write(b"X-Accel-Buffering: no\r\n")
|
||||
writer.write(b"Access-Control-Allow-Origin: *\r\n")
|
||||
writer.write(b"\r\n")
|
||||
await writer.drain()
|
||||
|
||||
for msg in grp.history:
|
||||
writer.write(f"data: {json.dumps(msg)}\n\n".encode())
|
||||
await writer.drain()
|
||||
|
||||
q: asyncio.Queue = asyncio.Queue()
|
||||
grp.sse_queues.append(q)
|
||||
try:
|
||||
while True:
|
||||
msg = await q.get()
|
||||
writer.write(f"data: {json.dumps(msg)}\n\n".encode())
|
||||
await writer.drain()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
if q in grp.sse_queues:
|
||||
grp.sse_queues.remove(q)
|
||||
writer.close()
|
||||
return
|
||||
|
||||
# POST /group/<name>/send
|
||||
if method == "POST" and action == "send":
|
||||
params = urllib.parse.parse_qs(body.decode())
|
||||
name = params.get("name", ["anon"])[0]
|
||||
text = params.get("text", [""])[0].strip()
|
||||
if text:
|
||||
await grp.broadcast({"ts": time.time(), "user": name, "text": text})
|
||||
writer.write(b"HTTP/1.1 204 No Content\r\n\r\n")
|
||||
await writer.drain()
|
||||
writer.close()
|
||||
return
|
||||
|
||||
# POST /group/<name>/upload
|
||||
if method == "POST" and action == "upload":
|
||||
ct = headers.get("content-type", "")
|
||||
if "multipart/form-data" in ct and "boundary=" in ct:
|
||||
boundary = ct.split("boundary=")[1].strip()
|
||||
fields = parse_multipart(body, boundary)
|
||||
name = fields.get("name", (None, b"anon"))[1]
|
||||
if isinstance(name, bytes):
|
||||
name = name.decode()
|
||||
file_entry = fields.get("file")
|
||||
if file_entry and file_entry[0]:
|
||||
filename = file_entry[0]
|
||||
file_data = file_entry[1]
|
||||
if len(file_data) <= MAX_FILE_SIZE:
|
||||
file_id = hashlib.sha256(file_data + str(time.time()).encode()).hexdigest()[:16]
|
||||
store_file(file_id, file_data)
|
||||
await grp.broadcast({
|
||||
"ts": time.time(), "user": name,
|
||||
"text": f"[file: {filename}]",
|
||||
"file_id": file_id, "filename": filename,
|
||||
"file_size": len(file_data)
|
||||
})
|
||||
writer.write(b"HTTP/1.1 204 No Content\r\n\r\n")
|
||||
await writer.drain()
|
||||
writer.close()
|
||||
return
|
||||
|
||||
# POST /group/<name>/setpass
|
||||
if method == "POST" and action == "setpass":
|
||||
params = urllib.parse.parse_qs(body.decode(), keep_blank_values=True)
|
||||
pw = params.get("password", [""])[0].strip()
|
||||
grp.password = pw if pw else None
|
||||
writer.write(b"HTTP/1.1 204 No Content\r\n\r\n")
|
||||
await writer.drain()
|
||||
writer.close()
|
||||
return
|
||||
|
||||
# GET /files/<id>/<filename> — download uploaded file
|
||||
if method == "GET" and path.startswith("/files/"):
|
||||
@@ -779,16 +917,17 @@ async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
|
||||
await handle_http(reader, writer, first_line)
|
||||
return
|
||||
|
||||
# Raw TCP chat client — first line is the name
|
||||
# Raw TCP chat client — first line is the name (uses lobby group)
|
||||
name = first_line
|
||||
clients[writer] = name
|
||||
grp = get_group(DEFAULT_GROUP)
|
||||
grp.clients[writer] = name
|
||||
|
||||
for msg in history:
|
||||
for msg in grp.history:
|
||||
writer.write((json.dumps(msg) + "\n").encode())
|
||||
await writer.drain()
|
||||
|
||||
await broadcast({"ts": time.time(), "user": "***", "text": f"{name} joined"})
|
||||
print(f"+ {name} connected ({len(clients)} online)")
|
||||
await grp.broadcast({"ts": time.time(), "user": "***", "text": f"{name} joined"})
|
||||
print(f"+ {name} connected ({len(grp.clients)} online in {grp.name})")
|
||||
|
||||
try:
|
||||
while True:
|
||||
@@ -798,7 +937,6 @@ async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
|
||||
line = data.decode().rstrip("\n")
|
||||
if not line:
|
||||
continue
|
||||
# Try JSON (new protocol: supports multiline + files)
|
||||
try:
|
||||
pkt = json.loads(line)
|
||||
if pkt.get("type") == "file":
|
||||
@@ -807,7 +945,7 @@ async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
|
||||
continue
|
||||
file_id = hashlib.sha256(file_data + str(time.time()).encode()).hexdigest()[:16]
|
||||
store_file(file_id, file_data)
|
||||
await broadcast({
|
||||
await grp.broadcast({
|
||||
"ts": time.time(), "user": name,
|
||||
"text": f"[file: {pkt['filename']}]",
|
||||
"file_id": file_id, "filename": pkt["filename"],
|
||||
@@ -816,17 +954,16 @@ async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
|
||||
else:
|
||||
text = pkt.get("text", "").strip()
|
||||
if text:
|
||||
await broadcast({"ts": time.time(), "user": name, "text": text})
|
||||
await grp.broadcast({"ts": time.time(), "user": name, "text": text})
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
# Legacy plain text (single line, backwards compat)
|
||||
if line.strip():
|
||||
await broadcast({"ts": time.time(), "user": name, "text": line.strip()})
|
||||
await grp.broadcast({"ts": time.time(), "user": name, "text": line.strip()})
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
clients.pop(writer, None)
|
||||
await broadcast({"ts": time.time(), "user": "***", "text": f"{name} left"})
|
||||
print(f"- {name} disconnected ({len(clients)} online)")
|
||||
grp.clients.pop(writer, None)
|
||||
await grp.broadcast({"ts": time.time(), "user": "***", "text": f"{name} left"})
|
||||
print(f"- {name} disconnected ({len(grp.clients)} online in {grp.name})")
|
||||
writer.close()
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user