Files
AgentHub/server/openapi-extra/chat.js
T
nntrivi2001 21a042dc2b 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).
2026-11-03 09:33:33 +07:00

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 };