Add ATC Team chat, soft identity, and update Present decks.
Ship team/DM chat with files, avatars, unread alerts, and a first-open identity gate (including Guest); fold Team into Ops desk; clarify Datacenter Engineer vs "Data Plumbers" FDE roles; refresh Present story/tech slides to match. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+519
-13
@@ -9,13 +9,15 @@ import sqlite3
|
||||
import ipaddress
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
import shutil
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import asyncssh
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Request
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Request, UploadFile, File, Form
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, Response, StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -2669,34 +2671,47 @@ OPS_USERS = [
|
||||
{
|
||||
"id": "jody",
|
||||
"name": "Jody van Dongen",
|
||||
"role": "ATC Datacenter Admin",
|
||||
"short": "Jody",
|
||||
"role": "Datacenter Engineer",
|
||||
"team": "admin",
|
||||
"email": "jody.van.dongen@dell.com",
|
||||
"focus": "OME fleet · racks · warranty / compliance",
|
||||
"focus": "Storage · servers · network · rack & stack · installs",
|
||||
},
|
||||
{
|
||||
"id": "laurens",
|
||||
"name": "Laurens Rammers",
|
||||
"role": "ATC Datacenter Admin",
|
||||
"short": "Laurens",
|
||||
"role": "Datacenter Engineer",
|
||||
"team": "admin",
|
||||
"email": "laurens.rammers@dell.com",
|
||||
"focus": "Datacenter ops · handoffs · escalation",
|
||||
"focus": "Storage · servers · network · rack & stack · installs",
|
||||
},
|
||||
{
|
||||
"id": "mo",
|
||||
"name": "Mohamed El Kadi",
|
||||
"short": "Mo",
|
||||
"role": "Data Forward Deployed Engineer",
|
||||
"team": "fde",
|
||||
"email": "mohamed.el.kadi@dell.com",
|
||||
"focus": "OME Cockpit · OpenManage AI · AI workloads on FDE cluster",
|
||||
"focus": "\"Data Plumbers\" 😉 · OME Cockpit · OpenManage AI · AI workloads on FDE cluster",
|
||||
},
|
||||
{
|
||||
"id": "bart",
|
||||
"name": "Bart Sjerps",
|
||||
"short": "Bart",
|
||||
"role": "Data Forward Deployed Engineer",
|
||||
"team": "fde",
|
||||
"email": "bart.sjerps@dell.com",
|
||||
"focus": "FDE cluster · AI workload deployment with Mo",
|
||||
"focus": "\"Data Plumbers\" 😉 · FDE cluster · AI workload deployment with Mo",
|
||||
},
|
||||
{
|
||||
"id": "guest",
|
||||
"name": "Guest",
|
||||
"short": "Guest",
|
||||
"role": "Visitor",
|
||||
"team": "guest",
|
||||
"email": "",
|
||||
"focus": "Temporary access · pick your name next time",
|
||||
},
|
||||
]
|
||||
# Back-compat alias used by older ticket endpoints / UI
|
||||
@@ -2891,12 +2906,43 @@ def init_db():
|
||||
created_at REAL NOT NULL,
|
||||
updated_at REAL NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS chat_rooms (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
kind TEXT NOT NULL,
|
||||
key TEXT NOT NULL UNIQUE,
|
||||
title TEXT,
|
||||
created_at REAL NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS chat_files (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
room_id INTEGER NOT NULL REFERENCES chat_rooms(id) ON DELETE CASCADE,
|
||||
uploader_id TEXT NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
mime TEXT,
|
||||
size INTEGER NOT NULL DEFAULT 0,
|
||||
stored_path TEXT NOT NULL,
|
||||
created_at REAL NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS chat_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
room_id INTEGER NOT NULL REFERENCES chat_rooms(id) ON DELETE CASCADE,
|
||||
author_id TEXT NOT NULL,
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
file_id INTEGER REFERENCES chat_files(id) ON DELETE SET NULL,
|
||||
created_at REAL NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS chat_presence (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
last_seen REAL NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_messages_room ON chat_messages(room_id, created_at);
|
||||
"""
|
||||
)
|
||||
_restore_ops_db_if_empty()
|
||||
_seed_atc_racks()
|
||||
_seed_atc_vlans()
|
||||
_seed_network_endpoints()
|
||||
_ensure_team_chat_seed()
|
||||
n = 0
|
||||
try:
|
||||
with _db() as conn:
|
||||
@@ -2906,6 +2952,131 @@ def init_db():
|
||||
log.info("Ops tickets DB ready at %s (%s tickets)", DB_PATH, n)
|
||||
|
||||
|
||||
TEAM_CHAT_DIR = DATA_DIR / "team-chat"
|
||||
TEAM_AVATAR_DIR = DATA_DIR / "team-chat" / "avatars"
|
||||
TEAM_CHAT_MAX_BYTES = 25 * 1024 * 1024
|
||||
TEAM_AVATAR_MAX_BYTES = 5 * 1024 * 1024
|
||||
TEAM_CHAT_CLIENTS: dict[str, set[WebSocket]] = defaultdict(set)
|
||||
TEAM_AVATAR_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".gif"}
|
||||
|
||||
|
||||
def _ensure_team_chat_seed() -> None:
|
||||
TEAM_CHAT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
TEAM_AVATAR_DIR.mkdir(parents=True, exist_ok=True)
|
||||
now = time.time()
|
||||
with _db() as conn:
|
||||
row = conn.execute("SELECT id FROM chat_rooms WHERE key='team'").fetchone()
|
||||
if not row:
|
||||
conn.execute(
|
||||
"INSERT INTO chat_rooms(kind, key, title, created_at) VALUES (?,?,?,?)",
|
||||
("team", "team", "ATC Team", now),
|
||||
)
|
||||
conn.commit()
|
||||
log.info("Seeded ATC Team chat room")
|
||||
|
||||
|
||||
def _require_ops_user_id(uid: str) -> str:
|
||||
uid = (uid or "").strip().lower()
|
||||
if not _ops_user(uid):
|
||||
raise HTTPException(400, f"Unknown ATC user: {uid}")
|
||||
return uid
|
||||
|
||||
|
||||
def _dm_room_key(a: str, b: str) -> str:
|
||||
x, y = sorted([a, b])
|
||||
return f"dm:{x}:{y}"
|
||||
|
||||
|
||||
def _ensure_dm_room(user_a: str, user_b: str) -> dict:
|
||||
a = _require_ops_user_id(user_a)
|
||||
b = _require_ops_user_id(user_b)
|
||||
if a == b:
|
||||
raise HTTPException(400, "Cannot DM yourself")
|
||||
key = _dm_room_key(a, b)
|
||||
now = time.time()
|
||||
with _db() as conn:
|
||||
row = conn.execute("SELECT * FROM chat_rooms WHERE key=?", (key,)).fetchone()
|
||||
if row:
|
||||
return dict(row)
|
||||
ua, ub = _ops_user(a), _ops_user(b)
|
||||
title = f"{ua.get('short') or ua['name'].split()[0]} ↔ {ub.get('short') or ub['name'].split()[0]}"
|
||||
cur = conn.execute(
|
||||
"INSERT INTO chat_rooms(kind, key, title, created_at) VALUES (?,?,?,?)",
|
||||
("dm", key, title, now),
|
||||
)
|
||||
conn.commit()
|
||||
rid = cur.lastrowid
|
||||
row = conn.execute("SELECT * FROM chat_rooms WHERE id=?", (rid,)).fetchone()
|
||||
return dict(row)
|
||||
|
||||
|
||||
def _message_dict(conn, row) -> dict:
|
||||
d = dict(row)
|
||||
file_meta = None
|
||||
if d.get("file_id"):
|
||||
fr = conn.execute("SELECT * FROM chat_files WHERE id=?", (d["file_id"],)).fetchone()
|
||||
if fr:
|
||||
file_meta = {
|
||||
"id": fr["id"],
|
||||
"filename": fr["filename"],
|
||||
"mime": fr["mime"],
|
||||
"size": fr["size"],
|
||||
"url": f"/api/team/files/{fr['id']}",
|
||||
}
|
||||
author = _ops_user(d["author_id"]) or {"id": d["author_id"], "name": d["author_id"], "short": d["author_id"]}
|
||||
return {
|
||||
"id": d["id"],
|
||||
"room_id": d["room_id"],
|
||||
"author_id": d["author_id"],
|
||||
"author_name": author.get("name"),
|
||||
"author_short": author.get("short") or (author.get("name") or "").split()[0],
|
||||
"author_team": author.get("team"),
|
||||
"author_avatar": f"/api/team/avatar/{d['author_id']}",
|
||||
"body": d["body"] or "",
|
||||
"file": file_meta,
|
||||
"created_at": d["created_at"],
|
||||
}
|
||||
|
||||
|
||||
async def _team_broadcast(payload: dict, exclude: WebSocket | None = None) -> None:
|
||||
dead: list[tuple[str, WebSocket]] = []
|
||||
for uid, socks in list(TEAM_CHAT_CLIENTS.items()):
|
||||
for ws in list(socks):
|
||||
if ws is exclude:
|
||||
continue
|
||||
try:
|
||||
await ws.send_json(payload)
|
||||
except Exception:
|
||||
dead.append((uid, ws))
|
||||
for uid, ws in dead:
|
||||
TEAM_CHAT_CLIENTS[uid].discard(ws)
|
||||
|
||||
|
||||
def _presence_snapshot() -> list[dict]:
|
||||
now = time.time()
|
||||
online = {uid for uid, socks in TEAM_CHAT_CLIENTS.items() if socks}
|
||||
with _db() as conn:
|
||||
rows = conn.execute("SELECT user_id, last_seen FROM chat_presence").fetchall()
|
||||
out = []
|
||||
for u in OPS_USERS:
|
||||
last = next((r["last_seen"] for r in rows if r["user_id"] == u["id"]), None)
|
||||
out.append(
|
||||
{
|
||||
"id": u["id"],
|
||||
"name": u["name"],
|
||||
"short": u.get("short") or u["name"].split()[0],
|
||||
"team": u["team"],
|
||||
"role": u.get("role"),
|
||||
"focus": u.get("focus"),
|
||||
"avatar_url": f"/api/team/avatar/{u['id']}",
|
||||
"online": u["id"] in online,
|
||||
"last_seen": last,
|
||||
"recent": bool(last and (now - last) < 120),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
async def fetch_gpu() -> dict:
|
||||
url = settings.gpu_metrics_url.rstrip("/") + "/api/gpu"
|
||||
try:
|
||||
@@ -3939,9 +4110,9 @@ def build_fleet_context(focus_device_id: int | None = None, max_chars: int | Non
|
||||
|
||||
lines = [
|
||||
"You are OpenManage Cockpit Copilot for Dell ATC. Be concise and operational.",
|
||||
"ATC Datacenter Admins (escalate here when facts are missing):",
|
||||
" - Jody van Dongen <jody.van.dongen@dell.com>",
|
||||
" - Laurens Rammers <laurens.rammers@dell.com>",
|
||||
"ATC Datacenter Engineers (escalate here when facts are missing):",
|
||||
" - Jody van Dongen <jody.van.dongen@dell.com> — storage, servers, network, rack & stack, installs",
|
||||
" - Laurens Rammers <laurens.rammers@dell.com> — storage, servers, network, rack & stack, installs",
|
||||
"ACCURACY RULES (mandatory — never break these):",
|
||||
"1) Use ONLY facts from this snapshot and any TOOL FACTS block. Never invent Service Tags, IPs, DIMM counts, firmware versions, RDP targets, port maps, VLAN members, or rack placements.",
|
||||
"2) If a requested fact is not present in the snapshot/TOOL FACTS, or tools failed/returned empty: say exactly what is unknown, then tell the user to overleggen met Jody van Dongen and Laurens Rammers (emails above). Do not guess.",
|
||||
@@ -4516,11 +4687,12 @@ async def api_ops_users():
|
||||
"users": OPS_USERS,
|
||||
"admins": [u for u in OPS_USERS if u.get("team") == "admin"],
|
||||
"fde": [u for u in OPS_USERS if u.get("team") == "fde"],
|
||||
"guests": [u for u in OPS_USERS if u.get("team") == "guest"],
|
||||
"context": {
|
||||
"cockpit": "OME Cockpit by Data Forward Deployed Engineers Mohamed El Kadi & Bart Sjerps",
|
||||
"cluster": "Runs on the FDE cluster operated by Data Forward Deployed Engineers Mohamed El Kadi and Bart Sjerps",
|
||||
"admins": "Jody van Dongen and Laurens Rammers — ATC datacenter administrators",
|
||||
"fde": "Mo and Bart — both Data Forward Deployed Engineers deploying AI workloads",
|
||||
"admins": "Jody van Dongen and Laurens Rammers — ATC Datacenter Engineers (storage, servers, network, rack & stack, installs)",
|
||||
"fde": "Mo and Bart — Data Forward Deployed Engineers, aka \"Data Plumbers\" 😉",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -4734,7 +4906,7 @@ async def api_chat(payload: ChatIn):
|
||||
user_msg = payload.message[:2500]
|
||||
escalate = (
|
||||
"If any needed fact is absent, say it is unknown and instruct the user to overleggen met "
|
||||
"Jody van Dongen (jody.van.dongen@dell.com) and Laurens Rammers (laurens.rammers@dell.com). "
|
||||
"Jody van Dongen (jody.van.dongen@dell.com) and Laurens Rammers (laurens.rammers@dell.com) — Datacenter Engineers. "
|
||||
"Never invent."
|
||||
)
|
||||
if tool_results:
|
||||
@@ -4848,6 +5020,335 @@ def _admin_name(aid: str) -> str:
|
||||
return u["name"] if u else aid
|
||||
|
||||
|
||||
class TeamMessageIn(BaseModel):
|
||||
author_id: str
|
||||
body: str = ""
|
||||
|
||||
|
||||
class TeamDmIn(BaseModel):
|
||||
me: str
|
||||
peer: str
|
||||
|
||||
|
||||
def _avatar_path(user_id: str) -> Path | None:
|
||||
"""Return existing avatar file for user, if any."""
|
||||
TEAM_AVATAR_DIR.mkdir(parents=True, exist_ok=True)
|
||||
for ext in (".jpg", ".jpeg", ".png", ".webp", ".gif"):
|
||||
p = TEAM_AVATAR_DIR / f"{user_id}{ext}"
|
||||
if p.is_file():
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def _ops_users_public() -> list[dict]:
|
||||
out = []
|
||||
for u in OPS_USERS:
|
||||
d = dict(u)
|
||||
d["short"] = u.get("short") or u["name"].split()[0]
|
||||
d["avatar_url"] = f"/api/team/avatar/{u['id']}"
|
||||
d["has_avatar"] = _avatar_path(u["id"]) is not None
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
|
||||
@app.get("/api/team/users")
|
||||
async def team_users():
|
||||
return {"users": _ops_users_public(), "presence": _presence_snapshot()}
|
||||
|
||||
|
||||
@app.get("/api/team/avatar/{user_id}")
|
||||
async def team_get_avatar(user_id: str):
|
||||
uid = (user_id or "").strip().lower()
|
||||
if not _ops_user(uid):
|
||||
raise HTTPException(404, "Unknown user")
|
||||
path = _avatar_path(uid)
|
||||
if not path:
|
||||
# 1x1 transparent PNG so <img> doesn't break
|
||||
raise HTTPException(404, "No avatar")
|
||||
media = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".webp": "image/webp",
|
||||
".gif": "image/gif",
|
||||
}.get(path.suffix.lower(), "application/octet-stream")
|
||||
resp = FileResponse(path, media_type=media)
|
||||
resp.headers["Cache-Control"] = "public, max-age=60"
|
||||
return resp
|
||||
|
||||
|
||||
@app.post("/api/team/avatar")
|
||||
async def team_upload_avatar(
|
||||
user_id: str = Form(...),
|
||||
file: UploadFile = File(...),
|
||||
):
|
||||
uid = _require_ops_user_id(user_id)
|
||||
raw_name = Path(file.filename or "avatar.jpg").name
|
||||
ext = Path(raw_name).suffix.lower()
|
||||
if ext not in TEAM_AVATAR_EXTS:
|
||||
raise HTTPException(400, "Avatar must be jpg, png, webp, or gif")
|
||||
data = await file.read()
|
||||
if not data:
|
||||
raise HTTPException(400, "Empty file")
|
||||
if len(data) > TEAM_AVATAR_MAX_BYTES:
|
||||
raise HTTPException(400, "Avatar too large (max 5 MB)")
|
||||
# basic sniff
|
||||
head = data[:16]
|
||||
ok = (
|
||||
head.startswith(b"\xff\xd8\xff")
|
||||
or head.startswith(b"\x89PNG\r\n\x1a\n")
|
||||
or head.startswith(b"GIF87a")
|
||||
or head.startswith(b"GIF89a")
|
||||
or head.startswith(b"RIFF")
|
||||
)
|
||||
if not ok:
|
||||
raise HTTPException(400, "File does not look like an image")
|
||||
TEAM_AVATAR_DIR.mkdir(parents=True, exist_ok=True)
|
||||
# remove previous variants
|
||||
for old in TEAM_AVATAR_DIR.glob(f"{uid}.*"):
|
||||
try:
|
||||
old.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
dest = TEAM_AVATAR_DIR / f"{uid}{ext}"
|
||||
dest.write_bytes(data)
|
||||
await _team_broadcast(
|
||||
{
|
||||
"type": "avatar",
|
||||
"user_id": uid,
|
||||
"avatar_url": f"/api/team/avatar/{uid}?t={int(time.time())}",
|
||||
}
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"user_id": uid,
|
||||
"avatar_url": f"/api/team/avatar/{uid}?t={int(time.time())}",
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/team/rooms")
|
||||
async def team_rooms(me: str = "mo"):
|
||||
me = _require_ops_user_id(me)
|
||||
# ensure team room + all DM rooms exist for this user
|
||||
for u in OPS_USERS:
|
||||
if u["id"] != me:
|
||||
_ensure_dm_room(me, u["id"])
|
||||
with _db() as conn:
|
||||
team = conn.execute("SELECT * FROM chat_rooms WHERE key='team'").fetchone()
|
||||
all_dms = conn.execute("SELECT * FROM chat_rooms WHERE kind='dm' ORDER BY title").fetchall()
|
||||
dms = []
|
||||
for r in all_dms:
|
||||
parts = (r["key"] or "").split(":")
|
||||
if len(parts) == 3 and me in (parts[1], parts[2]):
|
||||
dms.append(r)
|
||||
rooms = []
|
||||
if team:
|
||||
rooms.append(dict(team))
|
||||
rooms.extend(dict(r) for r in dms)
|
||||
# last message preview
|
||||
for r in rooms:
|
||||
last = conn.execute(
|
||||
"SELECT * FROM chat_messages WHERE room_id=? ORDER BY created_at DESC LIMIT 1",
|
||||
(r["id"],),
|
||||
).fetchone()
|
||||
r["last_message"] = _message_dict(conn, last) if last else None
|
||||
# peer for DMs
|
||||
if r["kind"] == "dm":
|
||||
parts = r["key"].split(":")
|
||||
peer = parts[1] if parts[2] == me else parts[2]
|
||||
pu = _ops_user(peer)
|
||||
r["peer_id"] = peer
|
||||
r["peer_name"] = pu["name"] if pu else peer
|
||||
r["peer_short"] = (pu.get("short") if pu else None) or (pu["name"].split()[0] if pu else peer)
|
||||
r["peer_team"] = pu.get("team") if pu else None
|
||||
r["peer_avatar"] = f"/api/team/avatar/{peer}"
|
||||
return {"me": me, "rooms": rooms, "presence": _presence_snapshot(), "users": _ops_users_public()}
|
||||
|
||||
|
||||
@app.post("/api/team/rooms/dm")
|
||||
async def team_open_dm(payload: TeamDmIn):
|
||||
room = _ensure_dm_room(payload.me, payload.peer)
|
||||
return {"room": room}
|
||||
|
||||
|
||||
@app.get("/api/team/rooms/{room_id}/messages")
|
||||
async def team_list_messages(room_id: int, limit: int = 200):
|
||||
limit = max(1, min(limit, 500))
|
||||
with _db() as conn:
|
||||
room = conn.execute("SELECT * FROM chat_rooms WHERE id=?", (room_id,)).fetchone()
|
||||
if not room:
|
||||
raise HTTPException(404, "Room not found")
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM chat_messages WHERE room_id=? ORDER BY created_at ASC LIMIT ?",
|
||||
(room_id, limit),
|
||||
).fetchall()
|
||||
return {
|
||||
"room": dict(room),
|
||||
"messages": [_message_dict(conn, r) for r in rows],
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/team/rooms/{room_id}/messages")
|
||||
async def team_post_message(room_id: int, payload: TeamMessageIn):
|
||||
author = _require_ops_user_id(payload.author_id)
|
||||
body = (payload.body or "").strip()
|
||||
if not body:
|
||||
raise HTTPException(400, "Empty message")
|
||||
if len(body) > 8000:
|
||||
raise HTTPException(400, "Message too long")
|
||||
now = time.time()
|
||||
with _db() as conn:
|
||||
room = conn.execute("SELECT * FROM chat_rooms WHERE id=?", (room_id,)).fetchone()
|
||||
if not room:
|
||||
raise HTTPException(404, "Room not found")
|
||||
cur = conn.execute(
|
||||
"INSERT INTO chat_messages(room_id, author_id, body, file_id, created_at) VALUES (?,?,?,?,?)",
|
||||
(room_id, author, body, None, now),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO chat_presence(user_id, last_seen) VALUES (?,?) "
|
||||
"ON CONFLICT(user_id) DO UPDATE SET last_seen=excluded.last_seen",
|
||||
(author, now),
|
||||
)
|
||||
conn.commit()
|
||||
mid = cur.lastrowid
|
||||
row = conn.execute("SELECT * FROM chat_messages WHERE id=?", (mid,)).fetchone()
|
||||
msg = _message_dict(conn, row)
|
||||
await _team_broadcast({"type": "message", "message": msg})
|
||||
return {"message": msg}
|
||||
|
||||
|
||||
@app.post("/api/team/rooms/{room_id}/files")
|
||||
async def team_upload_file(
|
||||
room_id: int,
|
||||
uploader_id: str = Form(...),
|
||||
body: str = Form(""),
|
||||
file: UploadFile = File(...),
|
||||
):
|
||||
author = _require_ops_user_id(uploader_id)
|
||||
with _db() as conn:
|
||||
room = conn.execute("SELECT * FROM chat_rooms WHERE id=?", (room_id,)).fetchone()
|
||||
if not room:
|
||||
raise HTTPException(404, "Room not found")
|
||||
raw_name = Path(file.filename or "upload.bin").name
|
||||
safe = re.sub(r"[^\w.\- ()\[\]]+", "_", raw_name)[:180] or "upload.bin"
|
||||
data = await file.read()
|
||||
if not data:
|
||||
raise HTTPException(400, "Empty file")
|
||||
if len(data) > TEAM_CHAT_MAX_BYTES:
|
||||
raise HTTPException(400, f"File too large (max {TEAM_CHAT_MAX_BYTES // (1024*1024)} MB)")
|
||||
room_dir = TEAM_CHAT_DIR / str(room_id)
|
||||
room_dir.mkdir(parents=True, exist_ok=True)
|
||||
stored_name = f"{uuid.uuid4().hex}_{safe}"
|
||||
dest = room_dir / stored_name
|
||||
dest.write_bytes(data)
|
||||
now = time.time()
|
||||
caption = (body or "").strip()[:2000]
|
||||
with _db() as conn:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO chat_files(room_id, uploader_id, filename, mime, size, stored_path, created_at)
|
||||
VALUES (?,?,?,?,?,?,?)
|
||||
""",
|
||||
(room_id, author, safe, file.content_type or "application/octet-stream", len(data), str(dest), now),
|
||||
)
|
||||
fid = cur.lastrowid
|
||||
cur2 = conn.execute(
|
||||
"INSERT INTO chat_messages(room_id, author_id, body, file_id, created_at) VALUES (?,?,?,?,?)",
|
||||
(room_id, author, caption or f"Shared {safe}", fid, now),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO chat_presence(user_id, last_seen) VALUES (?,?) "
|
||||
"ON CONFLICT(user_id) DO UPDATE SET last_seen=excluded.last_seen",
|
||||
(author, now),
|
||||
)
|
||||
conn.commit()
|
||||
mid = cur2.lastrowid
|
||||
row = conn.execute("SELECT * FROM chat_messages WHERE id=?", (mid,)).fetchone()
|
||||
msg = _message_dict(conn, row)
|
||||
await _team_broadcast({"type": "message", "message": msg})
|
||||
return {"message": msg}
|
||||
|
||||
|
||||
@app.get("/api/team/files/{file_id}")
|
||||
async def team_download_file(file_id: int):
|
||||
with _db() as conn:
|
||||
fr = conn.execute("SELECT * FROM chat_files WHERE id=?", (file_id,)).fetchone()
|
||||
if not fr:
|
||||
raise HTTPException(404, "File not found")
|
||||
path = Path(fr["stored_path"])
|
||||
if not path.is_file():
|
||||
raise HTTPException(404, "File missing on disk")
|
||||
return FileResponse(
|
||||
path,
|
||||
filename=fr["filename"],
|
||||
media_type=fr["mime"] or "application/octet-stream",
|
||||
)
|
||||
|
||||
|
||||
@app.websocket("/ws/team-chat")
|
||||
async def ws_team_chat(ws: WebSocket, user: str = "mo"):
|
||||
try:
|
||||
uid = _require_ops_user_id(user)
|
||||
except HTTPException:
|
||||
await ws.close(code=4400)
|
||||
return
|
||||
await ws.accept()
|
||||
TEAM_CHAT_CLIENTS[uid].add(ws)
|
||||
now = time.time()
|
||||
with _db() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO chat_presence(user_id, last_seen) VALUES (?,?) "
|
||||
"ON CONFLICT(user_id) DO UPDATE SET last_seen=excluded.last_seen",
|
||||
(uid, now),
|
||||
)
|
||||
conn.commit()
|
||||
await _team_broadcast({"type": "presence", "presence": _presence_snapshot()})
|
||||
try:
|
||||
await ws.send_json({"type": "hello", "user": uid, "presence": _presence_snapshot()})
|
||||
while True:
|
||||
raw = await ws.receive_text()
|
||||
try:
|
||||
msg = json.loads(raw)
|
||||
except Exception:
|
||||
continue
|
||||
mtype = msg.get("type")
|
||||
if mtype == "ping":
|
||||
with _db() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO chat_presence(user_id, last_seen) VALUES (?,?) "
|
||||
"ON CONFLICT(user_id) DO UPDATE SET last_seen=excluded.last_seen",
|
||||
(uid, time.time()),
|
||||
)
|
||||
conn.commit()
|
||||
await ws.send_json({"type": "pong", "presence": _presence_snapshot()})
|
||||
elif mtype == "typing":
|
||||
await _team_broadcast(
|
||||
{
|
||||
"type": "typing",
|
||||
"user_id": uid,
|
||||
"room_id": msg.get("room_id"),
|
||||
"name": (_ops_user(uid) or {}).get("name"),
|
||||
},
|
||||
exclude=ws,
|
||||
)
|
||||
elif mtype == "message":
|
||||
# allow send via WS as convenience
|
||||
room_id = int(msg.get("room_id") or 0)
|
||||
body = str(msg.get("body") or "").strip()
|
||||
if room_id and body:
|
||||
posted = await team_post_message(room_id, TeamMessageIn(author_id=uid, body=body))
|
||||
# already broadcast inside team_post_message
|
||||
_ = posted
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception as e:
|
||||
log.debug("team chat ws end: %s", e)
|
||||
finally:
|
||||
TEAM_CHAT_CLIENTS[uid].discard(ws)
|
||||
await _team_broadcast({"type": "presence", "presence": _presence_snapshot()})
|
||||
|
||||
|
||||
@app.get("/api/tickets")
|
||||
async def list_tickets():
|
||||
with _db() as conn:
|
||||
@@ -6463,6 +6964,11 @@ async def console_js():
|
||||
return FileResponse(STATIC_DIR / "console.js", media_type="application/javascript")
|
||||
|
||||
|
||||
@app.get("/team.js")
|
||||
async def team_js():
|
||||
return FileResponse(STATIC_DIR / "team.js", media_type="application/javascript")
|
||||
|
||||
|
||||
@app.get("/ssh.js")
|
||||
async def ssh_js():
|
||||
return FileResponse(STATIC_DIR / "ssh.js", media_type="application/javascript")
|
||||
|
||||
@@ -2,6 +2,7 @@ fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
httpx==0.28.1
|
||||
websockets==14.1
|
||||
python-multipart==0.0.20
|
||||
pydantic==2.10.4
|
||||
pydantic-settings==2.7.0
|
||||
asyncssh==2.18.0
|
||||
|
||||
Reference in New Issue
Block a user