Files
gerbeur/api/services/chat-service.ts
khannurien 134d1a0b97 v4: a moderator's or an admin's @everyone in the chat notifies every user — there was no way to reach everyone at once. The new chat:everyone permission, held by moderators and admins, lets a chat message saying @everyone notify every user but its author and those reading the chat, through notifyEveryone, after notifyMentions, so a user also named is told once, by name; the notification is a user_mentioned with everyone set, which the notifications page and the digest word as "mentioned everyone in the chat". chat_messages.mentions_everyone, sent as ChatMessage.mentionsEveryone, is set from the author's role each time the body is written, so a user's @everyone reaches no one, a moderator's edit of a user's message can't make it reach everyone, and a demoted author's next edit loses it. Only an edit that adds @everyone notifies, so fixing an announcement's typo reaches no one new. The client highlights and chimes by the flag, never by the text; Markdown shows @everyone as a mention only in a flagged message, and never links it to a profile. The chat composer offers @everyone first to those who may say it. No account may be named everyone, in any case: migration 0023 adds the column, flags the moderators' and admins' past @everyone messages, renames a user named everyone to everyone_N and drops an alias everyone. 265 backend tests and 681 frontend tests pass, and deno check, tsc, eslint, doc:lint and i18n:compile --strict are clean; checked in screenshots of the chat at 1280 and 390px, with a moderator's and a user's @everyone and the suggestions open.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:54:09 +00:00

