Fix Steam chat history ordinal logging

This commit is contained in:
2026-06-24 22:17:36 +08:00
parent 4d3c1d3576
commit c8c57c5eb8
9 changed files with 736 additions and 121 deletions

View File

@@ -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<unknown> | (() => Promise<unknown> | 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<string, Persona>;
myGroups?: unknown;
@@ -409,7 +419,7 @@ function createChatService(options: ChatServiceOptions = {}) {
const legacyAuth = createAuthChecker(config.auth);
const clients = new Set<WsConnection>();
const recentSentText = new Map<string, number>();
const recentSentImages = new Map<string, number>();
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<void>((resolve, reject) => {

View File

@@ -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<SteamFriendMessageEvent, 'id' | 'compatibilityMessage'>): string {
return `${eventName}\0${event.id}\0${event.compatibilityMessage}`;
}
function incrementPendingLegacy(map: Map<string, number>, 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<string, number>, 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<string, number>();
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
};

View File

@@ -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<SteamFriendMessageEventUser['chat']> & {
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<string>();
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 maybeImportSteamHistory(id: string) {
if (!id || importAttempts.has(id) || typeof steamUser.getChatHistory !== 'function') return;
importAttempts.add(id);
try {
async function importFriendMessageHistory(id: string): Promise<boolean> {
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<boolean> {
if (typeof steamUser.getChatHistory !== 'function') return false;
const history = await new Promise<SteamHistoryMessage[]>((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({
type: message.imageUrl ? 'image' : 'message',
echo,
id,
name: message.accountid ? String(message.accountid) : 'Unknown',
name: echo ? await getSelfName() : (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 });
}
return true;
}
async function maybeImportSteamHistory(id: string) {
if (!id || importAttempts.has(id)) return;
importAttempts.add(id);
try {
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 = {

View File

@@ -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<string, Promise<unknown>>();
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<HistoryItem, 'id' | 'date' | 'sentAt' | 'ordinal'>): string {
return `${item.id}\0${parseMessageDate(item)}`;
}
function usedOrdinalsByGroup(items: HistoryItem[]): Map<string, Set<number>> {
const used = new Map<string, Set<number>>();
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<number>();
values.add(ordinal);
used.set(key, values);
}
return used;
}
function nextAvailableOrdinal(used: Set<number>): 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<number>();
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<string, unknown> = {};
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<HistoryItem> {
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<HistoryItem> {
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<HistoryItem[]> {
@@ -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<HistoryItem, 'type' | 'message' | 'imageUrl'>): string {
if (item.type === 'image' || item.imageUrl) return '[图片]';
function previewForMessage(item: Pick<HistoryItem, 'type' | 'message'>): string {
if (item.type === 'image') return '[图片]';
const stickerType = extractStickerType(item.message);
if (stickerType) return `[贴纸] ${stickerType}`;
const emoticon = isEmoticonOnly(item.message);

View File

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

View File

@@ -29,6 +29,7 @@ type WsMessage = Record<string, unknown> & {
type WsInbox = {
next: () => Promise<WsMessage>;
nextWithin: (timeoutMs: number) => Promise<WsMessage | null>;
};
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<void>;
@@ -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);
});

View File

@@ -13,14 +13,18 @@ const {
readHistory
} = require('../src/storage/chat-log');
function keysOf(value: Record<string, unknown>): 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');

View File

@@ -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<EventEmitterType> & {
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();
});

View File

@@ -47,7 +47,7 @@ type MessageItem = ListEntry & {
date?: string;
sentAt?: string;
message?: string;
imageUrl?: string | null;
ordinal?: string | number | null;
};
type InventoryItem = Record<string, unknown> & {
@@ -60,7 +60,7 @@ type WsPayload = Record<string, unknown> & {
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