diff --git a/src/server/chat-service.ts b/src/server/chat-service.ts index 191d120..e36e5a7 100644 --- a/src/server/chat-service.ts +++ b/src/server/chat-service.ts @@ -11,10 +11,12 @@ import type { ChatConfig, ConversationSummary, HistoryItem, + HistoryRecordInput, LoggerLike, Persona, UnknownRecord } from '../types'; +import type { SteamFriendMessageEvent, SteamFriendMessageEventUser } from '../steam/friend-message-events'; import { errorCode, errorMessage, isRecord } from '../types'; const fs = require('node:fs/promises'); @@ -35,6 +37,9 @@ const { readHistory, steamIdToString } = require('../storage/chat-log'); +const { + subscribeFriendMessageEvents +} = require('../steam/friend-message-events'); const { IMAGE_CACHE_DIR, STICKER_CACHE_DIR, @@ -54,6 +59,9 @@ type Waiter = Promise | (() => Promise | unknown); type SteamChatApi = { sendFriendMessage?: CallbackStyleFunction; getEmoticonList?: CallbackStyleFunction; + on?: (event: 'friendMessage' | 'friendMessageEcho', listener: (...args: unknown[]) => void) => void; + off?: (event: 'friendMessage' | 'friendMessageEcho', listener: (...args: unknown[]) => void) => void; + removeListener?: (event: 'friendMessage' | 'friendMessageEcho', listener: (...args: unknown[]) => void) => void; }; type SteamUserLike = { @@ -61,6 +69,8 @@ type SteamUserLike = { sendFriendMessage?: CallbackStyleFunction; getEmoticonList?: CallbackStyleFunction; on?: (event: 'friendMessage' | 'friendMessageEcho', listener: (...args: unknown[]) => void) => void; + off?: (event: 'friendMessage' | 'friendMessageEcho', listener: (...args: unknown[]) => void) => void; + removeListener?: (event: 'friendMessage' | 'friendMessageEcho', listener: (...args: unknown[]) => void) => void; myFriends?: UnknownRecord; users?: Record; myGroups?: unknown; @@ -409,7 +419,7 @@ function createChatService(options: ChatServiceOptions = {}) { const legacyAuth = createAuthChecker(config.auth); const clients = new Set(); const recentSentText = new Map(); - const recentSentImages = new Map(); + let disposeSteamEvents = () => {}; const server: Server = options.server || http.createServer(handleHttpRequest); const wss: WsServer = new WebSocketServer({ noServer: true }); @@ -624,20 +634,25 @@ function createChatService(options: ChatServiceOptions = {}) { throw new Error('Steam chat sender is unavailable'); } const message = String(msg); - await withSteamRetry(() => { + const result = await withSteamRetry(() => { const sender = steamUser.chat?.sendFriendMessage || steamUser.sendFriendMessage; const context = steamUser.chat?.sendFriendMessage ? steamUser.chat : steamUser; if (!sender) throw new Error('Steam chat sender is unavailable'); return callMaybeCallback(sender, context, [id, message]); }); remember(recentSentText, `${id}:${message}`); - const item = await appendLog({ + const record: HistoryRecordInput = { type: 'message', echo: true, id, name: await getSelfName(), message - }, { logPath }); + }; + if (isRecord(result)) { + if (typeof result.ordinal === 'string' || typeof result.ordinal === 'number') record.ordinal = result.ordinal; + if (result.server_timestamp instanceof Date) record.sentAt = result.server_timestamp.toISOString(); + } + const item = await appendLog(record, { logPath }); broadcast({ type: 'message', ...item }); return item; } @@ -648,64 +663,54 @@ function createChatService(options: ChatServiceOptions = {}) { throw new Error('Steam image sender is unavailable'); } let imageBuffer: Buffer; - let imageUrl: string | null = body.url || null; + let message = body.url || ''; if (body.img) { imageBuffer = decodeBase64Image(body.img); } else if (body.url) { const downloaded = await loadOrDownloadRemoteImage(body.url, { fetchImpl }); imageBuffer = downloaded.buffer; - remember(recentSentImages, body.url); } else { throw Object.assign(new Error('img or url is required'), { statusCode: 400 }); } const imageArgs = steamCommunity.sendImageToUser.length >= 4 ? [id, imageBuffer, 'image.png'] : [id, imageBuffer]; const result = await withSteamRetry(() => callMaybeCallback(steamCommunity.sendImageToUser, steamCommunity, imageArgs), true); - if (!imageUrl && isRecord(result) && typeof result.url === 'string') imageUrl = result.url; - if (imageUrl) remember(recentSentImages, imageUrl); - const item = await appendLog({ - type: 'image', + if (!message && isRecord(result) && typeof result.url === 'string') message = result.url; + const item = normalizeHistoryItem({ + type: 'message', echo: true, id, name: await getSelfName(), - message: '', - imageUrl, - sentAt: new Date().toISOString() - }, { logPath }); + message, + ordinal: isRecord(result) && (typeof result.ordinal === 'string' || typeof result.ordinal === 'number') ? result.ordinal : 0 + }); return item; } - function containsRecentImageEcho(message: unknown): boolean { - const text = String(message || ''); - for (const url of recentSentImages.keys()) { - if (text.includes(url)) return true; - } - return false; - } - - async function handleSteamIncoming(steamID: unknown, message: unknown, type?: unknown, chatter?: unknown, ordinal?: unknown) { - const id = steamIdToString(steamID); - if (containsRecentImageEcho(message)) return; - const info = await getUserInfo(steamID).catch((): Persona => ({ player_name: id })); + async function handleSteamIncoming(event: SteamFriendMessageEvent) { + const id = event.id; + const info = await getUserInfo(event.steamID || id).catch((): Persona => ({ player_name: id })); const item = normalizeHistoryItem({ type: 'message', id, name: info.player_name || info.personaName || id, - message: typeof message === 'string' ? message : '', - ordinal: typeof ordinal === 'string' || typeof ordinal === 'number' ? ordinal : null + message: event.message, + ordinal: event.ordinal ?? 0, + date: event.serverTimestamp ? formatDate(event.serverTimestamp) : undefined }); broadcast({ type: 'message', ...item }); } - async function handleSteamEcho(steamID: unknown, message: unknown, ordinal?: unknown) { - const id = steamIdToString(steamID); - if (isRecent(recentSentText, `${id}:${message}`) || containsRecentImageEcho(message)) return; + async function handleSteamEcho(event: SteamFriendMessageEvent) { + const id = event.id; + if (isRecent(recentSentText, `${id}:${event.message}`) || isRecent(recentSentText, `${id}:${event.compatibilityMessage}`)) return; const item = normalizeHistoryItem({ type: 'message', echo: true, id, name: await getSelfName(), - message: typeof message === 'string' ? message : '', - ordinal: typeof ordinal === 'string' || typeof ordinal === 'number' ? ordinal : null + message: event.message, + ordinal: event.ordinal ?? 0, + date: event.serverTimestamp ? formatDate(event.serverTimestamp) : undefined }); broadcast({ type: 'message', ...item }); } @@ -794,7 +799,6 @@ function createChatService(options: ChatServiceOptions = {}) { if (req.method === 'POST' && (url.pathname === '/image' || url.pathname === '/img')) { const body = await readJsonBody(req); const item = await sendImageMessage(body.id, body); - broadcast({ type: 'image', ...item }); jsonResponse(res, 200, { ok: true, item }); return; } @@ -831,7 +835,6 @@ function createChatService(options: ChatServiceOptions = {}) { if (type === 'send_image' || type === 'img') { const item = await sendImageMessage(payload.id, payload); reply({ type: 'image_sent', item }); - broadcast({ type: 'image', ...item }, ws); return; } if (type === 'get_history' || type === 'history') { @@ -909,15 +912,11 @@ function createChatService(options: ChatServiceOptions = {}) { ws.on('error', () => clients.delete(ws)); }); - if (steamUser?.on) { - steamUser.on('friendMessage', (steamID: unknown, message: unknown, type?: unknown, chatter?: unknown, ordinal?: unknown) => { - handleSteamIncoming(steamID, message, type, chatter, ordinal).catch((error) => { - logger.warn?.('Failed to broadcast Steam message', { error: errorMessage(error) }); - }); - }); - steamUser.on('friendMessageEcho', (steamID: unknown, message: unknown, ordinal?: unknown) => { - handleSteamEcho(steamID, message, ordinal).catch((error) => { - logger.warn?.('Failed to broadcast Steam echo', { error: errorMessage(error) }); + if (steamUser) { + disposeSteamEvents = subscribeFriendMessageEvents(steamUser as SteamFriendMessageEventUser, (event: SteamFriendMessageEvent) => { + const task = event.echo ? handleSteamEcho(event) : handleSteamIncoming(event); + task.catch((error) => { + logger.warn?.('Failed to broadcast Steam message', { id: event.id, error: errorMessage(error) }); }); }); } @@ -935,6 +934,7 @@ function createChatService(options: ChatServiceOptions = {}) { return server; }, stop() { + disposeSteamEvents(); for (const ws of clients) ws.close(); wss.close(); return new Promise((resolve, reject) => { diff --git a/src/steam/friend-message-events.ts b/src/steam/friend-message-events.ts new file mode 100644 index 0000000..ff4324f --- /dev/null +++ b/src/steam/friend-message-events.ts @@ -0,0 +1,150 @@ +'use strict'; + +import type { UnknownRecord } from '../types'; +import { isRecord } from '../types'; + +const { + steamIdToString +} = require('../storage/chat-log'); + +type FriendMessageEventName = 'friendMessage' | 'friendMessageEcho'; + +type FriendMessageEmitter = { + on?: (event: FriendMessageEventName, listener: (...args: unknown[]) => void) => void; + off?: (event: FriendMessageEventName, listener: (...args: unknown[]) => void) => void; + removeListener?: (event: FriendMessageEventName, listener: (...args: unknown[]) => void) => void; +}; + +export type SteamFriendMessageEvent = { + id: string; + steamID: unknown; + echo: boolean; + message: string; + compatibilityMessage: string; + ordinal?: string | number; + serverTimestamp?: Date; + source: 'chat' | 'legacy'; + raw: unknown; +}; + +export type SteamFriendMessageEventUser = FriendMessageEmitter & { + chat?: FriendMessageEmitter; +}; + +type SteamFriendMessageEventHandler = (event: SteamFriendMessageEvent) => void; + +function optionalOrdinal(value: unknown): string | number | undefined { + return typeof value === 'string' || typeof value === 'number' ? value : undefined; +} + +function optionalDate(value: unknown): Date | undefined { + if (value instanceof Date) return value; + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined; + return new Date(value > 1000000000000 ? value : value * 1000); +} + +function messageFromRecord(record: UnknownRecord): string { + if (typeof record.message === 'string') return record.message; + return typeof record.message_no_bbcode === 'string' ? record.message_no_bbcode : ''; +} + +function compatibilityMessageFromRecord(record: UnknownRecord, message: string): string { + return typeof record.message_no_bbcode === 'string' ? record.message_no_bbcode : message; +} + +function legacyCompatibilityKey(eventName: FriendMessageEventName, event: Pick): string { + return `${eventName}\0${event.id}\0${event.compatibilityMessage}`; +} + +function incrementPendingLegacy(map: Map, key: string) { + map.set(key, (map.get(key) || 0) + 1); + setTimeout(() => { + const next = (map.get(key) || 0) - 1; + if (next > 0) map.set(key, next); + else map.delete(key); + }, 1000).unref?.(); +} + +function consumePendingLegacy(map: Map, key: string): boolean { + const count = map.get(key) || 0; + if (count <= 0) return false; + if (count === 1) map.delete(key); + else map.set(key, count - 1); + return true; +} + +function chatEventFromBody(eventName: FriendMessageEventName, body: unknown): SteamFriendMessageEvent | null { + if (!isRecord(body)) return null; + const steamID = body.steamid_friend || body.steamID || body.steamid; + const id = steamIdToString(steamID); + if (!id) return null; + const message = messageFromRecord(body); + return { + id, + steamID, + echo: eventName === 'friendMessageEcho' || body.local_echo === true, + message, + compatibilityMessage: compatibilityMessageFromRecord(body, message), + ordinal: optionalOrdinal(body.ordinal), + serverTimestamp: optionalDate(body.server_timestamp || body.timestamp), + source: 'chat', + raw: body + }; +} + +function legacyEventFromArgs(eventName: FriendMessageEventName, args: unknown[]): SteamFriendMessageEvent | null { + const [steamID, message] = args; + const ordinal = eventName === 'friendMessageEcho' ? args[2] : args[4]; + const id = steamIdToString(steamID); + if (!id) return null; + const text = typeof message === 'string' ? message : ''; + return { + id, + steamID, + echo: eventName === 'friendMessageEcho', + message: text, + compatibilityMessage: text, + ordinal: optionalOrdinal(ordinal), + source: 'legacy', + raw: args + }; +} + +function subscribe(emitter: FriendMessageEmitter | undefined, eventName: FriendMessageEventName, listener: (...args: unknown[]) => void) { + if (typeof emitter?.on !== 'function') return () => {}; + emitter.on(eventName, listener); + return () => { + if (typeof emitter.off === 'function') emitter.off(eventName, listener); + else emitter.removeListener?.(eventName, listener); + }; +} + +function subscribeFriendMessageEvents(steamUser: SteamFriendMessageEventUser, handler: SteamFriendMessageEventHandler) { + const pendingLegacy = new Map(); + const disposers: Array<() => void> = []; + + for (const eventName of ['friendMessage', 'friendMessageEcho'] as FriendMessageEventName[]) { + disposers.push(subscribe(steamUser.chat, eventName, (body: unknown) => { + const event = chatEventFromBody(eventName, body); + if (!event) return; + incrementPendingLegacy(pendingLegacy, legacyCompatibilityKey(eventName, event)); + handler(event); + })); + + disposers.push(subscribe(steamUser, eventName, (...args: unknown[]) => { + const event = legacyEventFromArgs(eventName, args); + if (!event) return; + if (consumePendingLegacy(pendingLegacy, legacyCompatibilityKey(eventName, event))) return; + handler(event); + })); + } + + return () => { + for (const dispose of disposers) dispose(); + pendingLegacy.clear(); + }; +} + +module.exports = { + subscribeFriendMessageEvents +}; diff --git a/src/steam/message-logger.ts b/src/steam/message-logger.ts index bb834b6..1e5865a 100644 --- a/src/steam/message-logger.ts +++ b/src/steam/message-logger.ts @@ -1,6 +1,7 @@ 'use strict'; import type { LoggerLike, Persona } from '../types'; +import type { SteamFriendMessageEvent, SteamFriendMessageEventUser } from './friend-message-events'; import { errorMessage } from '../types'; const { @@ -9,24 +10,35 @@ const { formatDate, steamIdToString } = require('../storage/chat-log'); +const { + subscribeFriendMessageEvents +} = require('./friend-message-events'); type SteamHistoryMessage = { - imageUrl?: string | null; accountid?: string | number; message?: string; ordinal?: string | number | null; + steamID?: unknown; timestamp?: number; }; -type SteamMessageLoggerUser = { - on: { - (event: 'friendMessage', listener: (steamID: unknown, message: unknown, type?: unknown, chatter?: unknown, ordinal?: unknown) => void): void; - (event: 'friendMessageEcho', listener: (steamID: unknown, message: unknown, ordinal?: unknown) => void): void; - }; - off?: { - (event: 'friendMessage', listener: (steamID: unknown, message: unknown, type?: unknown, chatter?: unknown, ordinal?: unknown) => void): void; - (event: 'friendMessageEcho', listener: (steamID: unknown, message: unknown, ordinal?: unknown) => void): void; - }; +type SteamFriendHistoryMessage = { + sender?: unknown; + server_timestamp?: Date; + ordinal?: string | number | null; + message?: string; +}; + +type SteamMessageLoggerChat = NonNullable & { + getFriendMessageHistory?: ( + id: string, + options: { maxCount: number; wantBbcode: boolean }, + callback: (error: unknown, response?: { messages?: SteamFriendHistoryMessage[] }) => void + ) => void; +}; + +type SteamMessageLoggerUser = SteamFriendMessageEventUser & { + chat?: SteamMessageLoggerChat; getChatHistory?: (id: string, callback: (error: unknown, messages?: SteamHistoryMessage[]) => void) => void; }; @@ -49,7 +61,7 @@ function createSteamMessageLogger(options: SteamMessageLoggerOptions) { const importAttempts = new Set(); function echoKey(id: string, message: unknown, ordinal: unknown): string { - return `${id}:${ordinal || ''}:${message}`; + return `${id}:${ordinal ?? ''}:${message}`; } function rememberEcho(key: string): boolean { @@ -59,71 +71,105 @@ function createSteamMessageLogger(options: SteamMessageLoggerOptions) { return true; } + async function importFriendMessageHistory(id: string): Promise { + if (typeof steamUser.chat?.getFriendMessageHistory !== 'function') return false; + const response = await new Promise<{ messages?: SteamFriendHistoryMessage[] }>((resolve, reject) => { + steamUser.chat?.getFriendMessageHistory?.(id, { maxCount: 100, wantBbcode: true }, (error: unknown, result?: { messages?: SteamFriendHistoryMessage[] }) => { + if (error) reject(error); + else resolve(result || {}); + }); + }); + const friendInfo = await getUserInfo(id).catch((): Persona => ({ player_name: id })); + const selfName = await getSelfName(); + for (const message of response.messages || []) { + const senderId = steamIdToString(message.sender); + const echo = Boolean(senderId && senderId !== id); + await appendLog({ + echo, + id, + name: echo ? selfName : (friendInfo.player_name || friendInfo.personaName || id), + message: typeof message.message === 'string' ? message.message : '', + ordinal: message.ordinal ?? null, + date: message.server_timestamp instanceof Date ? formatDate(message.server_timestamp) : undefined + }, { logPath }); + } + return true; + } + + async function importLegacyChatHistory(id: string): Promise { + if (typeof steamUser.getChatHistory !== 'function') return false; + const history = await new Promise((resolve) => { + steamUser.getChatHistory?.(id, (error: unknown, messages?: SteamHistoryMessage[]) => resolve(error ? [] : messages || [])); + }); + for (const message of history) { + const senderId = steamIdToString(message.steamID || message.accountid); + const echo = Boolean(senderId && senderId !== id); + await appendLog({ + echo, + id, + name: echo ? await getSelfName() : (message.accountid ? String(message.accountid) : 'Unknown'), + message: message.message || '', + ordinal: message.ordinal ?? null, + date: message.timestamp ? formatDate(new Date(message.timestamp * 1000)) : undefined + }, { logPath }); + } + return true; + } + async function maybeImportSteamHistory(id: string) { - if (!id || importAttempts.has(id) || typeof steamUser.getChatHistory !== 'function') return; + if (!id || importAttempts.has(id)) return; importAttempts.add(id); try { - const history = await new Promise((resolve) => { - steamUser.getChatHistory?.(id, (error: unknown, messages?: SteamHistoryMessage[]) => resolve(error ? [] : messages || [])); - }); - for (const message of history) { - await appendLog({ - type: message.imageUrl ? 'image' : 'message', - id, - name: message.accountid ? String(message.accountid) : 'Unknown', - message: message.message || '', - imageUrl: message.imageUrl || null, - ordinal: message.ordinal ?? null, - date: message.timestamp ? formatDate(new Date(message.timestamp * 1000)) : undefined - }, { logPath }); - } + const imported = await importFriendMessageHistory(id); + if (!imported) await importLegacyChatHistory(id); } catch (error) { - logger.warn?.('Steam history import failed', { id, error: errorMessage(error) }); + try { + await importLegacyChatHistory(id); + } catch (fallbackError) { + logger.warn?.('Steam history import failed', { id, error: errorMessage(fallbackError || error) }); + } } } - const onFriendMessage = async (steamID: unknown, message: unknown, type?: unknown, chatter?: unknown, ordinal?: unknown) => { - const id = steamIdToString(steamID); + const onFriendMessage = async (event: SteamFriendMessageEvent) => { + const id = event.id; try { await maybeImportSteamHistory(id); - const info = await getUserInfo(steamID); + const info = await getUserInfo(event.steamID || id); await appendLog({ - type: 'message', id, name: info.player_name || info.personaName || id, - message: typeof message === 'string' ? message : '', - ordinal: typeof ordinal === 'string' || typeof ordinal === 'number' ? ordinal : null + message: event.message, + ordinal: event.ordinal ?? null, + date: event.serverTimestamp ? formatDate(event.serverTimestamp) : undefined }, { logPath }); } catch (error) { logger.error?.('Failed to log friend message', { id, error: errorMessage(error) }); } }; - const onFriendMessageEcho = async (steamID: unknown, message: unknown, ordinal?: unknown) => { - const id = steamIdToString(steamID); - const key = echoKey(id, message, ordinal); + const onFriendMessageEcho = async (event: SteamFriendMessageEvent) => { + const id = event.id; + const key = echoKey(id, event.message, event.ordinal); if (!rememberEcho(key)) return; try { await appendLog({ - type: 'message', echo: true, id, name: await getSelfName(), - message: typeof message === 'string' ? message : '', - ordinal: typeof ordinal === 'string' || typeof ordinal === 'number' ? ordinal : null + message: event.message, + ordinal: event.ordinal ?? null, + date: event.serverTimestamp ? formatDate(event.serverTimestamp) : undefined }, { logPath }); } catch (error) { logger.error?.('Failed to log echoed message', { id, error: errorMessage(error) }); } }; - steamUser.on('friendMessage', onFriendMessage); - steamUser.on('friendMessageEcho', onFriendMessageEcho); - - return () => { - steamUser.off?.('friendMessage', onFriendMessage); - steamUser.off?.('friendMessageEcho', onFriendMessageEcho); - }; + return subscribeFriendMessageEvents(steamUser, (event: SteamFriendMessageEvent) => { + const task = event.echo ? onFriendMessageEcho(event) : onFriendMessage(event); + task.catch((error) => logger.error?.('Failed to log Steam message event', { id: event.id, error: errorMessage(error) })); + }); } module.exports = { diff --git a/src/storage/chat-log.ts b/src/storage/chat-log.ts index df63f7e..2f7b60c 100644 --- a/src/storage/chat-log.ts +++ b/src/storage/chat-log.ts @@ -9,6 +9,7 @@ const { CHAT_LOG_PATH } = require('../paths'); const DEFAULT_LOG_PATH = CHAT_LOG_PATH; const MAX_HISTORY_LIMIT = 500; +const appendQueues = new Map>(); function formatDate(date = new Date()) { const pad = (value: unknown, width = 2) => String(value).padStart(width, '0'); @@ -60,26 +61,109 @@ function steamIdToString(value: unknown): string { } function normalizeHistoryItem(record: HistoryRecordInput): HistoryItem { + const legacyImageUrl = typeof record.imageUrl === 'string' ? record.imageUrl : ''; + const type = typeof record.type === 'string' ? record.type : (legacyImageUrl ? 'image' : 'message'); + const message = typeof record.message === 'string' ? record.message : ''; const item: HistoryItem = { - type: typeof record.type === 'string' ? record.type : (record.imageUrl ? 'image' : 'message'), + type, date: typeof record.date === 'string' ? record.date : formatDate(record.sentAt ? new Date(record.sentAt) : new Date()), echo: Boolean(record.echo), id: steamIdToString(record.id || record.steamID), name: typeof record.name === 'string' ? record.name : (record.echo ? 'Me' : 'Unknown'), - message: typeof record.message === 'string' ? record.message : '', - imageUrl: typeof record.imageUrl === 'string' ? record.imageUrl : null, + message: type === 'image' && !message && legacyImageUrl ? legacyImageUrl : message, ordinal: typeof record.ordinal === 'string' || typeof record.ordinal === 'number' ? record.ordinal : null }; if (typeof record.sentAt === 'string') item.sentAt = record.sentAt; return item; } +function numericOrdinal(value: unknown): number | null { + if (typeof value !== 'string' && typeof value !== 'number') return null; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function ordinalGroupKey(item: Pick): string { + return `${item.id}\0${parseMessageDate(item)}`; +} + +function usedOrdinalsByGroup(items: HistoryItem[]): Map> { + const used = new Map>(); + for (const item of items) { + const ordinal = numericOrdinal(item.ordinal); + if (ordinal === null) continue; + const key = ordinalGroupKey(item); + const values = used.get(key) || new Set(); + values.add(ordinal); + used.set(key, values); + } + return used; +} + +function nextAvailableOrdinal(used: Set): number { + let ordinal = 0; + while (used.has(ordinal)) ordinal += 1; + return ordinal; +} + +function fillMissingOrdinals(items: HistoryItem[]): HistoryItem[] { + const used = usedOrdinalsByGroup(items); + for (const item of items) { + if (numericOrdinal(item.ordinal) !== null) continue; + const key = ordinalGroupKey(item); + const values = used.get(key) || new Set(); + const ordinal = nextAvailableOrdinal(values); + item.ordinal = ordinal; + values.add(ordinal); + used.set(key, values); + } + return items; +} + +function nextOrdinalForAppend(existing: HistoryItem[], item: HistoryItem): number { + const key = ordinalGroupKey(item); + let max = -1; + for (const record of existing) { + if (ordinalGroupKey(record) !== key) continue; + const ordinal = numericOrdinal(record.ordinal); + if (ordinal !== null) max = Math.max(max, ordinal); + } + return max + 1; +} + +function serializeHistoryLogRecord(source: HistoryRecordInput, item: HistoryItem) { + const record: Record = {}; + if (source.type === 'message') record.type = source.type; + record.date = item.date; + record.echo = item.echo; + record.id = item.id; + record.name = item.name; + record.message = item.message; + record.ordinal = item.ordinal; + return record; +} + +async function appendLogNow(item: HistoryRecordInput, logPath: string): Promise { + const normalized = normalizeHistoryItem(item); + if (numericOrdinal(normalized.ordinal) === null) { + const existing = await readAllLogLines(logPath, { warn() {} }); + normalized.ordinal = nextOrdinalForAppend(existing, normalized); + } + await fs.mkdir(path.dirname(logPath), { recursive: true }); + await fs.appendFile(logPath, `${JSON.stringify(serializeHistoryLogRecord(item, normalized))}\n`, 'utf8'); + return normalized; +} + async function appendLog(item: HistoryRecordInput, options: { logPath?: string } = {}): Promise { const logPath = options.logPath || DEFAULT_LOG_PATH; - const normalized = normalizeHistoryItem(item); - await fs.mkdir(path.dirname(logPath), { recursive: true }); - await fs.appendFile(logPath, `${JSON.stringify(normalized)}\n`, 'utf8'); - return normalized; + const pending = appendQueues.get(logPath) || Promise.resolve(); + const next = pending.catch((): undefined => undefined).then(() => appendLogNow(item, logPath)); + appendQueues.set(logPath, next); + try { + return await next; + } finally { + if (appendQueues.get(logPath) === next) appendQueues.delete(logPath); + } } async function readAllLogLines(logPath: string, logger: LoggerLike = console): Promise { @@ -100,7 +184,7 @@ async function readAllLogLines(logPath: string, logger: LoggerLike = console): P logger.warn?.('Skipping invalid JSONL chat log line', { line: index + 1, error: errorMessage(error) }); } } - return records; + return fillMissingOrdinals(records); } function sortHistoryItems(items: HistoryItem[]) { @@ -152,8 +236,8 @@ function stripMarkup(message: unknown): string { .trim(); } -function previewForMessage(item: Pick): string { - if (item.type === 'image' || item.imageUrl) return '[图片]'; +function previewForMessage(item: Pick): string { + if (item.type === 'image') return '[图片]'; const stickerType = extractStickerType(item.message); if (stickerType) return `[贴纸] ${stickerType}`; const emoticon = isEmoticonOnly(item.message); diff --git a/src/types.ts b/src/types.ts index fee4635..2329094 100644 --- a/src/types.ts +++ b/src/types.ts @@ -48,7 +48,6 @@ export type HistoryRecordInput = UnknownRecord & { steamID?: string | number | SteamIdLike; name?: string; message?: string; - imageUrl?: string | null; ordinal?: string | number | null; sentAt?: string; }; @@ -60,7 +59,6 @@ export type HistoryItem = { id: string; name: string; message: string; - imageUrl: string | null; ordinal: number | string | null; sentAt?: string; }; diff --git a/test/chat.test.ts b/test/chat.test.ts index ec6e472..4b06e42 100644 --- a/test/chat.test.ts +++ b/test/chat.test.ts @@ -29,6 +29,7 @@ type WsMessage = Record & { type WsInbox = { next: () => Promise; + nextWithin: (timeoutMs: number) => Promise; }; type TestSteamUser = EventEmitterType & { @@ -37,6 +38,10 @@ type TestSteamUser = EventEmitterType & { }; }; +type TestSteamCommunity = { + sendImageToUser: (id: unknown, image: Buffer, filename: string, callback: (error: Error | null, result?: unknown) => void) => void; +}; + type ChatServiceRuntime = { server: Server; stop: () => Promise; @@ -75,15 +80,37 @@ function createWsInbox(ws: WsConnection): WsInbox { }); return { next() { - if (queue.length) return Promise.resolve(queue.shift()); + if (queue.length) return Promise.resolve(queue.shift() as WsMessage); return new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error('Timed out waiting for WebSocket message')), 2000); - waiters.push({ + const waiter = { resolve(value: WsMessage) { clearTimeout(timer); resolve(value); } - }); + }; + const timer = setTimeout(() => { + const index = waiters.indexOf(waiter); + if (index >= 0) waiters.splice(index, 1); + reject(new Error('Timed out waiting for WebSocket message')); + }, 2000); + waiters.push(waiter); + }); + }, + nextWithin(timeoutMs: number) { + if (queue.length) return Promise.resolve(queue.shift() as WsMessage); + return new Promise((resolve) => { + const waiter = { + resolve(value: WsMessage) { + clearTimeout(timer); + resolve(value); + } + }; + const timer = setTimeout(() => { + const index = waiters.indexOf(waiter); + if (index >= 0) waiters.splice(index, 1); + resolve(null); + }, timeoutMs); + waiters.push(waiter); }); } }; @@ -157,16 +184,68 @@ test('HTTP API sends messages, writes history, and builds conversations', async const sendPayload = await sendResponse.json(); assert.equal(sendPayload.item.message, 'hello'); assert.equal(sendPayload.item.echo, true); + assert.equal(typeof sendPayload.item.ordinal, 'number'); + assert.equal((await fs.readFile(logPath, 'utf8')).includes('"ordinal":null'), false); const history = await (await fetch(`http://127.0.0.1:${port}/history?id=7656119`)).json(); assert.equal(history.length, 1); assert.equal(history[0].name, 'Me'); + assert.equal(typeof history[0].ordinal, 'number'); const conversations = await (await fetch(`http://127.0.0.1:${port}/conversations`)).json(); assert.equal(conversations[0].id, '7656119'); assert.equal(conversations[0].preview, 'hello'); }); +test('HTTP image API sends images without writing the legacy-incompatible image row', async (t: TestContext) => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'steam-chat-http-image-')); + const logPath = path.join(dir, 'chat.jsonl'); + const steamUser = new EventEmitter() as TestSteamUser; + steamUser.chat = { + sendFriendMessage(id: unknown, msg: unknown, callback: (error: Error | null, result?: unknown) => void) { + callback(null, { id, msg }); + } + }; + const steamCommunity: TestSteamCommunity = { + sendImageToUser(_id: unknown, _image: Buffer, _filename: string, callback: (error: Error | null, result?: unknown) => void) { + callback(null, { + url: 'https://images.steamusercontent.com/ugc/example/' + }); + } + }; + const service = createChatService({ + config: { host: '127.0.0.1', port: 0, wsPath: '/ws' }, + steamUser, + steamCommunity, + logPath, + getSelfName: async () => 'Me', + logger: { info() {}, warn() {}, error() {} } + }) as ChatServiceRuntime; + t.after(() => service.stop().catch(() => {})); + const port = await listen(service.server); + + const encoded = Buffer.from('hello image').toString('base64'); + const sendResponse = await fetch(`http://127.0.0.1:${port}/image`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: '7656119', img: `data:image/png;base64,${encoded}` }) + }); + assert.equal(sendResponse.status, 200); + const sendPayload = await sendResponse.json(); + assert.equal(sendPayload.item.type, 'message'); + assert.equal(sendPayload.item.ordinal, 0); + + const content = await fs.readFile(logPath, 'utf8').catch((error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') return ''; + throw error; + }); + assert.equal(content.includes('"type":"image"'), false); + assert.equal(content.includes('imageUrl'), false); + assert.equal(content.includes('sentAt'), false); + assert.equal(content.includes('"ordinal":null'), false); + assert.equal(content.trim(), ''); +}); + test('WebSocket sends ready, handles ping, rejects invalid JSON, and supports history requests', async (t: TestContext) => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'steam-chat-ws-')); const logPath = path.join(dir, 'chat.jsonl'); @@ -208,3 +287,45 @@ test('WebSocket sends ready, handles ping, rejects invalid JSON, and supports hi assert.equal(history.requestId, 'h1'); assert.equal(history.items[0].message, 'via ws'); }); + +test('WebSocket broadcasts merged chat object events with Steam ordinals', async (t: TestContext) => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'steam-chat-ws-merged-')); + const logPath = path.join(dir, 'chat.jsonl'); + const steamUser = new EventEmitter() as TestSteamUser; + const chat = new EventEmitter() as EventEmitterType & TestSteamUser['chat']; + chat.sendFriendMessage = (_id: unknown, _msg: unknown, callback: (error: Error | null, result?: unknown) => void) => { + callback(null); + }; + steamUser.chat = chat; + const service = createChatService({ + config: { host: '127.0.0.1', port: 0, wsPath: '/ws' }, + steamUser, + logPath, + getUserInfo: async () => ({ player_name: 'Alice' }), + getSelfName: async () => 'Me', + logger: { info() {}, warn() {}, error() {} } + }) as ChatServiceRuntime; + t.after(() => service.stop().catch(() => {})); + const port = await listen(service.server); + const { ws, inbox } = await wsOpen(`ws://127.0.0.1:${port}/ws`); + t.after(() => ws.close()); + + assert.deepEqual(await inbox.next(), { type: 'ready', wsPath: '/ws' }); + + chat.emit('friendMessage', { + steamid_friend: '42', + message: '[sticker type="happy" limit="0"][/sticker]', + message_no_bbcode: 'happy', + ordinal: 22, + server_timestamp: new Date(1710000000 * 1000) + }); + steamUser.emit('friendMessage', '42', 'happy'); + + const event = await inbox.next(); + assert.equal(event.type, 'message'); + assert.equal(event.id, '42'); + assert.equal(event.name, 'Alice'); + assert.equal(event.message, '[sticker type="happy" limit="0"][/sticker]'); + assert.equal(event.ordinal, 22); + assert.equal(await inbox.nextWithin(50), null); +}); diff --git a/test/logger.test.ts b/test/logger.test.ts index 0551507..1bcb737 100644 --- a/test/logger.test.ts +++ b/test/logger.test.ts @@ -13,14 +13,18 @@ const { readHistory } = require('../src/storage/chat-log'); +function keysOf(value: Record): string[] { + return Object.keys(value); +} + test('readHistory normalizes, filters, limits, sorts, and skips invalid JSONL lines', async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'steam-chat-log-')); const logPath = path.join(dir, 'chat.jsonl'); await fs.writeFile(logPath, [ - '{"id":"2","name":"B","message":"later","date":"2026-06-23 10:12:00.000","ordinal":2}', + '{"id":"2","name":"B","message":"later","date":"2026-06-23 10:12:00.000","ordinal":3}', 'not json', - '{"id":"1","name":"A","message":"first","date":"2026-06-23 10:10:00.000","ordinal":1}', - '{"id":"1","name":"A","message":"second","date":"2026-06-23 10:10:00.000","ordinal":2}' + '{"id":"1","name":"A","message":"second","date":"2026-06-23 10:10:00.000","ordinal":2}', + '{"id":"1","name":"A","message":"first","date":"2026-06-23 10:10:00.000","ordinal":1}' ].join('\n')); const history = await readHistory({ @@ -33,7 +37,53 @@ test('readHistory normalizes, filters, limits, sorts, and skips invalid JSONL li assert.equal(history.length, 2); assert.equal(history[0].type, 'message'); assert.equal(history[0].message, 'first'); + assert.equal(history[0].ordinal, 1); assert.equal(history[1].message, 'second'); + assert.equal(history[1].ordinal, 2); +}); + +test('readHistory folds legacy imageUrl into image message content', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'steam-chat-log-')); + const logPath = path.join(dir, 'chat.jsonl'); + await fs.writeFile(logPath, '{"id":"1","name":"A","imageUrl":"https://example.com/old.png","date":"2026-06-23 10:10:00.000"}\n'); + + const history = await readHistory({ logPath, id: '1', limit: 10 }); + + assert.equal(history.length, 1); + assert.equal(history[0].type, 'image'); + assert.equal(history[0].message, 'https://example.com/old.png'); + assert.equal(history[0].ordinal, 0); + assert.equal(Object.hasOwn(history[0], 'imageUrl'), false); +}); + +test('appendLog assigns missing ordinals without overwriting provided ordinals', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'steam-chat-log-')); + const logPath = path.join(dir, 'chat.jsonl'); + await appendLog({ + id: '1', + name: 'Alice', + message: 'first generated', + date: '2026-06-23 10:00:00.000' + }, { logPath }); + await appendLog({ + id: '1', + name: 'Alice', + message: 'second generated', + date: '2026-06-23 10:00:00.000' + }, { logPath }); + await appendLog({ + id: '1', + name: 'Alice', + message: 'provided ordinal', + ordinal: 9, + date: '2026-06-23 10:00:00.000' + }, { logPath }); + + const rawLines = (await fs.readFile(logPath, 'utf8')).trim().split('\n'); + const rawItems = rawLines.map((line: string) => JSON.parse(line)); + assert.equal(rawLines.some((line: string) => line.includes('"ordinal":null')), false); + assert.deepEqual(rawItems.map((item: { ordinal: number }) => item.ordinal), [0, 1, 9]); + assert.deepEqual(keysOf(rawItems[0]), ['date', 'echo', 'id', 'name', 'message', 'ordinal']); }); test('appendLog and buildConversations generate previews and newest-first summaries', async () => { @@ -43,23 +93,33 @@ test('appendLog and buildConversations generate previews and newest-first summar id: '1', name: 'Alice', message: ':wave:', + ordinal: 1, date: '2026-06-23 10:00:00.000' }, { logPath }); await appendLog({ id: '2', name: 'Bob', message: '[sticker type="happy" limit="0"][/sticker]', + ordinal: 1, date: '2026-06-23 10:02:00.000' }, { logPath }); await appendLog({ - type: 'image', id: '1', name: 'Alice', - imageUrl: 'https://example.com/a.png', + message: '[img src=https://example.com/a.png][url=https://example.com/a.png]https://example.com/a.png[/url][/img]', + ordinal: 2, date: '2026-06-23 10:03:00.000' }, { logPath }); assert.equal(previewForMessage({ type: 'message', message: '[og url="https://e.test" title="OG Title"]x[/og]' }), 'OG Title'); + const rawLines = (await fs.readFile(logPath, 'utf8')).trim().split('\n'); + const rawItems = rawLines.map((line: string) => JSON.parse(line)); + assert.equal(rawLines.some((line: string) => line.includes('imageUrl')), false); + assert.equal(rawLines.some((line: string) => line.includes('sentAt')), false); + assert.equal(rawLines.some((line: string) => line.includes('"type":"image"')), false); + assert.deepEqual(rawItems.map((item: { ordinal: number }) => item.ordinal), [1, 1, 2]); + assert.deepEqual(keysOf(rawItems[0]), ['date', 'echo', 'id', 'name', 'message', 'ordinal']); + assert.deepEqual(keysOf(rawItems[2]), ['date', 'echo', 'id', 'name', 'message', 'ordinal']); const conversations = await buildConversations({ logPath }); assert.equal(conversations[0].id, '1'); diff --git a/test/message-logger.test.ts b/test/message-logger.test.ts index 4e9d611..fc71b1a 100644 --- a/test/message-logger.test.ts +++ b/test/message-logger.test.ts @@ -14,14 +14,30 @@ const { createSteamMessageLogger } = require('../src/steam/message-logger'); const { readHistory } = require('../src/storage/chat-log'); type SteamHistoryMessage = { - imageUrl?: string | null; accountid?: string | number; message?: string; ordinal?: string | number | null; + steamID?: unknown; timestamp?: number; }; +type SteamFriendHistoryMessage = { + sender?: unknown; + server_timestamp?: Date; + ordinal?: string | number | null; + message?: string; +}; + +type TestSteamChat = Partial & { + getFriendMessageHistory?: ( + id: string, + options: { maxCount: number; wantBbcode: boolean }, + callback: (error: unknown, response?: { messages?: SteamFriendHistoryMessage[] }) => void + ) => void; +}; + type TestSteamUser = EventEmitterType & { + chat?: TestSteamChat; getChatHistory?: (id: string, callback: (error: unknown, messages?: SteamHistoryMessage[]) => void) => void; }; @@ -35,7 +51,103 @@ async function historyUntil(logPath: string, expectedLength: number) { assert.fail(`Timed out waiting for ${expectedLength} chat log rows, got ${last.length}`); } -test('createSteamMessageLogger imports Steam history once before live friend messages', async () => { +test('createSteamMessageLogger imports chat friend history with original ordinals', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'steam-chat-message-log-')); + const logPath = path.join(dir, 'chat.jsonl'); + const steamUser = new EventEmitter() as TestSteamUser; + const steamID = { getSteamID64: () => '76561198000000000' }; + const selfID = { getSteamID64: () => '76561198000000001' }; + let historyCalls = 0; + steamUser.chat = { + getFriendMessageHistory(id: string, options: { maxCount: number; wantBbcode: boolean }, callback: (error: unknown, response?: { messages?: SteamFriendHistoryMessage[] }) => void) { + historyCalls += 1; + assert.equal(id, '76561198000000000'); + assert.deepEqual(options, { maxCount: 100, wantBbcode: true }); + callback(null, { + messages: [ + { sender: steamID, message: 'old friend text', ordinal: 11, server_timestamp: new Date(1710000000 * 1000) }, + { sender: selfID, message: 'old self text', ordinal: 12, server_timestamp: new Date(1710000001 * 1000) } + ] + }); + } + }; + steamUser.getChatHistory = () => assert.fail('legacy getChatHistory should not be used when chat history is available'); + + const dispose = createSteamMessageLogger({ + steamUser, + getUserInfo: async () => ({ player_name: 'Alice' }), + getSelfName: async () => 'Me', + logPath, + logger: { info() {}, warn() {}, error() {} } + }); + + steamUser.emit('friendMessage', steamID, 'live one', undefined, undefined, 13); + const history = await historyUntil(logPath, 3); + assert.equal(historyCalls, 1); + assert.deepEqual(history.map((item: { message: string }) => item.message), ['old friend text', 'old self text', 'live one']); + assert.deepEqual(history.map((item: { ordinal: number | string | null }) => item.ordinal), [11, 12, 13]); + assert.equal(history[0].echo, false); + assert.equal(history[1].echo, true); + + dispose(); +}); + +test('createSteamMessageLogger merges chat object events with legacy compatibility events', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'steam-chat-message-log-')); + const logPath = path.join(dir, 'chat.jsonl'); + const steamUser = new EventEmitter() as TestSteamUser; + const chat = new EventEmitter() as TestSteamChat & EventEmitterType; + const steamID = { getSteamID64: () => '76561198000000000' }; + const bbcodeImage = '[img src=https://images.steamusercontent.com/ugc/example/][url=https://images.steamusercontent.com/ugc/example/]https://images.steamusercontent.com/ugc/example/[/url][/img]'; + let historyCalls = 0; + chat.getFriendMessageHistory = (id: string, options: { maxCount: number; wantBbcode: boolean }, callback: (error: unknown, response?: { messages?: SteamFriendHistoryMessage[] }) => void) => { + historyCalls += 1; + assert.equal(id, '76561198000000000'); + assert.deepEqual(options, { maxCount: 100, wantBbcode: true }); + callback(null, { + messages: [ + { sender: steamID, message: 'history from chat object trigger', ordinal: 43, server_timestamp: new Date(1709999999 * 1000) } + ] + }); + }; + steamUser.chat = chat; + + const dispose = createSteamMessageLogger({ + steamUser, + getUserInfo: async () => ({ player_name: 'Alice' }), + getSelfName: async () => 'Me', + logPath, + logger: { info() {}, warn() {}, error() {} } + }); + + chat.emit('friendMessage', { + steamid_friend: steamID, + message: bbcodeImage, + message_no_bbcode: 'https://images.steamusercontent.com/ugc/example/', + ordinal: 44, + server_timestamp: new Date(1710000000 * 1000) + }); + steamUser.emit('friendMessage', steamID, 'https://images.steamusercontent.com/ugc/example/'); + + let history = await historyUntil(logPath, 2); + await delay(30); + history = await readHistory({ logPath, limit: 50 }); + assert.equal(historyCalls, 1); + assert.equal(history.length, 2); + assert.deepEqual(history.map((item: { message: string }) => item.message), ['history from chat object trigger', bbcodeImage]); + assert.deepEqual(history.map((item: { ordinal: number | string | null }) => item.ordinal), [43, 44]); + assert.equal(history[1].name, 'Alice'); + + const raw = await fs.readFile(logPath, 'utf8'); + assert.equal(raw.includes('imageUrl'), false); + assert.equal(raw.includes('sentAt'), false); + assert.equal(raw.includes('"ordinal":43'), true); + assert.equal(raw.includes('"ordinal":44'), true); + + dispose(); +}); + +test('createSteamMessageLogger falls back to legacy Steam history before live friend messages', async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'steam-chat-message-log-')); const logPath = path.join(dir, 'chat.jsonl'); const steamUser = new EventEmitter() as TestSteamUser; @@ -46,7 +158,7 @@ test('createSteamMessageLogger imports Steam history once before live friend mes assert.equal(id, '76561198000000000'); callback(null, [ { accountid: 'history-user', message: 'old text', ordinal: 1, timestamp: 1710000000 }, - { imageUrl: 'https://example.com/old.png', message: '', ordinal: 2, timestamp: 1710000001 } + { accountid: 'history-user', message: 'old image', ordinal: 2, timestamp: 1710000001 } ]); }; @@ -61,14 +173,21 @@ test('createSteamMessageLogger imports Steam history once before live friend mes steamUser.emit('friendMessage', steamID, 'live one', undefined, undefined, 3); let history = await historyUntil(logPath, 3); assert.equal(historyCalls, 1); - assert.deepEqual(history.map((item: { message: string }) => item.message), ['old text', '', 'live one']); - assert.equal(history[1].type, 'image'); + assert.deepEqual(history.map((item: { message: string }) => item.message), ['old text', 'old image', 'live one']); + assert.deepEqual(history.map((item: { ordinal: number | string | null }) => item.ordinal), [1, 2, 3]); + assert.equal(history[1].type, 'message'); assert.equal(history[2].name, 'Alice'); + const raw = await fs.readFile(logPath, 'utf8'); + assert.equal(raw.includes('"ordinal":3'), true); + assert.equal(raw.includes('imageUrl'), false); + assert.equal(raw.includes('sentAt'), false); + assert.equal(raw.includes('"type"'), false); steamUser.emit('friendMessage', steamID, 'live two', undefined, undefined, 4); history = await historyUntil(logPath, 4); assert.equal(historyCalls, 1); assert.equal(history[3].message, 'live two'); + assert.equal(history[3].ordinal, 4); dispose(); steamUser.emit('friendMessage', steamID, 'after dispose', undefined, undefined, 5); @@ -100,6 +219,34 @@ test('createSteamMessageLogger records one echoed message for duplicate echo eve assert.equal(history[0].id, '42'); assert.equal(history[0].name, 'Self'); assert.equal(history[0].message, 'echo text'); + assert.equal(history[0].ordinal, 7); + + dispose(); +}); + +test('createSteamMessageLogger generates ordinals when legacy Steam events omit them', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'steam-chat-message-log-')); + const logPath = path.join(dir, 'chat.jsonl'); + const steamUser = new EventEmitter() as TestSteamUser; + + const dispose = createSteamMessageLogger({ + steamUser, + getUserInfo: async () => ({ player_name: 'Alice' }), + getSelfName: async () => 'Self', + logPath, + logger: { info() {}, warn() {}, error() {} } + }); + + steamUser.emit('friendMessage', '42', 'legacy incoming'); + steamUser.emit('friendMessageEcho', '42', 'legacy echo'); + + const history = await historyUntil(logPath, 2); + const raw = await fs.readFile(logPath, 'utf8'); + assert.equal(raw.includes('"ordinal":null'), false); + assert.equal(raw.includes('imageUrl'), false); + assert.equal(raw.includes('sentAt'), false); + assert.equal(raw.includes('"type"'), false); + assert.equal(history.every((item: { ordinal: number | string | null }) => typeof item.ordinal === 'number'), true); dispose(); }); diff --git a/web/app.ts b/web/app.ts index 706177d..25bd3a3 100644 --- a/web/app.ts +++ b/web/app.ts @@ -47,7 +47,7 @@ type MessageItem = ListEntry & { date?: string; sentAt?: string; message?: string; - imageUrl?: string | null; + ordinal?: string | number | null; }; type InventoryItem = Record & { @@ -60,7 +60,7 @@ type WsPayload = Record & { id?: string; name?: string; message?: string; - imageUrl?: string | null; + ordinal?: string | number | null; echo?: boolean; date?: string; sentAt?: string; @@ -824,8 +824,13 @@ function renderMessage(item: MessageItem) { const meta = create('div', 'meta'); meta.append(create('span', '', item.name || (item.echo ? '我' : item.id)), create('span', '', formatTime(item.sentAt || item.date))); const content = create('div', 'message-content'); - if (item.imageUrl) content.append(imageNode(item.imageUrl)); - else appendMessageText(content, item.message || ''); + const message = item.message || ''; + if (item.type === 'image') { + if (isRemoteImageSource(message)) content.append(imageNode(message)); + else appendMessageText(content, message || '[图片]'); + } else { + appendMessageText(content, message); + } bubble.append(meta, content); row.append(bubble); return row; @@ -872,6 +877,10 @@ function imageNode(sourceUrl: string) { return shell; } +function isRemoteImageSource(value: unknown): value is string { + return typeof value === 'string' && /^https?:\/\//i.test(value); +} + function formatTime(value: unknown): string { if (!value) return ''; const date = new Date(String(value).replace(' ', 'T')); @@ -985,7 +994,7 @@ function ensureWebSocket() { id: String(payload.id || ''), name: typeof payload.name === 'string' ? payload.name : undefined, message: typeof payload.message === 'string' ? payload.message : undefined, - imageUrl: typeof payload.imageUrl === 'string' ? payload.imageUrl : null, + ordinal: typeof payload.ordinal === 'string' || typeof payload.ordinal === 'number' ? payload.ordinal : 0, echo: Boolean(payload.echo), date: typeof payload.date === 'string' ? payload.date : undefined, sentAt: typeof payload.sentAt === 'string' ? payload.sentAt : undefined