03ebea1d92
- 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.
353 lines
12 KiB
JavaScript
353 lines
12 KiB
JavaScript
/**
|
|
* @file OpenAPI fragment for `/api/chat` (server/routes/chat.js): chat between
|
|
* the users of a server-mode install — direct chats and groups, text and
|
|
* files. Every route acts on the caller's own rooms only (404 otherwise, an
|
|
* admin included); local mode answers `400 ELOCALMODE`.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const err = { $ref: "#/components/schemas/ErrorResponse" };
|
|
const json = (schema) => ({ content: { "application/json": { schema } } });
|
|
const fail = (codes) =>
|
|
Object.fromEntries(codes.map((c) => [String(c), { description: "Refused", ...json(err) }]));
|
|
const idParam = { name: "id", in: "path", required: true, schema: { type: "integer" } };
|
|
|
|
const tags = [
|
|
{
|
|
name: "Chat",
|
|
description:
|
|
"Chat between users (server mode): direct chats and groups in the pet's window. Only a room's members read it.",
|
|
},
|
|
];
|
|
|
|
const schemas = {
|
|
ChatFile: {
|
|
type: "object",
|
|
properties: {
|
|
id: { type: "string", example: "9f0c1b2a3d4e5f60718293a4b5c6d7e8" },
|
|
name: { type: "string", example: "screenshot.png" },
|
|
type: { type: "string", example: "image/png" },
|
|
size: { type: "integer", example: 20480 },
|
|
},
|
|
},
|
|
ChatMessage: {
|
|
type: "object",
|
|
properties: {
|
|
id: { type: "integer", example: 42 },
|
|
roomId: { type: "integer", example: 3 },
|
|
userId: { type: "integer", nullable: true, example: 2 },
|
|
kind: {
|
|
type: "string",
|
|
enum: ["text", "system"],
|
|
description:
|
|
"`system`: a group change — body `created`, `renamed`, `left`, `added:<ids>` or `removed:<ids>`.",
|
|
},
|
|
body: { type: "string", example: "Chào cả nhóm" },
|
|
createdAt: { type: "string", format: "date-time" },
|
|
files: { type: "array", items: { $ref: "#/components/schemas/ChatFile" } },
|
|
},
|
|
},
|
|
ChatRoom: {
|
|
type: "object",
|
|
properties: {
|
|
id: { type: "integer", example: 3 },
|
|
kind: { type: "string", enum: ["dm", "group"] },
|
|
name: { type: "string", description: "A group's name; empty for a direct chat." },
|
|
createdBy: { type: "integer", nullable: true },
|
|
createdAt: { type: "string", format: "date-time" },
|
|
lastMessageAt: { type: "string", format: "date-time", nullable: true },
|
|
members: { type: "array", items: { type: "integer" } },
|
|
lastMessage: { allOf: [{ $ref: "#/components/schemas/ChatMessage" }], nullable: true },
|
|
lastReadId: { type: "integer" },
|
|
clearedId: {
|
|
type: "integer",
|
|
description: "Messages up to this id were deleted by the caller for themselves.",
|
|
},
|
|
unread: { type: "integer", description: "Messages of others after `lastReadId`." },
|
|
reads: {
|
|
type: "object",
|
|
additionalProperties: { type: "integer" },
|
|
description: "Every member's read mark by user id — what the room shows as Seen.",
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
const roomResp = json({
|
|
type: "object",
|
|
properties: { room: { $ref: "#/components/schemas/ChatRoom" } },
|
|
});
|
|
|
|
const paths = {
|
|
"/api/chat/people": {
|
|
get: {
|
|
tags: ["Chat"],
|
|
summary: "Users the caller can chat with",
|
|
description:
|
|
"Every account but the caller: id, username, display name, avatar time, and `disabled` (not offered for a new chat).",
|
|
responses: {
|
|
200: {
|
|
description: "People",
|
|
...json({
|
|
type: "object",
|
|
properties: {
|
|
people: {
|
|
type: "array",
|
|
items: {
|
|
type: "object",
|
|
properties: {
|
|
id: { type: "integer" },
|
|
username: { type: "string" },
|
|
displayName: { type: "string", nullable: true },
|
|
avatarAt: { type: "string", nullable: true },
|
|
disabled: {
|
|
type: "boolean",
|
|
description: "A disabled account: still named in old chats, never offered for a new one.",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
},
|
|
...fail([400]),
|
|
},
|
|
},
|
|
},
|
|
"/api/chat/rooms": {
|
|
get: {
|
|
tags: ["Chat"],
|
|
summary: "The caller's rooms",
|
|
description:
|
|
"Most recently active first, each with its members, newest message and unread count.",
|
|
responses: {
|
|
200: {
|
|
description: "Rooms",
|
|
...json({
|
|
type: "object",
|
|
properties: {
|
|
rooms: { type: "array", items: { $ref: "#/components/schemas/ChatRoom" } },
|
|
},
|
|
}),
|
|
},
|
|
...fail([400]),
|
|
},
|
|
},
|
|
post: {
|
|
tags: ["Chat"],
|
|
summary: "Open a direct chat or create a group",
|
|
description:
|
|
"`{kind: 'dm', userId}` answers the one direct chat of the pair (201 when made now, 200 when it existed); `{kind: 'group', name, userIds}` makes a group of the caller plus `userIds`. Broadcasts `chat_room` to the members.",
|
|
requestBody: {
|
|
required: true,
|
|
...json({
|
|
type: "object",
|
|
required: ["kind"],
|
|
properties: {
|
|
kind: { type: "string", enum: ["dm", "group"] },
|
|
userId: { type: "integer" },
|
|
name: { type: "string", maxLength: 80 },
|
|
userIds: { type: "array", items: { type: "integer" } },
|
|
},
|
|
}),
|
|
},
|
|
responses: {
|
|
200: { description: "The existing direct chat", ...roomResp },
|
|
201: { description: "Created", ...roomResp },
|
|
...fail([400]),
|
|
},
|
|
},
|
|
},
|
|
"/api/chat/rooms/{id}": {
|
|
delete: {
|
|
tags: ["Chat"],
|
|
summary: "Disband a group",
|
|
description:
|
|
"The group's creator only (`403 EFORBIDDEN`): deletes it for every member, its files included. Broadcasts `chat_room`.",
|
|
parameters: [idParam],
|
|
responses: {
|
|
200: {
|
|
description: "Disbanded",
|
|
...json({ type: "object", properties: { ok: { type: "boolean" } } }),
|
|
},
|
|
...fail([400, 403, 404]),
|
|
},
|
|
},
|
|
patch: {
|
|
tags: ["Chat"],
|
|
summary: "Rename a group, add or remove members",
|
|
description: "Only the group's creator (`403 EFORBIDDEN`). Removed users are told too.",
|
|
parameters: [idParam],
|
|
requestBody: {
|
|
required: true,
|
|
...json({
|
|
type: "object",
|
|
properties: {
|
|
name: { type: "string" },
|
|
add: { type: "array", items: { type: "integer" } },
|
|
remove: { type: "array", items: { type: "integer" } },
|
|
},
|
|
}),
|
|
},
|
|
responses: { 200: { description: "Updated", ...roomResp }, ...fail([400, 403, 404]) },
|
|
},
|
|
},
|
|
"/api/chat/rooms/{id}/clear": {
|
|
post: {
|
|
tags: ["Chat"],
|
|
summary: "Delete a chat for yourself",
|
|
description:
|
|
"Every message so far is gone for the caller only (`clearedId`); a direct chat also leaves their list until a new message arrives. Nobody else's copy changes — but what EVERY member has deleted is deleted for real (messages, files, and a direct chat both people deleted, whole).",
|
|
parameters: [idParam],
|
|
responses: {
|
|
200: {
|
|
description: "Cleared",
|
|
...json({
|
|
type: "object",
|
|
properties: {
|
|
hidden: { type: "boolean" },
|
|
purged: {
|
|
type: "boolean",
|
|
description: "Every member has now deleted it: the chat is gone from the server.",
|
|
},
|
|
},
|
|
}),
|
|
},
|
|
...fail([404]),
|
|
},
|
|
},
|
|
},
|
|
"/api/chat/rooms/{id}/leave": {
|
|
post: {
|
|
tags: ["Chat"],
|
|
summary: "Leave a group",
|
|
description: "The last member out deletes the group and its files.",
|
|
parameters: [idParam],
|
|
responses: {
|
|
200: {
|
|
description: "Left",
|
|
...json({ type: "object", properties: { ok: { type: "boolean" } } }),
|
|
},
|
|
...fail([400, 404]),
|
|
},
|
|
},
|
|
},
|
|
"/api/chat/rooms/{id}/messages": {
|
|
get: {
|
|
tags: ["Chat"],
|
|
summary: "A page of a room's messages",
|
|
description:
|
|
"Oldest first: the newest `limit` (default 50, max 200), or those before `before`.",
|
|
parameters: [
|
|
idParam,
|
|
{ name: "before", in: "query", schema: { type: "integer" } },
|
|
{ name: "limit", in: "query", schema: { type: "integer" } },
|
|
],
|
|
responses: {
|
|
200: {
|
|
description: "Messages",
|
|
...json({
|
|
type: "object",
|
|
properties: {
|
|
messages: { type: "array", items: { $ref: "#/components/schemas/ChatMessage" } },
|
|
hasMore: { type: "boolean" },
|
|
},
|
|
}),
|
|
},
|
|
...fail([400, 404]),
|
|
},
|
|
},
|
|
post: {
|
|
tags: ["Chat"],
|
|
summary: "Post a message",
|
|
description:
|
|
"JSON `{body}` or multipart `body` + up to 10 `files` of ≤ 25 MB. Broadcasts `chat_message` to the room's members.",
|
|
parameters: [idParam],
|
|
requestBody: {
|
|
required: true,
|
|
content: {
|
|
"application/json": {
|
|
schema: { type: "object", properties: { body: { type: "string", maxLength: 8000 } } },
|
|
},
|
|
"multipart/form-data": {
|
|
schema: {
|
|
type: "object",
|
|
properties: {
|
|
body: { type: "string" },
|
|
files: { type: "array", items: { type: "string", format: "binary" } },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
responses: {
|
|
201: {
|
|
description: "Posted",
|
|
...json({
|
|
type: "object",
|
|
properties: { message: { $ref: "#/components/schemas/ChatMessage" } },
|
|
}),
|
|
},
|
|
...fail([400, 404, 413]),
|
|
},
|
|
},
|
|
},
|
|
"/api/chat/rooms/{id}/messages/{messageId}": {
|
|
delete: {
|
|
tags: ["Chat"],
|
|
summary: "Delete your own message",
|
|
description:
|
|
"Only the message's author (`403 EFORBIDDEN`, an admin included); its files go with it. Broadcasts `chat_deleted` to the members.",
|
|
parameters: [
|
|
idParam,
|
|
{ name: "messageId", in: "path", required: true, schema: { type: "integer" } },
|
|
],
|
|
responses: {
|
|
200: {
|
|
description: "Deleted",
|
|
...json({ type: "object", properties: { ok: { type: "boolean" } } }),
|
|
},
|
|
...fail([403, 404]),
|
|
},
|
|
},
|
|
},
|
|
"/api/chat/rooms/{id}/read": {
|
|
post: {
|
|
tags: ["Chat"],
|
|
summary: "Mark a room read up to a message",
|
|
description:
|
|
"Never moves back, never past the room's newest message. A mark that moved is told to the members as `chat_read` (`{roomId, userId, lastReadId}`).",
|
|
parameters: [idParam],
|
|
requestBody: {
|
|
required: true,
|
|
...json({ type: "object", required: ["upTo"], properties: { upTo: { type: "integer" } } }),
|
|
},
|
|
responses: {
|
|
200: {
|
|
description: "Read mark",
|
|
...json({ type: "object", properties: { lastReadId: { type: "integer" } } }),
|
|
},
|
|
...fail([400, 404]),
|
|
},
|
|
},
|
|
},
|
|
"/api/chat/rooms/{id}/files/{fileId}": {
|
|
get: {
|
|
tags: ["Chat"],
|
|
summary: "One file of a room",
|
|
description:
|
|
"Members only. Served under a `sandbox` CSP and `nosniff`; PNG/JPEG/GIF/WebP inline, anything else as an attachment.",
|
|
parameters: [
|
|
idParam,
|
|
{ name: "fileId", in: "path", required: true, schema: { type: "string" } },
|
|
],
|
|
responses: {
|
|
200: { description: "The file's bytes", content: { "application/octet-stream": {} } },
|
|
...fail([400, 404]),
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
module.exports = { tags, schemas, paths };
|