333 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Stores the site-wide chat's messages and each user's read marker.
*
* The WebSocket route (`routes/ws.ts`) writes messages and moves read markers,
* then broadcasts the result. `routes/chat.ts` reads the history, a page at a
* time.
*
* @module
*/
import { APIException } from "../lib/api-exception.ts";
import type { ChatMessage } from "../../shared/chat.ts";
import { chatMessageRowToApi, isChatMessageRow } from "../model/chat.ts";
import { db } from "../db/connection.ts";
import { linkAttachments } from "./attachment-service.ts";
import { VALIDATION } from "../../shared/validation.ts";
import { mentionsEveryone } from "../../shared/mention.ts";
import { hasPermission } from "../../shared/permissions.ts";
import { isRole } from "../../shared/user.ts";
const DEFAULT_LIMIT = 50;
const MAX_LIMIT = 100;
/**
* How much of the replied-to message a reply carries. The client shows one line
* of it, once stripped of its Markdown, so the cut leaves room for a long link
* or image URL before the text.
*/
const REPLY_SNIPPET_LEN = 300;
/** The columns of a `ChatMessageRow`, to select over {@link FROM_JOINS}. */
const SELECT_COLS =
`m.id, m.user_id, m.body, m.created_at, m.updated_at, m.reply_to_id,
m.mentions_everyone, u.username as author_username, u.avatar_mime as author_avatar_mime,
ru.username as reply_to_author, substr(rm.body, 1, ${REPLY_SNIPPET_LEN}) as reply_to_body`;
/**
* Joins a message to its author, and to the message it replies to and that
* message's author. Those two are left joins: a reply outlives its target.
*/
const FROM_JOINS = `FROM chat_messages m
JOIN users u ON m.user_id = u.id
LEFT JOIN chat_messages rm ON m.reply_to_id = rm.id
LEFT JOIN users ru ON rm.user_id = ru.id`;
/** Returns message `id` as the broadcast sends it, or throws a 404. */
function fetchMessage(id: string): ChatMessage {
const row = db.prepare(
`SELECT ${SELECT_COLS} ${FROM_JOINS} WHERE m.id = ?;`,
).get(id);
if (!row || !isChatMessageRow(row)) {
throw new APIException(404, "Message not found");
}
return chatMessageRowToApi(row);
}
/**
* Returns 1 when `body`, written by `authorId`, reaches everyone: it says
* `@everyone` and the author's role, read now, has `chat:everyone`. Else 0,
* for `chat_messages.mentions_everyone`.
*/
function everyoneFlag(authorId: string, body: string): number {
if (!mentionsEveryone(body)) return 0;
const row = db.prepare(`SELECT role FROM users WHERE id = ?;`).get(
authorId,
) as { role: unknown } | undefined;
return row && isRole(row.role) && hasPermission(row.role, "chat:everyone")
? 1
: 0;
}
/**
* Stores a message from `userId` on a `chat_send`, and claims the pasted images
* it links to. A `replyToId` naming no message is dropped, and the message is
* posted as no reply. The message `mentionsEveryone` when it says `@everyone`
* and `userId` may. The caller broadcasts the result, and notifies everyone
* when it is flagged.
*
* @throws {APIException} 400 when the body is blank or longer than
* `VALIDATION.CHAT_MESSAGE_MAX`.
*/
export function createChatMessage(
userId: string,
body: string,
replyToId?: string,
): ChatMessage {
const trimmed = body.trim();
if (!trimmed) {
throw new APIException(400, "Message cannot be empty");
}
if (trimmed.length > VALIDATION.CHAT_MESSAGE_MAX) {
throw new APIException(
400,
`Message exceeds ${VALIDATION.CHAT_MESSAGE_MAX} characters`,
);
}
let replyTo: string | null = null;
if (replyToId) {
const exists = db.prepare(`SELECT 1 FROM chat_messages WHERE id = ?;`).get(
replyToId,
);
if (exists) replyTo = replyToId;
}
const id = crypto.randomUUID();
const createdAt = new Date().toISOString();
db.prepare(
`INSERT INTO chat_messages (id, user_id, body, created_at, reply_to_id, mentions_everyone)
VALUES (?, ?, ?, ?, ?, ?);`,
).run(id, userId, trimmed, createdAt, replyTo, everyoneFlag(userId, trimmed));
// Unclaimed attachments are deleted as orphans at startup.
linkAttachments(trimmed, "chat", id);
return fetchMessage(id);
}
/** Returns the id of message `id`'s author, or throws a 404. */
function getMessageOwner(id: string): string {
const row = db.prepare(`SELECT user_id FROM chat_messages WHERE id = ?;`).get(
id,
) as { user_id: string } | undefined;
if (!row) {
throw new APIException(404, "Message not found");
}
return row.user_id;
}
/**
* Checks whether message `id` is flagged `mentionsEveryone`, false when there
* is no such message. The WebSocket route reads it before an edit, to notify
* everyone only when the edit adds `@everyone`.
*/
export function chatMessageMentionsEveryone(id: string): boolean {
const row = db.prepare(
`SELECT mentions_everyone FROM chat_messages WHERE id = ?;`,
).get(id) as { mentions_everyone: number } | undefined;
return row?.mentions_everyone === 1;
}
/**
* Replaces a message's body on a `chat_edit`, and claims any image the new body
* links to. `canModerate` lets a moderator edit anyone's message. The flag
* `mentionsEveryone` is set again from the new body and the author's role, not
* the editor's: a moderator's edit can't make a user's message reach everyone.
* The caller broadcasts the result.
*
* @throws {APIException} 404 when there is no such message, 403 when the user
* is neither its author nor a moderator, 400 when the body is blank or too
* long.
*/
export function updateChatMessage(
id: string,
body: string,
requestingUserId: string,
canModerate: boolean,
): ChatMessage {
const ownerId = getMessageOwner(id);
if (ownerId !== requestingUserId && !canModerate) {
throw new APIException(403, "Not authorized to edit this message");
}
const trimmed = body.trim();
if (!trimmed) {
throw new APIException(400, "Message cannot be empty");
}
if (trimmed.length > VALIDATION.CHAT_MESSAGE_MAX) {
throw new APIException(
400,
`Message exceeds ${VALIDATION.CHAT_MESSAGE_MAX} characters`,
);
}
db.prepare(
`UPDATE chat_messages SET body = ?, updated_at = ?, mentions_everyone = ?
WHERE id = ?;`,
).run(trimmed, new Date().toISOString(), everyoneFlag(ownerId, trimmed), id);
linkAttachments(trimmed, "chat", id);
return fetchMessage(id);
}
/**
* Deletes a message on a `chat_delete`. `canModerate` lets a moderator delete
* anyone's message. Replies to it keep their `replyToId`, without its preview.
*
* @throws {APIException} 404 when there is no such message, 403 when the user
* is neither its author nor a moderator.
*/
export function deleteChatMessage(
id: string,
requestingUserId: string,
canModerate: boolean,
): void {
const ownerId = getMessageOwner(id);
if (ownerId !== requestingUserId && !canModerate) {
throw new APIException(403, "Not authorized to delete this message");
}
db.prepare(`DELETE FROM chat_messages WHERE id = ?;`).run(id);
}
/**
* Passed by `routes/chat.ts` to page through the history, from the message the
* client already has at one end. Messages are ordered by `createdAt`, then by
* id, so a page boundary between two messages sent in the same millisecond
* skips neither.
*/
export interface ChatCursor {
/** An ISO timestamp, compared as a string with `created_at`. */
createdAt: string;
/**
* Without it, the cursor takes in every message at `createdAt`: they are
* all older for {@link getRecentChatMessages} and all newer for
* {@link getChatMessagesAfter}.
*/
id?: string;
}
/** Converts query rows to messages, or throws a 500 if one doesn't parse. */
function toMessages(rows: unknown[]): ChatMessage[] {
if (!rows.every(isChatMessageRow)) {
throw new APIException(500, "Malformed chat message data");
}
return rows.map(chatMessageRowToApi);
}
function capLimit(limit: number): number {
return Math.min(Math.max(Math.floor(limit), 1), MAX_LIMIT);
}
/**
* Returns the latest `limit` messages, or with `before` the `limit` messages
* just older than it, oldest first either way. `limit` is clamped to 1–100.
*
* @throws {APIException} 500 when a row doesn't parse as a message.
*/
export function getRecentChatMessages(
limit = DEFAULT_LIMIT,
before?: ChatCursor,
): ChatMessage[] {
const capped = capLimit(limit);
const rows = before
? db.prepare(
`SELECT ${SELECT_COLS} ${FROM_JOINS}
WHERE m.created_at < ? OR (m.created_at = ? AND m.id < ?)
ORDER BY m.created_at DESC, m.id DESC LIMIT ?;`,
).all(
before.createdAt,
before.createdAt,
// Nothing sorts below "", so no message at that timestamp is taken.
before.id ?? "",
capped,
)
: db.prepare(
`SELECT ${SELECT_COLS} ${FROM_JOINS}
ORDER BY m.created_at DESC, m.id DESC LIMIT ?;`,
).all(capped);
return toMessages(rows).reverse();
}
/**
* Returns the first `limit` messages after `after`, oldest first. The chatbox
* uses it to fetch what it missed while the socket was down. `limit` is
* clamped to 1–100.
*
* @throws {APIException} 500 when a row doesn't parse as a message.
*/
export function getChatMessagesAfter(
after: ChatCursor,
limit = DEFAULT_LIMIT,
): ChatMessage[] {
const rows = db.prepare(
`SELECT ${SELECT_COLS} ${FROM_JOINS}
WHERE m.created_at > ? OR (m.created_at = ? AND m.id > ?)
ORDER BY m.created_at ASC, m.id ASC LIMIT ?;`,
).all(after.createdAt, after.createdAt, after.id ?? "", capLimit(limit));
return toMessages(rows);
}
/**
* Returned to the WebSocket route by {@link getChatReadState} and
* {@link markChatRead}. The route sends it in `welcome` and in
* `chat_read_update`, and the client shows `unread` on the chat's badge.
*/
export interface ChatReadState {
/** The `created_at` of the newest message the user has read. */
readAt: string;
/** Counts only the messages of other users after `readAt`. */
unread: number;
}
/**
* Returns how far `userId` has read the chat. A user who never opened it has
* read up to their registration, so the history from before it isn't unread.
*
* @throws {APIException} 404 when there is no such user.
*/
export function getChatReadState(userId: string): ChatReadState {
const row = db.prepare(
`SELECT COALESCE(u.chat_read_at, u.created_at) AS read_at,
(SELECT COUNT(*) FROM chat_messages m
WHERE m.user_id != u.id
AND m.created_at > COALESCE(u.chat_read_at, u.created_at)) AS unread
FROM users u WHERE u.id = ?;`,
).get(userId) as { read_at: string; unread: number } | undefined;
if (!row) {
throw new APIException(404, "User not found");
}
return { readAt: row.read_at, unread: row.unread };
}
/**
* Moves `userId`'s read marker to `at`, a message's `created_at`, on a
* `chat_read`. It never moves back, so a lagging tab can't undo a newer tab's
* read. It never passes the newest message, so a timestamp from the future
* can't mark messages read before they are sent.
*
* @throws {APIException} 400 when `at` isn't a date, 404 when there is no
* such user.
*/
export function markChatRead(userId: string, at: string): ChatReadState {
const parsed = new Date(at);
if (Number.isNaN(parsed.getTime())) {
throw new APIException(400, "Invalid read marker");
}
const newest = db.prepare(
`SELECT MAX(created_at) AS newest FROM chat_messages;`,
).get() as { newest: string | null };
// With no message to cap it at, a marker from the future would stay stuck.
if (!newest.newest) return getChatReadState(userId);
const iso = parsed.toISOString();
const target = iso > newest.newest ? newest.newest : iso;
db.prepare(
`UPDATE users SET chat_read_at = ?
WHERE id = ? AND (chat_read_at IS NULL OR chat_read_at < ?);`,
).run(target, userId, target);
return getChatReadState(userId);
}