21a042dc2b
Server mode: a rail beside the pet's chat lists the pet, then the user's direct chats (one per pair) and groups with unread counts, and + for a new chat. A room shows its messages (group senders named), loads older pages, and sends text, pasted / dropped / attached files (<= 10 x 25 MB; images inline). A group's creator renames it and adds or removes people; anyone may leave. A blue dot on the pet counts unread messages. Only a room's members read or write it, an admin included (roomFor → 404). Files are stored under server-made names in <data dir>/chat/<room>/ and served to members only under a sandbox CSP + nosniff. chat_message / chat_room frames reach exactly the ids in `to` (scope.exactAudience). New tables chat_rooms / chat_members / chat_messages / chat_files (additive).
279 lines
8.8 KiB
JavaScript
279 lines
8.8 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" },
|
|
unread: { type: "integer", description: "Messages of others after `lastReadId`." },
|
|
},
|
|
},
|
|
};
|
|
|
|
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 enabled account but the caller: id, username, display name, avatar time.",
|
|
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 },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
},
|
|
...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}": {
|
|
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}/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}/read": {
|
|
post: {
|
|
tags: ["Chat"],
|
|
summary: "Mark a room read up to a message",
|
|
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 };
|