Files
AgentHub/server/routes/chat.js
T
nntrivi2001 03ebea1d92 fix(chat): the review's gaps — owner hand-over, live group lines, names, scroll, one tab's sound, uploads on disk, a chat button without the pet
- A group whose creator left or was deleted passes to its longest-standing
  member (ensureOwner), so it can still be managed.
- Group changes (added / removed / renamed / left) are sent live as
  chat_message and never count as unread, here or on the server.
- A disabled account is still named in old chats (people carries
  `disabled`) and never offered for a new one; a deleted one reads
  'Deleted user' instead of '?'.
- Load older keeps the message being read in place.
- With several tabs open only the first to see a message plays its sound /
  system notification (agenthub.chat.notified).
- The people list is read again on New chat and on every room change.
- Uploads go to <data dir>/chat/.incoming/ (multer disk storage) and are
  renamed into place — never held in memory; a refused one is removed.
- With the pet hidden, a chat button in the bottom-right corner (ChatDock,
  server mode) opens the same window, unread count and new-message card
  included.
2026-11-03 09:59:13 +07:00

216 lines
7.0 KiB
JavaScript

/**
* @file chat.js (routes)
* @description `/api/chat` — chat between users in server mode (the pet's
* window, `client/src/components/Sagi/ChatHub.tsx`): people to chat with,
* the caller's rooms, a direct chat or a group, messages (text + files, a
* multipart `POST`), read marks and a room's files. Everything acts on the
* CALLER's own rooms (`server/lib/chat.js`): a room they are not in is 404,
* whatever their role. Local mode has one person and no chat (`400 ELOCALMODE`).
* Each change is broadcast as `chat_message` / `chat_room` / `chat_deleted` to exactly the
* users concerned (`to`, `scope.exactAudience`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
"use strict";
const express = require("express");
const chat = require("../lib/chat");
const { isServerMode } = require("../lib/auth");
const { sameOriginGuard } = require("../lib/same-origin");
const router = express.Router();
function send(res, e) {
const status = e.status || (e.code === "ENOUSER" || e.code === "EBADFIELD" ? 400 : 500);
if (status === 500) console.error("[chat]", e);
res.status(status).json({ error: { code: e.code || "EINTERNAL", message: e.message } });
}
/** Run `fn(userId)`; refuses local mode and an anonymous request. */
const handle = (fn) => (req, res) => {
if (!isServerMode() || !req.user) {
return res
.status(400)
.json({ error: { code: "ELOCALMODE", message: "chat is for a shared server" } });
}
try {
fn(req.user.id, req, res);
} catch (e) {
send(res, e);
}
};
function tell(type, data, to) {
try {
require("../websocket").broadcast(type, { ...data, to });
} catch {
// no socket server (tests): nothing to tell
}
}
/** Multipart `files` + `body`, written to disk (never held whole in memory), within the chat's limits. */
function upload(req, res, next) {
if (!req.is("multipart/form-data")) return next();
const multer = require("multer");
multer({
storage: multer.diskStorage({
destination: (_req, _file, cb) => cb(null, chat.incomingDir()),
// A name of ours; the browser's goes only into the database.
filename: (_req, _file, cb) =>
cb(null, require("node:crypto").randomBytes(16).toString("hex")),
}),
limits: { fileSize: chat.MAX_FILE_BYTES, files: chat.MAX_FILES },
}).array("files")(req, res, (e) => {
if (!e) return next();
const big = e.code === "LIMIT_FILE_SIZE" || e.code === "LIMIT_FILE_COUNT";
return res.status(big ? 413 : 400).json({
error: { code: big ? "ETOOBIG" : "EBADUPLOAD", message: e.message },
});
});
}
router.get(
"/people",
handle((me, _req, res) => res.json({ people: chat.people(me) }))
);
router.get(
"/rooms",
handle((me, _req, res) => res.json({ rooms: chat.listRooms(me) }))
);
router.post(
"/rooms",
sameOriginGuard,
handle((me, req, res) => {
const b = req.body || {};
if (b.kind === "dm") {
const { room, created } = chat.openDm(me, b.userId);
if (created) tell("chat_room", { roomId: room.id }, room.members);
return res.status(created ? 201 : 200).json({ room });
}
if (b.kind === "group") {
const room = chat.createGroup(me, { name: b.name, userIds: b.userIds });
tell("chat_room", { roomId: room.id }, room.members);
return res.status(201).json({ room });
}
throw Object.assign(new Error("kind: dm or group"), { code: "EBADFIELD" });
})
);
router.patch(
"/rooms/:id",
sameOriginGuard,
handle((me, req, res) => {
const b = req.body || {};
const { room, notify, lines } = chat.updateGroup(me, req.params.id, {
name: b.name,
add: b.add,
remove: b.remove,
});
tell("chat_room", { roomId: room.id }, notify);
// The "X added Y" lines, live in every open view.
for (const message of lines) tell("chat_message", { roomId: room.id, message }, room.members);
res.json({ room });
})
);
router.post(
"/rooms/:id/leave",
sameOriginGuard,
handle((me, req, res) => {
const { notify, lines } = chat.leaveGroup(me, req.params.id);
tell("chat_room", { roomId: Number(req.params.id) }, notify);
const stay = notify.filter((id) => id !== me);
for (const message of lines)
tell("chat_message", { roomId: Number(req.params.id), message }, stay);
res.json({ ok: true });
})
);
// Delete the chat for the caller only (their history; a direct chat leaves their list).
router.post(
"/rooms/:id/clear",
sameOriginGuard,
handle((me, req, res) => res.json(chat.clearChat(me, req.params.id)))
);
// The group's creator deletes it for everyone.
router.delete(
"/rooms/:id",
sameOriginGuard,
handle((me, req, res) => {
const { notify } = chat.disbandGroup(me, req.params.id);
tell("chat_room", { roomId: Number(req.params.id) }, notify);
res.json({ ok: true });
})
);
router.get(
"/rooms/:id/messages",
handle((me, req, res) =>
res.json(chat.messages(me, req.params.id, { before: req.query.before, limit: req.query.limit }))
)
);
router.post("/rooms/:id/messages", sameOriginGuard, upload, (req, res) =>
handle((me) => {
const files = (req.files || []).map((f) => ({
// multer reads names as latin1; browsers send UTF-8.
name: Buffer.from(f.originalname, "latin1").toString("utf8"),
type: f.mimetype,
path: f.path,
}));
let posted;
try {
posted = chat.post(me, req.params.id, { body: req.body?.body, files });
} finally {
// Moved into place on success; anything left over is a refused upload.
for (const f of files) require("node:fs").rmSync(f.path, { force: true });
}
const { message, notify } = posted;
tell("chat_message", { roomId: message.roomId, message }, notify);
res.status(201).json({ message });
})(req, res)
);
router.delete(
"/rooms/:id/messages/:messageId",
sameOriginGuard,
handle((me, req, res) => {
const { notify } = chat.removeMessage(me, req.params.id, req.params.messageId);
const roomId = Number(req.params.id);
tell("chat_deleted", { roomId, messageId: Number(req.params.messageId) }, notify);
res.json({ ok: true });
})
);
router.post(
"/rooms/:id/read",
sameOriginGuard,
handle((me, req, res) => {
const { lastReadId, moved, notify } = chat.markRead(me, req.params.id, req.body?.upTo);
// "Seen": the other members' views move their read marks.
if (moved) tell("chat_read", { roomId: Number(req.params.id), userId: me, lastReadId }, notify);
res.json({ lastReadId });
})
);
router.get(
"/rooms/:id/files/:fileId",
handle((me, req, res) => {
const f = chat.file(me, req.params.id, req.params.fileId);
const ascii = f.name.replace(/[^\x20-\x7e]|["\\]/g, "_");
res.set({
"Content-Type": f.type,
"X-Content-Type-Options": "nosniff",
"Content-Security-Policy": "sandbox; default-src 'none'",
"Cache-Control": "private, max-age=86400",
"Content-Disposition": `${f.inline ? "inline" : "attachment"}; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(f.name)}`,
});
require("../lib/send-file").sendFile(res, f.path);
})
);
module.exports = router;