feat(chat): direct chats and groups between users in the pet's window
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).
This commit is contained in:
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* @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 };
|
||||
Reference in New Issue
Block a user