Fix Steam chat history ordinal logging
This commit is contained in:
@@ -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) => {
|
||||
|
||||
150
src/steam/friend-message-events.ts
Normal file
150
src/steam/friend-message-events.ts
Normal 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
|
||||
};
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
19
web/app.ts
19
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<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
|
||||
|
||||
Reference in New Issue
Block a user