You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
232 lines
6.9 KiB
232 lines
6.9 KiB
/**
|
|
* Chat Service — DB-persisted pub/sub for the public chat room.
|
|
*
|
|
* Messages are stored in SQLite (survives server restarts).
|
|
* SSE subscribers are tracked so new messages are broadcast in real-time.
|
|
*/
|
|
import { assertUnderRateLimit } from "#server/utils/simple-rate-limit"
|
|
import { sendMail } from "#server/service/email"
|
|
import { dbGlobal } from "drizzle-pkg/lib/db"
|
|
import { chatMessages } from "drizzle-pkg/lib/schema/content"
|
|
import { desc, sql } from "drizzle-orm"
|
|
import { env } from "env"
|
|
|
|
// ── Types ──
|
|
export interface ChatMessage {
|
|
id: number
|
|
nickname: string
|
|
content: string
|
|
createdAt: number
|
|
/** ID of the user who sent this message */
|
|
userId?: number
|
|
clientId?: string
|
|
/** Sent by the author (admin) account */
|
|
isAuthor?: boolean
|
|
/** @mention triggered an email notification */
|
|
mentionNotified?: boolean
|
|
/** @mention would have triggered email but author was online — skipped */
|
|
mentionSkipped?: boolean
|
|
}
|
|
|
|
// ── Config ──
|
|
const MAX_MESSAGES = 500
|
|
const NICKNAME_PREFIXES = ['小', '大', '阿', '老', '' as string]
|
|
const NICKNAME_SUFFIXES = ['白', '黑', '花', '黄', '蓝', '云', '风', '月', '星', '龙', '虎', '猫', '狗', '鹿', '鹏']
|
|
|
|
function randomNickname(): string {
|
|
const prefix = NICKNAME_PREFIXES[Math.floor(Math.random() * NICKNAME_PREFIXES.length)]
|
|
const suffix = NICKNAME_SUFFIXES[Math.floor(Math.random() * NICKNAME_SUFFIXES.length)]
|
|
return `${prefix}${suffix}`
|
|
}
|
|
|
|
// ── @mention → email mapping ──
|
|
const MENTION_EMAIL_MAP: Record<string, string> = {
|
|
'作者': '1549469775@qq.com',
|
|
}
|
|
|
|
function parseMentions(content: string): string[] {
|
|
const matches = content.match(/@(\S+)/g)
|
|
if (!matches) return []
|
|
return matches.map(m => m.slice(1))
|
|
}
|
|
|
|
// ── SSE subscribers ──
|
|
type SSECallback = (msg: ChatMessage | string) => void
|
|
const subscribers = new Set<SSECallback>()
|
|
|
|
// ── User online tracking (userId → connection count) ──
|
|
const userConnections = new Map<string, number>()
|
|
|
|
export function userConnected(userId: string) {
|
|
userConnections.set(userId, (userConnections.get(userId) || 0) + 1)
|
|
}
|
|
|
|
export function userDisconnected(userId: string) {
|
|
const count = userConnections.get(userId) || 0
|
|
if (count <= 1) userConnections.delete(userId)
|
|
else userConnections.set(userId, count - 1)
|
|
}
|
|
|
|
export function isUserOnline(userId: string): boolean {
|
|
return (userConnections.get(userId) || 0) > 0
|
|
}
|
|
|
|
// ── Unread @mention tracking (userId → count) ──
|
|
const unreadCounts = new Map<string, number>()
|
|
|
|
export function getUnreadCount(userId: string): number {
|
|
return unreadCounts.get(userId) || 0
|
|
}
|
|
|
|
export function clearUnread(userId: string) {
|
|
unreadCounts.delete(userId)
|
|
}
|
|
|
|
// ── Author online tracking ──
|
|
let authorConnectionCount = 0
|
|
|
|
export function authorConnected() {
|
|
const wasOffline = authorConnectionCount === 0
|
|
authorConnectionCount++
|
|
if (wasOffline) broadcastStatus('authorOnline', true)
|
|
}
|
|
|
|
export function authorDisconnected() {
|
|
if (authorConnectionCount > 0) authorConnectionCount--
|
|
if (authorConnectionCount === 0) broadcastStatus('authorOnline', false)
|
|
}
|
|
|
|
function broadcastStatus(type: string, value: any) {
|
|
const evt = JSON.stringify({ type, data: value })
|
|
for (const cb of subscribers) {
|
|
try { cb(evt as any) } catch {}
|
|
}
|
|
}
|
|
|
|
export function isAuthorOnline(): boolean {
|
|
return authorConnectionCount > 0
|
|
}
|
|
|
|
// ── Public API ──
|
|
|
|
/** Get recent messages (newest last) from DB. Pass beforeId for cursor pagination. */
|
|
export async function getMessages(limit = 20, beforeId?: number): Promise<ChatMessage[]> {
|
|
let query = dbGlobal
|
|
.select()
|
|
.from(chatMessages)
|
|
.orderBy(desc(chatMessages.createdAt))
|
|
.limit(limit)
|
|
|
|
if (beforeId) {
|
|
query = query.where(sql`${chatMessages.id} < ${beforeId}`)
|
|
}
|
|
|
|
const rows = await query
|
|
return rows.reverse().map(r => ({
|
|
id: r.id,
|
|
nickname: r.nickname,
|
|
content: r.content,
|
|
createdAt: r.createdAt instanceof Date ? r.createdAt.getTime() : r.createdAt,
|
|
userId: r.userId ?? undefined,
|
|
clientId: r.clientId ?? undefined,
|
|
}))
|
|
}
|
|
|
|
/**
|
|
* Send a message to the chat room.
|
|
*/
|
|
export async function sendMessage(
|
|
content: string, ip: string, nickname?: string, clientId?: string, isAuthor?: boolean, userId?: number
|
|
): Promise<ChatMessage> {
|
|
assertUnderRateLimit(`chat:${ip}`, 10, 10_000)
|
|
|
|
const trimmed = content.trim()
|
|
if (!trimmed || trimmed.length > 500) {
|
|
throw createError({
|
|
statusCode: 400,
|
|
statusMessage: trimmed ? '消息不能超过 500 字' : '消息不能为空',
|
|
})
|
|
}
|
|
|
|
const displayNickname = isAuthor ? '作者' : (nickname?.trim() || randomNickname())
|
|
const now = Date.now()
|
|
|
|
const [inserted] = await dbGlobal
|
|
.insert(chatMessages)
|
|
.values({
|
|
nickname: displayNickname,
|
|
content: trimmed,
|
|
createdAt: new Date(now),
|
|
clientId: clientId || null,
|
|
userId: userId || null,
|
|
})
|
|
.returning({ id: chatMessages.id })
|
|
|
|
const msg: ChatMessage = {
|
|
id: inserted!.id,
|
|
nickname: displayNickname,
|
|
content: trimmed,
|
|
createdAt: now,
|
|
userId: userId || undefined,
|
|
clientId: clientId || undefined,
|
|
isAuthor: isAuthor || undefined,
|
|
}
|
|
|
|
// ── Handle @mentions → email notification + unread tracking ──
|
|
const mentions = parseMentions(trimmed)
|
|
const hasSubscribers = subscribers.size > 0
|
|
|
|
for (const mention of mentions) {
|
|
const email = MENTION_EMAIL_MAP[mention]
|
|
if (email) {
|
|
if (isAuthorOnline()) {
|
|
msg.mentionSkipped = true
|
|
// Broadcast a live notification to the author via SSE (toast)
|
|
broadcastStatus('mentionNotify', { from: displayNickname, content: trimmed })
|
|
break
|
|
}
|
|
assertUnderRateLimit(`chat-mention:${ip}:${mention}`, 1, 5 * 60_000)
|
|
sendMail({
|
|
to: email,
|
|
subject: `[聊天室] @${mention} — 来自 ${displayNickname} 的消息`,
|
|
text: `有人在聊天室 @${mention} 了你:\n\n"${trimmed}"\n\n—— ${displayNickname}\n时间:${new Date(now).toLocaleString('zh-CN')}\n\n网站:${env.APP_URL || '(未配置)'}`,
|
|
}).catch(err => {
|
|
console.error('Failed to send @mention email:', err)
|
|
})
|
|
msg.mentionNotified = true
|
|
break
|
|
}
|
|
|
|
// For any @mention: if no SSE subscribers at all, increment global unread
|
|
if (!hasSubscribers) {
|
|
const current = unreadCounts.get('_global') || 0
|
|
unreadCounts.set('_global', current + 1)
|
|
break
|
|
}
|
|
}
|
|
|
|
pruneMessages()
|
|
|
|
for (const cb of subscribers) {
|
|
try { cb(msg) } catch { /* drop broken subscriber */ }
|
|
}
|
|
|
|
return msg
|
|
}
|
|
|
|
async function pruneMessages() {
|
|
const [{ count }] = await dbGlobal
|
|
.select({ count: sql<number>`count(*)` })
|
|
.from(chatMessages)
|
|
if (count > MAX_MESSAGES) {
|
|
const excess = count - MAX_MESSAGES
|
|
await dbGlobal.run(
|
|
sql`DELETE FROM chat_messages WHERE id IN (SELECT id FROM chat_messages ORDER BY created_at ASC LIMIT ${excess})`
|
|
)
|
|
}
|
|
}
|
|
|
|
export function subscribe(cb: SSECallback): () => void {
|
|
subscribers.add(cb)
|
|
return () => { subscribers.delete(cb) }
|
|
}
|
|
|