1864 lines
56 KiB
JavaScript
1864 lines
56 KiB
JavaScript
const http = require('http');
|
|
const fs = require('fs');
|
|
const crypto = require('crypto');
|
|
const path = require('path');
|
|
const { once } = require('node:events');
|
|
|
|
const axios = require('axios');
|
|
const dateformat = require('@matteo.collina/dateformat');
|
|
const WebSocket = require('ws');
|
|
|
|
const CHAT_LOG_FILE = './logs/chat.jsonl';
|
|
const STICKER_CACHE_DIR = './logs/stickers';
|
|
const IMAGE_CACHE_DIR = './logs/images';
|
|
|
|
function normalizeAuthConfig(rawAuth) {
|
|
const defaultConfig = {
|
|
username: '',
|
|
password: '',
|
|
realm: 'Steam Chat',
|
|
trustProxy: false,
|
|
};
|
|
|
|
if (!rawAuth || typeof rawAuth !== 'object') {
|
|
return defaultConfig;
|
|
}
|
|
|
|
return {
|
|
username: rawAuth.username || '',
|
|
password: rawAuth.password || '',
|
|
realm: rawAuth.realm || defaultConfig.realm,
|
|
trustProxy: rawAuth.trustProxy === true,
|
|
};
|
|
}
|
|
|
|
function normalizeChatConfig(rawConfig) {
|
|
const defaultConfig = {
|
|
enabled: Boolean(rawConfig),
|
|
host: '0.0.0.0',
|
|
port: 3000,
|
|
wsPath: '/ws',
|
|
auth: normalizeAuthConfig(null),
|
|
};
|
|
|
|
if (!rawConfig || typeof rawConfig !== 'object') {
|
|
return defaultConfig;
|
|
}
|
|
|
|
return {
|
|
enabled: rawConfig.enabled !== false,
|
|
host: rawConfig.host || defaultConfig.host,
|
|
port: rawConfig.port || defaultConfig.port,
|
|
wsPath: rawConfig.wsPath || defaultConfig.wsPath,
|
|
auth: normalizeAuthConfig(rawConfig.auth),
|
|
};
|
|
}
|
|
|
|
function normalizeIpAddress(rawAddress) {
|
|
let address = String(rawAddress || '').trim();
|
|
if (!address) {
|
|
return '';
|
|
}
|
|
|
|
const forwardedMatch = address.match(/^for=(.+)$/i);
|
|
if (forwardedMatch) {
|
|
address = forwardedMatch[1];
|
|
}
|
|
|
|
address = address.replace(/^"|"$/g, '');
|
|
|
|
if (address.startsWith('[')) {
|
|
const closingIndex = address.indexOf(']');
|
|
if (closingIndex !== -1) {
|
|
address = address.slice(1, closingIndex);
|
|
}
|
|
} else if ((address.match(/:/g) || []).length === 1 && address.includes('.')) {
|
|
address = address.split(':')[0];
|
|
}
|
|
|
|
address = address.replace(/^\[|\]$/g, '').replace(/%[0-9a-z]+$/i, '').trim().toLowerCase();
|
|
|
|
if (address.startsWith('::ffff:')) {
|
|
return address.slice('::ffff:'.length);
|
|
}
|
|
|
|
return address;
|
|
}
|
|
|
|
function parseForwardedHeader(headerValue) {
|
|
if (!headerValue) {
|
|
return '';
|
|
}
|
|
|
|
for (const part of String(headerValue).split(',')) {
|
|
for (const segment of part.split(';')) {
|
|
const match = segment.trim().match(/^for=(.+)$/i);
|
|
if (match && match[1]) {
|
|
return normalizeIpAddress(match[1]);
|
|
}
|
|
}
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
function getClientIp(req, trustProxy = false) {
|
|
const headers = req && req.headers ? req.headers : {};
|
|
|
|
if (trustProxy) {
|
|
const forwarded = parseForwardedHeader(headers.forwarded);
|
|
if (forwarded) {
|
|
return forwarded;
|
|
}
|
|
|
|
const xForwardedFor = String(headers['x-forwarded-for'] || '')
|
|
.split(',')
|
|
.map((item) => normalizeIpAddress(item))
|
|
.find(Boolean);
|
|
if (xForwardedFor) {
|
|
return xForwardedFor;
|
|
}
|
|
|
|
const xRealIp = normalizeIpAddress(headers['x-real-ip']);
|
|
if (xRealIp) {
|
|
return xRealIp;
|
|
}
|
|
}
|
|
|
|
return normalizeIpAddress(
|
|
(req && req.socket && req.socket.remoteAddress)
|
|
|| (req && req.connection && req.connection.remoteAddress)
|
|
|| '',
|
|
);
|
|
}
|
|
|
|
function isLanIp(rawAddress) {
|
|
const address = normalizeIpAddress(rawAddress);
|
|
if (!address) {
|
|
return false;
|
|
}
|
|
|
|
if (address === '::1' || address === 'localhost') {
|
|
return true;
|
|
}
|
|
|
|
if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(address)) {
|
|
if (address.startsWith('10.') || address.startsWith('127.') || address.startsWith('192.168.') || address.startsWith('169.254.')) {
|
|
return true;
|
|
}
|
|
|
|
const octets = address.split('.').map((item) => Number.parseInt(item, 10));
|
|
if (octets.length === 4 && octets.every((item) => Number.isInteger(item) && item >= 0 && item <= 255)) {
|
|
return octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
return address.startsWith('fc')
|
|
|| address.startsWith('fd')
|
|
|| address.startsWith('fe8')
|
|
|| address.startsWith('fe9')
|
|
|| address.startsWith('fea')
|
|
|| address.startsWith('feb');
|
|
}
|
|
|
|
function isAuthEnabled(authConfig) {
|
|
return Boolean(authConfig && authConfig.username && authConfig.password);
|
|
}
|
|
|
|
function hashSecret(value) {
|
|
return crypto.createHash('sha256').update(String(value || '')).digest();
|
|
}
|
|
|
|
function safeEqual(left, right) {
|
|
return crypto.timingSafeEqual(hashSecret(left), hashSecret(right));
|
|
}
|
|
|
|
function parseBasicAuthHeader(headerValue) {
|
|
const match = String(headerValue || '').match(/^Basic\s+(.+)$/i);
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const decoded = Buffer.from(match[1], 'base64').toString('utf8');
|
|
const separatorIndex = decoded.indexOf(':');
|
|
if (separatorIndex === -1) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
username: decoded.slice(0, separatorIndex),
|
|
password: decoded.slice(separatorIndex + 1),
|
|
};
|
|
} catch (err) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function isAuthorized(req, authConfig) {
|
|
if (!isAuthEnabled(authConfig)) {
|
|
return true;
|
|
}
|
|
|
|
const credentials = parseBasicAuthHeader(req && req.headers ? req.headers.authorization : '');
|
|
if (!credentials) {
|
|
return false;
|
|
}
|
|
|
|
return safeEqual(credentials.username, authConfig.username)
|
|
&& safeEqual(credentials.password, authConfig.password);
|
|
}
|
|
|
|
function requiresHttpAuth(req, chatConfig) {
|
|
if (!isAuthEnabled(chatConfig && chatConfig.auth)) {
|
|
return false;
|
|
}
|
|
|
|
return !isLanIp(getClientIp(req, chatConfig.auth.trustProxy));
|
|
}
|
|
|
|
function normalizeWsRequest(payload) {
|
|
switch (payload.type) {
|
|
case 'msg':
|
|
case 'send_message':
|
|
return {
|
|
action: 'send_message',
|
|
requestId: payload.requestId,
|
|
id: payload.id,
|
|
msg: payload.msg,
|
|
};
|
|
case 'img':
|
|
case 'send_image':
|
|
return {
|
|
action: 'send_image',
|
|
requestId: payload.requestId,
|
|
id: payload.id,
|
|
img: payload.img,
|
|
url: payload.url,
|
|
};
|
|
case 'history':
|
|
case 'get_history':
|
|
return {
|
|
action: 'get_history',
|
|
requestId: payload.requestId,
|
|
id: payload.id,
|
|
limit: payload.limit,
|
|
};
|
|
case 'conversations':
|
|
case 'get_conversations':
|
|
return {
|
|
action: 'get_conversations',
|
|
requestId: payload.requestId,
|
|
limit: payload.limit,
|
|
};
|
|
case 'emoticons':
|
|
case 'get_emoticons':
|
|
return {
|
|
action: 'get_emoticons',
|
|
requestId: payload.requestId,
|
|
};
|
|
case 'friends':
|
|
case 'get_friends':
|
|
return {
|
|
action: 'get_friends',
|
|
requestId: payload.requestId,
|
|
};
|
|
case 'groups':
|
|
case 'get_groups':
|
|
return {
|
|
action: 'get_groups',
|
|
requestId: payload.requestId,
|
|
};
|
|
case 'ping':
|
|
return {
|
|
action: 'ping',
|
|
requestId: payload.requestId,
|
|
};
|
|
default:
|
|
return {
|
|
action: payload.type,
|
|
requestId: payload.requestId,
|
|
...payload,
|
|
};
|
|
}
|
|
}
|
|
|
|
function buildMessageKey(message) {
|
|
return `${message.id}:${message.ordinal}:${message.message}`;
|
|
}
|
|
|
|
function normalizeHistoryEntry(entry) {
|
|
if (!entry || typeof entry !== 'object') {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
type: entry.type || (entry.imageUrl ? 'image' : 'message'),
|
|
date: entry.date || '',
|
|
echo: Boolean(entry.echo),
|
|
id: entry.id || '',
|
|
name: entry.name || '',
|
|
message: entry.message || '',
|
|
imageUrl: entry.imageUrl || null,
|
|
ordinal: typeof entry.ordinal === 'number' ? entry.ordinal : null,
|
|
sentAt: entry.sentAt || null,
|
|
};
|
|
}
|
|
|
|
function sanitizeLimit(limit, fallback = 100) {
|
|
const value = Number.parseInt(limit, 10);
|
|
if (!Number.isFinite(value) || value <= 0) {
|
|
return fallback;
|
|
}
|
|
return Math.min(value, 500);
|
|
}
|
|
|
|
function extractStickerType(message) {
|
|
if (typeof message !== 'string') {
|
|
return null;
|
|
}
|
|
|
|
const match = message.match(/\[sticker\s+type="([^"]+)"/i);
|
|
return match ? match[1] : null;
|
|
}
|
|
|
|
function extractEmoticonNames(message) {
|
|
if (typeof message !== 'string') {
|
|
return [];
|
|
}
|
|
|
|
const names = new Set();
|
|
|
|
for (const match of message.matchAll(/\[emoticon\s+name="([^"]+)"\](?:\[\/emoticon\])?/gi)) {
|
|
if (match[1]) {
|
|
names.add(match[1]);
|
|
}
|
|
}
|
|
|
|
for (const match of message.matchAll(/\[emoticon\]([^\[]+)\[\/emoticon\]/gi)) {
|
|
if (match[1]) {
|
|
names.add(match[1].trim());
|
|
}
|
|
}
|
|
|
|
for (const match of message.matchAll(/(^|\s):([a-z0-9_][a-z0-9_\-]*):(?=\s|$|[!?,.])/gi)) {
|
|
if (match[2]) {
|
|
names.add(match[2]);
|
|
}
|
|
}
|
|
|
|
return [...names];
|
|
}
|
|
|
|
function extractImageUrls(message) {
|
|
if (typeof message !== 'string') {
|
|
return [];
|
|
}
|
|
|
|
const urls = new Set();
|
|
|
|
for (const match of message.matchAll(/\[img\](https?:\/\/[^\s[\]]+?)\[\/img\]/gi)) {
|
|
if (match[1]) {
|
|
urls.add(match[1]);
|
|
}
|
|
}
|
|
|
|
for (const match of message.matchAll(/\[img\s+src=(https?:\/\/\S+?)[\s\]]/gi)) {
|
|
if (match[1]) {
|
|
urls.add(match[1]);
|
|
}
|
|
}
|
|
|
|
for (const match of message.matchAll(/<img\b[^>]*?\bsrc=["'](https?:\/\/[^"']+)["'][^>]*>/gi)) {
|
|
if (match[1]) {
|
|
urls.add(match[1]);
|
|
}
|
|
}
|
|
|
|
for (const match of message.matchAll(/https?:\/\/\S+?(?:png|jpe?g|gif|webp|bmp)(?:\?\S*)?/gi)) {
|
|
if (match[0]) {
|
|
urls.add(match[0]);
|
|
}
|
|
}
|
|
|
|
return [...urls];
|
|
}
|
|
|
|
function parseBbCodeAttributes(rawAttributes) {
|
|
const attrs = {};
|
|
const content = String(rawAttributes || '');
|
|
const attributeRegex = /([a-z][a-z0-9_-]*)=(?:"((?:\\.|[^"])*)"|'((?:\\.|[^'])*)'|([^\s"'=<>`]+))/gi;
|
|
let match;
|
|
|
|
while ((match = attributeRegex.exec(content)) !== null) {
|
|
const key = match[1].toLowerCase();
|
|
const value = match[2] ?? match[3] ?? match[4] ?? '';
|
|
attrs[key] = value.replace(/\\(["'])/g, '$1');
|
|
}
|
|
|
|
return attrs;
|
|
}
|
|
|
|
function extractOpenGraphEmbeds(message) {
|
|
if (typeof message !== 'string') {
|
|
return [];
|
|
}
|
|
|
|
const embeds = [];
|
|
|
|
for (const match of message.matchAll(/\[og\s+([^\]]+)\]([\s\S]*?)\[\/og\]/gi)) {
|
|
const attrs = parseBbCodeAttributes(match[1] || '');
|
|
const fallbackUrl = (match[2] || '').trim();
|
|
|
|
embeds.push({
|
|
url: attrs.url || fallbackUrl,
|
|
img: attrs.img || null,
|
|
title: attrs.title || '',
|
|
});
|
|
}
|
|
|
|
return embeds.filter((item) => item.url);
|
|
}
|
|
|
|
function buildSteamEmoticonUrl(name, large = true) {
|
|
const normalized = String(name || '').trim().replace(/^:+|:+$/g, '');
|
|
if (!normalized) {
|
|
return null;
|
|
}
|
|
|
|
const sizePath = large ? 'emoticonlarge' : 'emoticon';
|
|
return `https://steamcommunity-a.akamaihd.net/economy/${sizePath}/${encodeURIComponent(normalized)}`;
|
|
}
|
|
|
|
function buildSteamStickerCandidateUrls(type) {
|
|
const normalized = String(type || '').trim();
|
|
if (!normalized) {
|
|
return [];
|
|
}
|
|
|
|
return [
|
|
`https://steamcommunity-a.akamaihd.net/economy/sticker/${encodeURIComponent(normalized)}`,
|
|
`https://steamcommunity-a.akamaihd.net/economy/stickerlarge/${encodeURIComponent(normalized)}`,
|
|
`https://steamcommunity.com/economy/sticker/${encodeURIComponent(normalized)}`,
|
|
`https://steamcommunity.com/economy/stickerlarge/${encodeURIComponent(normalized)}`,
|
|
];
|
|
}
|
|
|
|
function buildStickerCachePath(type) {
|
|
const normalized = String(type || '').trim();
|
|
if (!normalized) {
|
|
return path.join(STICKER_CACHE_DIR, 'unknown.png');
|
|
}
|
|
|
|
return path.join(STICKER_CACHE_DIR, `${encodeURIComponent(normalized)}.bin`);
|
|
}
|
|
|
|
function buildImageCachePaths(url) {
|
|
const normalized = String(url || '').trim();
|
|
const hash = crypto.createHash('sha1').update(normalized).digest('hex');
|
|
return {
|
|
dataPath: path.join(IMAGE_CACHE_DIR, `${hash}.bin`),
|
|
metaPath: path.join(IMAGE_CACHE_DIR, `${hash}.json`),
|
|
};
|
|
}
|
|
|
|
function guessImageContentType(url, fallback = 'image/png') {
|
|
const pathname = String(url || '').split('?')[0].toLowerCase();
|
|
if (pathname.endsWith('.png')) {
|
|
return 'image/png';
|
|
}
|
|
if (pathname.endsWith('.jpg') || pathname.endsWith('.jpeg')) {
|
|
return 'image/jpeg';
|
|
}
|
|
if (pathname.endsWith('.gif')) {
|
|
return 'image/gif';
|
|
}
|
|
if (pathname.endsWith('.webp')) {
|
|
return 'image/webp';
|
|
}
|
|
if (pathname.endsWith('.bmp')) {
|
|
return 'image/bmp';
|
|
}
|
|
if (pathname.endsWith('.svg')) {
|
|
return 'image/svg+xml';
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
function buildConversationPreview(entry) {
|
|
if (!entry) {
|
|
return '';
|
|
}
|
|
|
|
if (entry.type === 'image' || entry.imageUrl) {
|
|
return '[图片]';
|
|
}
|
|
|
|
const ogEmbeds = extractOpenGraphEmbeds(entry.message);
|
|
if (ogEmbeds.length > 0) {
|
|
return ogEmbeds[0].title || ogEmbeds[0].url || '[链接预览]';
|
|
}
|
|
|
|
if (extractImageUrls(entry.message).length > 0) {
|
|
return '[图片]';
|
|
}
|
|
|
|
const stickerType = extractStickerType(entry.message);
|
|
if (stickerType) {
|
|
return `[贴纸] ${stickerType.replace(/^Sticker_/, '')}`;
|
|
}
|
|
|
|
const emoticonNames = extractEmoticonNames(entry.message);
|
|
const emoticonOnlyText = String(entry.message || '')
|
|
.replace(/\[emoticon\s+name="([^"]+)"\](?:\[\/emoticon\])?/gi, (_, name) => `:${name}:`)
|
|
.replace(/\[emoticon\]([^\[]+)\[\/emoticon\]/gi, (_, name) => `:${name.trim()}:`)
|
|
.trim()
|
|
.replace(/\s+/g, '');
|
|
if (emoticonNames.length && emoticonOnlyText === emoticonNames.map((name) => `:${name}:`).join('')) {
|
|
return `[表情] ${emoticonNames.join(' ')}`;
|
|
}
|
|
|
|
return String(entry.message || '').trim().replace(/\s+/g, ' ').slice(0, 60);
|
|
}
|
|
|
|
function sortHistoryItems(items) {
|
|
return [...items].sort((left, right) => {
|
|
const leftDate = left.date || left.sentAt || '';
|
|
const rightDate = right.date || right.sentAt || '';
|
|
|
|
if (leftDate !== rightDate) {
|
|
return leftDate.localeCompare(rightDate);
|
|
}
|
|
|
|
const leftOrdinal = typeof left.ordinal === 'number' ? left.ordinal : Number.MAX_SAFE_INTEGER;
|
|
const rightOrdinal = typeof right.ordinal === 'number' ? right.ordinal : Number.MAX_SAFE_INTEGER;
|
|
|
|
return leftOrdinal - rightOrdinal;
|
|
});
|
|
}
|
|
|
|
function buildConversationSummaries(items) {
|
|
const conversations = new Map();
|
|
|
|
for (const entry of sortHistoryItems(items)) {
|
|
if (!entry.id) {
|
|
continue;
|
|
}
|
|
|
|
const current = conversations.get(entry.id) || {
|
|
id: entry.id,
|
|
name: entry.echo ? '' : (entry.name || ''),
|
|
updatedAt: entry.date || entry.sentAt || '',
|
|
preview: buildConversationPreview(entry),
|
|
lastType: entry.type || (entry.imageUrl ? 'image' : 'message'),
|
|
lastEcho: Boolean(entry.echo),
|
|
messageCount: 0,
|
|
};
|
|
|
|
if (!entry.echo) {
|
|
current.name = entry.name || current.name;
|
|
}
|
|
current.updatedAt = entry.date || entry.sentAt || current.updatedAt;
|
|
current.preview = buildConversationPreview(entry) || current.preview;
|
|
current.lastType = entry.type || (entry.imageUrl ? 'image' : 'message');
|
|
current.lastEcho = Boolean(entry.echo);
|
|
current.messageCount += 1;
|
|
|
|
conversations.set(entry.id, current);
|
|
}
|
|
|
|
return [...conversations.values()].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
|
}
|
|
|
|
const PUBLIC_DIR = path.join(__dirname, 'public');
|
|
|
|
const STATIC_CONTENT_TYPES = {
|
|
'.html': 'text/html; charset=utf-8',
|
|
'.css': 'text/css; charset=utf-8',
|
|
'.js': 'application/javascript; charset=utf-8',
|
|
};
|
|
|
|
function serveStaticFile(res, filePath) {
|
|
const ext = path.extname(filePath) || '.html';
|
|
const contentType = STATIC_CONTENT_TYPES[ext];
|
|
if (!contentType) {
|
|
res.writeHead(404);
|
|
res.end('Not Found');
|
|
return;
|
|
}
|
|
|
|
const fullPath = path.join(PUBLIC_DIR, filePath);
|
|
const normalized = path.normalize(fullPath);
|
|
if (!normalized.startsWith(PUBLIC_DIR)) {
|
|
res.writeHead(403);
|
|
res.end('Forbidden');
|
|
return;
|
|
}
|
|
|
|
fs.readFile(normalized, (err, data) => {
|
|
if (err) {
|
|
res.writeHead(404);
|
|
res.end('Not Found');
|
|
return;
|
|
}
|
|
res.writeHead(200, { 'Content-Type': contentType });
|
|
res.end(data);
|
|
});
|
|
}
|
|
|
|
function getDefaultDeps() {
|
|
const config = require('./config.js');
|
|
const client = require('./client');
|
|
|
|
return {
|
|
rawChatConfig: config.chat,
|
|
client,
|
|
logger: client.logger,
|
|
steamUser: client.steamUser,
|
|
steamCommunity: client.steamCommunity,
|
|
fsModule: fs,
|
|
httpModule: http,
|
|
onceFn: once,
|
|
axiosInstance: axios,
|
|
WebSocketImpl: WebSocket,
|
|
dateToString: (date) => dateformat(date, 'yyyy-mm-dd HH:MM:ss.l'),
|
|
};
|
|
}
|
|
|
|
function createChatService(customDeps = {}) {
|
|
const baseDeps = customDeps.useDefaultDeps === false ? {} : getDefaultDeps();
|
|
const deps = {
|
|
...baseDeps,
|
|
...customDeps,
|
|
};
|
|
|
|
delete deps.useDefaultDeps;
|
|
|
|
const {
|
|
rawChatConfig,
|
|
client,
|
|
logger,
|
|
steamUser,
|
|
steamCommunity,
|
|
fsModule,
|
|
httpModule,
|
|
onceFn,
|
|
axiosInstance,
|
|
WebSocketImpl,
|
|
dateToString,
|
|
} = deps;
|
|
|
|
const chatConfig = normalizeChatConfig(rawChatConfig);
|
|
const recentSelfMessages = new Map();
|
|
const recentSelfImageUrls = new Map();
|
|
const pendingStickerFetches = new Map();
|
|
const pendingImageFetches = new Map();
|
|
let started = false;
|
|
|
|
fsModule.mkdir('./logs', { recursive: true }, (err) => {
|
|
if (err) {
|
|
logger.error('an error occurred while creating the logs directory: ' + err);
|
|
}
|
|
});
|
|
fsModule.mkdir(STICKER_CACHE_DIR, { recursive: true }, (err) => {
|
|
if (err) {
|
|
logger.error('an error occurred while creating the sticker cache directory: ' + err);
|
|
}
|
|
});
|
|
fsModule.mkdir(IMAGE_CACHE_DIR, { recursive: true }, (err) => {
|
|
if (err) {
|
|
logger.error('an error occurred while creating the image cache directory: ' + err);
|
|
}
|
|
});
|
|
|
|
const server = httpModule.createServer(handleHttp);
|
|
const wss = new WebSocketImpl.Server({
|
|
server,
|
|
path: chatConfig.wsPath,
|
|
verifyClient: (info, done) => {
|
|
if (!requiresHttpAuth(info.req, chatConfig) || isAuthorized(info.req, chatConfig.auth)) {
|
|
done(true);
|
|
return;
|
|
}
|
|
|
|
done(false, 401, 'Authentication Required', {
|
|
'WWW-Authenticate': `Basic realm="${String(chatConfig.auth.realm || 'Steam Chat').replace(/"/g, '\\"')}"`,
|
|
});
|
|
},
|
|
});
|
|
|
|
async function readRequestBody(req) {
|
|
let body = '';
|
|
req.on('data', (chunk) => {
|
|
body += chunk.toString();
|
|
});
|
|
await onceFn(req, 'end');
|
|
return body;
|
|
}
|
|
|
|
async function readJsonBody(req) {
|
|
const body = await readRequestBody(req);
|
|
if (!body) {
|
|
return {};
|
|
}
|
|
|
|
try {
|
|
return JSON.parse(body);
|
|
} catch (err) {
|
|
const error = new Error('Invalid JSON');
|
|
error.code = 400;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function sendJson(res, statusCode, payload) {
|
|
res.statusCode = statusCode;
|
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
res.end(JSON.stringify(payload));
|
|
}
|
|
|
|
function sendAuthRequired(res) {
|
|
res.statusCode = 401;
|
|
res.setHeader('WWW-Authenticate', `Basic realm="${String(chatConfig.auth.realm || 'Steam Chat').replace(/"/g, '\\"')}"`);
|
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
res.end(JSON.stringify({ error: 'Authentication Required' }));
|
|
}
|
|
|
|
function sendWs(ws, payload) {
|
|
if (ws.readyState === WebSocketImpl.OPEN) {
|
|
ws.send(JSON.stringify(payload));
|
|
}
|
|
}
|
|
|
|
function broadcastWs(payload) {
|
|
const encoded = JSON.stringify(payload);
|
|
wss.clients.forEach((ws) => {
|
|
if (ws.readyState === WebSocketImpl.OPEN) {
|
|
ws.send(encoded);
|
|
}
|
|
});
|
|
}
|
|
|
|
function appendLogEntry(entry) {
|
|
fsModule.appendFile(CHAT_LOG_FILE, JSON.stringify(entry) + '\n', (err) => {
|
|
if (err) {
|
|
logger.error('an error occurred while writing chat log file: ' + err);
|
|
}
|
|
});
|
|
}
|
|
|
|
async function readFileIfExists(filePath) {
|
|
return new Promise((resolve, reject) => {
|
|
fsModule.readFile(filePath, (err, content) => {
|
|
if (err) {
|
|
if (err.code === 'ENOENT') {
|
|
resolve(null);
|
|
return;
|
|
}
|
|
reject(err);
|
|
return;
|
|
}
|
|
|
|
resolve(content);
|
|
});
|
|
});
|
|
}
|
|
|
|
async function writeFileAsync(filePath, content) {
|
|
return new Promise((resolve, reject) => {
|
|
if (typeof fsModule.writeFile !== 'function') {
|
|
resolve();
|
|
return;
|
|
}
|
|
|
|
fsModule.writeFile(filePath, content, (err) => {
|
|
if (err) {
|
|
reject(err);
|
|
return;
|
|
}
|
|
|
|
resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
async function readJsonIfExists(filePath) {
|
|
const content = await readFileIfExists(filePath);
|
|
if (!content) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
return JSON.parse(Buffer.isBuffer(content) ? content.toString('utf8') : String(content));
|
|
} catch (err) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function normalizeSteamId(steamId) {
|
|
if (!steamId) {
|
|
return '';
|
|
}
|
|
|
|
if (typeof steamId === 'string') {
|
|
return steamId;
|
|
}
|
|
|
|
if (typeof steamId.getSteamID64 === 'function') {
|
|
return steamId.getSteamID64();
|
|
}
|
|
|
|
return String(steamId);
|
|
}
|
|
|
|
async function getMessageSenderName(friendId, echo) {
|
|
const senderId = echo ? normalizeSteamId(steamUser.steamID) : normalizeSteamId(friendId);
|
|
if (!senderId) {
|
|
return '';
|
|
}
|
|
|
|
const sender = await client.getUserInfo(senderId, () => {});
|
|
return sender && sender.player_name ? sender.player_name : senderId;
|
|
}
|
|
|
|
function appendOutgoingLog(uid, response) {
|
|
getMessageSenderName(uid, true).then((senderName) => {
|
|
appendLogEntry({
|
|
type: 'message',
|
|
date: dateToString(response.server_timestamp),
|
|
echo: true,
|
|
id: uid,
|
|
name: senderName,
|
|
message: response.modified_message,
|
|
ordinal: response.ordinal,
|
|
});
|
|
});
|
|
}
|
|
|
|
async function appendOutgoingImageLog(uid, imageUrl) {
|
|
const entry = {
|
|
type: 'image',
|
|
date: dateToString(new Date()),
|
|
echo: true,
|
|
id: uid,
|
|
name: await getMessageSenderName(uid, true),
|
|
imageUrl,
|
|
ordinal: null,
|
|
sentAt: new Date().toISOString(),
|
|
};
|
|
appendLogEntry(entry);
|
|
return entry;
|
|
}
|
|
|
|
async function encodeSteamMessage(message, echo) {
|
|
const friendId = normalizeSteamId(message.steamid_friend);
|
|
|
|
return {
|
|
type: 'message',
|
|
date: dateToString(message.server_timestamp),
|
|
echo,
|
|
id: friendId,
|
|
name: await getMessageSenderName(friendId, echo),
|
|
message: message.message,
|
|
ordinal: message.ordinal,
|
|
imageUrl: null,
|
|
sentAt: null,
|
|
};
|
|
}
|
|
|
|
function rememberSelfMessage(message) {
|
|
const key = buildMessageKey(message);
|
|
recentSelfMessages.set(key, Date.now() + 15000);
|
|
const timer = setTimeout(() => {
|
|
recentSelfMessages.delete(key);
|
|
}, 15000);
|
|
|
|
if (typeof timer.unref === 'function') {
|
|
timer.unref();
|
|
}
|
|
}
|
|
|
|
function wasRecentlyBroadcasted(message) {
|
|
const key = buildMessageKey(message);
|
|
const expiresAt = recentSelfMessages.get(key);
|
|
if (!expiresAt) {
|
|
return false;
|
|
}
|
|
|
|
if (expiresAt < Date.now()) {
|
|
recentSelfMessages.delete(key);
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
async function broadcastSteamMessage(message, echo, { dedupe = false } = {}) {
|
|
const data = await encodeSteamMessage(message, echo);
|
|
if (dedupe && wasRecentlyBroadcasted(data)) {
|
|
return;
|
|
}
|
|
if (dedupe && wasRecentImageEcho(data)) {
|
|
return;
|
|
}
|
|
|
|
broadcastWs({
|
|
type: 'message',
|
|
data,
|
|
});
|
|
}
|
|
|
|
async function getEmoticonList() {
|
|
await ensureWebSession();
|
|
|
|
const EMsg = require('steam-user/enums/EMsg');
|
|
const msgKey = EMsg.ClientEmoticonList;
|
|
|
|
function removeHandler(handler) {
|
|
const handlers = steamUser._handlerManager._handlers[msgKey];
|
|
if (handlers) {
|
|
const idx = handlers.indexOf(handler);
|
|
if (idx !== -1) {
|
|
handlers.splice(idx, 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
const body = await new Promise((resolve, reject) => {
|
|
const timeout = setTimeout(() => {
|
|
removeHandler(handler);
|
|
reject(new Error('getEmoticonList timed out'));
|
|
}, 10000);
|
|
if (typeof timeout.unref === 'function') {
|
|
timeout.unref();
|
|
}
|
|
function handler(body) {
|
|
clearTimeout(timeout);
|
|
removeHandler(handler);
|
|
resolve(body);
|
|
}
|
|
steamUser._handlerManager.add(msgKey, handler);
|
|
steamUser._send(EMsg.ClientGetEmoticonList, {});
|
|
});
|
|
|
|
const emoticons = (body.emoticons || []).map((e) => ({
|
|
name: String(e.name || '').replace(/^:+|:+$/g, ''),
|
|
count: e.count,
|
|
use_count: e.use_count || 0,
|
|
time_last_used: e.time_last_used,
|
|
appid: e.appid,
|
|
}));
|
|
|
|
const stickers = (body.stickers || []).map((s) => ({
|
|
name: s.name,
|
|
count: s.count,
|
|
use_count: s.use_count || 0,
|
|
time_last_used: s.time_last_used,
|
|
appid: s.appid,
|
|
}));
|
|
|
|
return { emoticons, stickers };
|
|
}
|
|
|
|
async function getFriendsList() {
|
|
await client.steamLoginPromise;
|
|
const myFriends = steamUser.myFriends || {};
|
|
const friendIds = Object.keys(myFriends).filter((id) => myFriends[id] === 3); // EFriendRelationship.Friend
|
|
|
|
const friends = [];
|
|
for (const id of friendIds) {
|
|
const cached = steamUser.users && steamUser.users[id];
|
|
friends.push({
|
|
id,
|
|
name: (cached && cached.player_name) || id,
|
|
avatar: (cached && cached.avatar_url_medium) || null,
|
|
status: cached ? (cached.persona_state || 0) : 0,
|
|
game: (cached && cached.game_name) || null,
|
|
});
|
|
}
|
|
|
|
friends.sort((a, b) => {
|
|
// Online users first (status > 0), then alphabetical
|
|
if (a.status > 0 && b.status === 0) return -1;
|
|
if (a.status === 0 && b.status > 0) return 1;
|
|
return (a.name || '').localeCompare(b.name || '');
|
|
});
|
|
|
|
return friends;
|
|
}
|
|
|
|
async function getGroupsList() {
|
|
await client.steamLoginPromise;
|
|
const myGroups = steamUser.myGroups || {};
|
|
const groupIds = Object.keys(myGroups);
|
|
|
|
const groups = [];
|
|
for (const id of groupIds) {
|
|
const cached = steamUser.groups && steamUser.groups[id];
|
|
const nameInfo = cached && cached.name_info;
|
|
groups.push({
|
|
id,
|
|
name: (nameInfo && nameInfo.clan_name) || id,
|
|
});
|
|
}
|
|
|
|
groups.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
|
return groups;
|
|
}
|
|
|
|
function sendFriendMessage(uid, msg) {
|
|
return new Promise((resolve, reject) => {
|
|
if (!uid || typeof msg !== 'string') {
|
|
reject(new Error('id and msg are required'));
|
|
return;
|
|
}
|
|
|
|
steamUser.chat.sendFriendMessage(uid, msg, (err, response) => {
|
|
if (err) {
|
|
reject(err);
|
|
return;
|
|
}
|
|
|
|
appendOutgoingLog(uid, response);
|
|
|
|
resolve({
|
|
server_timestamp: response.server_timestamp,
|
|
steamid_friend: uid,
|
|
message: response.modified_message,
|
|
ordinal: response.ordinal,
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
async function ensureWebSession() {
|
|
await client.steamLoginPromise;
|
|
await client.steamWebLoginPromise;
|
|
}
|
|
|
|
function isTransientNetworkError(err) {
|
|
const code = String(err && err.code ? err.code : '').toUpperCase();
|
|
const message = String(err && err.message ? err.message : '').toLowerCase();
|
|
|
|
if ([
|
|
'ECONNRESET',
|
|
'ECONNABORTED',
|
|
'ETIMEDOUT',
|
|
'EPIPE',
|
|
'EAI_AGAIN',
|
|
'ENETUNREACH',
|
|
'EHOSTUNREACH',
|
|
'ECONNREFUSED',
|
|
].includes(code)) {
|
|
return true;
|
|
}
|
|
|
|
return message.includes('client network socket disconnected before secure tls connection was established')
|
|
|| message.includes('socket disconnected before secure tls connection was established')
|
|
|| message.includes('tls connection')
|
|
|| message.includes('socket hang up');
|
|
}
|
|
|
|
function isLikelyExpiredWebSessionError(err) {
|
|
const code = String(err && err.code ? err.code : '').toUpperCase();
|
|
const message = String(err && err.message ? err.message : '').toLowerCase();
|
|
|
|
if (code === 'ESESSIONEXPIRED' || code === 'EWEBSESSION') {
|
|
return true;
|
|
}
|
|
|
|
return message.includes('session')
|
|
|| message.includes('cookie')
|
|
|| message.includes('not logged in')
|
|
|| message.includes('access denied')
|
|
|| message.includes('forbidden');
|
|
}
|
|
|
|
async function waitForFreshWebSession(timeoutMs = 15000) {
|
|
let timer = null;
|
|
|
|
try {
|
|
await Promise.race([
|
|
onceFn(steamUser, 'webSession'),
|
|
new Promise((_, reject) => {
|
|
timer = setTimeout(() => {
|
|
const error = new Error(`Timed out after ${timeoutMs}ms while waiting for Steam web session`);
|
|
error.code = 'WEB_SESSION_TIMEOUT';
|
|
reject(error);
|
|
}, timeoutMs);
|
|
}),
|
|
]);
|
|
} finally {
|
|
if (timer) {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function readUrlAsBuffer(url) {
|
|
try {
|
|
const response = await axiosInstance.get(url, { responseType: 'arraybuffer' });
|
|
return Buffer.from(response.data);
|
|
} catch (err) {
|
|
const error = new Error(`Failed to fetch URL: ${err.message}`);
|
|
error.code = 400;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function parseImageBuffer({ img, url }) {
|
|
if (url) {
|
|
return readUrlAsBuffer(url);
|
|
}
|
|
|
|
if (img) {
|
|
const normalized = String(img).includes(',') ? String(img).split(',').pop() : String(img);
|
|
return Buffer.from(normalized, 'base64');
|
|
}
|
|
|
|
const error = new Error('img or url is required');
|
|
error.code = 400;
|
|
throw error;
|
|
}
|
|
|
|
function uploadImageToUser(uid, imageBuffer) {
|
|
return new Promise((resolve, reject) => {
|
|
steamCommunity.sendImageToUser(uid, imageBuffer, (err, imageUrl) => {
|
|
if (err) {
|
|
reject(err);
|
|
return;
|
|
}
|
|
|
|
resolve(imageUrl);
|
|
});
|
|
});
|
|
}
|
|
|
|
async function sendImageToUser(uid, img, url) {
|
|
if (!uid) {
|
|
const error = new Error('id is required');
|
|
error.code = 400;
|
|
throw error;
|
|
}
|
|
|
|
await ensureWebSession();
|
|
|
|
const imageBuffer = await parseImageBuffer({ img, url });
|
|
|
|
try {
|
|
return await uploadImageToUser(uid, imageBuffer);
|
|
} catch (err) {
|
|
let uploadError = err;
|
|
|
|
if (isTransientNetworkError(uploadError)) {
|
|
logger.warn('temporary network error while sending image, retrying once', {
|
|
id: uid,
|
|
error: uploadError.message,
|
|
code: uploadError.code || null,
|
|
});
|
|
|
|
try {
|
|
return await uploadImageToUser(uid, imageBuffer);
|
|
} catch (retryErr) {
|
|
uploadError = retryErr;
|
|
}
|
|
}
|
|
|
|
if (!isLikelyExpiredWebSessionError(uploadError) && !isTransientNetworkError(uploadError)) {
|
|
logger.error('an error occurred while sending image', uploadError);
|
|
throw uploadError;
|
|
}
|
|
|
|
logger.warn('failed to send image, trying to refresh web session', {
|
|
id: uid,
|
|
error: uploadError.message,
|
|
code: uploadError.code || null,
|
|
});
|
|
|
|
try {
|
|
steamUser.webLogOn();
|
|
await waitForFreshWebSession();
|
|
return await uploadImageToUser(uid, imageBuffer);
|
|
} catch (retryErr) {
|
|
logger.error('an error occurred while sending image', retryErr);
|
|
throw retryErr;
|
|
}
|
|
}
|
|
}
|
|
|
|
function parseLogLines(content) {
|
|
return String(content || '')
|
|
.split('\n')
|
|
.map((line) => line.trim())
|
|
.filter(Boolean)
|
|
.map((line) => {
|
|
try {
|
|
return normalizeHistoryEntry(JSON.parse(line));
|
|
} catch (err) {
|
|
logger.warn('skip invalid chat log line', { line });
|
|
return null;
|
|
}
|
|
})
|
|
.filter(Boolean);
|
|
}
|
|
|
|
async function readChatHistory({ id, limit } = {}) {
|
|
const lines = await new Promise((resolve, reject) => {
|
|
fsModule.readFile(CHAT_LOG_FILE, 'utf8', (err, content) => {
|
|
if (err) {
|
|
if (err.code === 'ENOENT') {
|
|
resolve('');
|
|
return;
|
|
}
|
|
reject(err);
|
|
return;
|
|
}
|
|
|
|
resolve(content);
|
|
});
|
|
});
|
|
|
|
const maxItems = sanitizeLimit(limit, 100);
|
|
let items = parseLogLines(lines);
|
|
|
|
if (id) {
|
|
items = items.filter((entry) => entry.id === id);
|
|
}
|
|
|
|
if (items.length > maxItems) {
|
|
items = items.slice(-maxItems);
|
|
}
|
|
|
|
return sortHistoryItems(items);
|
|
}
|
|
|
|
async function readConversationSummaries({ limit } = {}) {
|
|
const items = await readChatHistory({ limit: sanitizeLimit(limit, 500) });
|
|
const summaries = buildConversationSummaries(items);
|
|
|
|
for (const summary of summaries) {
|
|
if (!summary.name && summary.id) {
|
|
try {
|
|
const friend = await client.getUserInfo(summary.id, () => {});
|
|
summary.name = friend.player_name || summary.id;
|
|
} catch (err) {
|
|
summary.name = summary.id;
|
|
}
|
|
}
|
|
}
|
|
|
|
return summaries;
|
|
}
|
|
|
|
async function fetchStickerBuffer(type) {
|
|
const normalizedType = String(type || '').trim();
|
|
const cachePath = buildStickerCachePath(normalizedType);
|
|
const cached = await readFileIfExists(cachePath);
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
|
|
const pendingFetch = pendingStickerFetches.get(normalizedType);
|
|
if (pendingFetch) {
|
|
return pendingFetch;
|
|
}
|
|
|
|
const urls = buildSteamStickerCandidateUrls(normalizedType);
|
|
let lastError = null;
|
|
const fetchPromise = (async () => {
|
|
for (const url of urls) {
|
|
try {
|
|
const response = await axiosInstance.get(url, { responseType: 'arraybuffer' });
|
|
const buffer = Buffer.from(response.data);
|
|
if (!buffer.length) {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
await writeFileAsync(cachePath, buffer);
|
|
} catch (writeErr) {
|
|
logger.warn('failed to write sticker cache', { type: normalizedType, error: writeErr.message });
|
|
}
|
|
|
|
return buffer;
|
|
} catch (err) {
|
|
lastError = err;
|
|
}
|
|
}
|
|
|
|
const error = new Error(`Failed to fetch sticker: ${normalizedType}`);
|
|
error.code = 404;
|
|
error.cause = lastError;
|
|
throw error;
|
|
})();
|
|
|
|
pendingStickerFetches.set(normalizedType, fetchPromise);
|
|
|
|
try {
|
|
return await fetchPromise;
|
|
} finally {
|
|
if (pendingStickerFetches.get(normalizedType) === fetchPromise) {
|
|
pendingStickerFetches.delete(normalizedType);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function fetchCachedImage(url) {
|
|
const normalizedUrl = String(url || '').trim();
|
|
if (!/^https?:\/\//i.test(normalizedUrl)) {
|
|
const error = new Error('Invalid image URL');
|
|
error.code = 400;
|
|
throw error;
|
|
}
|
|
|
|
const { dataPath, metaPath } = buildImageCachePaths(normalizedUrl);
|
|
const cachedData = await readFileIfExists(dataPath);
|
|
if (cachedData) {
|
|
const cachedMeta = await readJsonIfExists(metaPath);
|
|
return {
|
|
buffer: cachedData,
|
|
contentType: (cachedMeta && cachedMeta.contentType) || guessImageContentType(normalizedUrl),
|
|
};
|
|
}
|
|
|
|
const pendingFetch = pendingImageFetches.get(normalizedUrl);
|
|
if (pendingFetch) {
|
|
return pendingFetch;
|
|
}
|
|
|
|
const fetchPromise = (async () => {
|
|
const response = await axiosInstance.get(normalizedUrl, { responseType: 'arraybuffer' });
|
|
const buffer = Buffer.from(response.data);
|
|
const contentType = guessImageContentType(
|
|
normalizedUrl,
|
|
response.headers && response.headers['content-type'] ? response.headers['content-type'] : 'image/png',
|
|
);
|
|
|
|
try {
|
|
await writeFileAsync(dataPath, buffer);
|
|
await writeFileAsync(metaPath, JSON.stringify({ contentType }));
|
|
} catch (err) {
|
|
logger.warn('failed to cache image', { url: normalizedUrl, error: err.message });
|
|
}
|
|
|
|
return {
|
|
buffer,
|
|
contentType,
|
|
};
|
|
})();
|
|
|
|
pendingImageFetches.set(normalizedUrl, fetchPromise);
|
|
|
|
try {
|
|
return await fetchPromise;
|
|
} finally {
|
|
if (pendingImageFetches.get(normalizedUrl) === fetchPromise) {
|
|
pendingImageFetches.delete(normalizedUrl);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function handleStickerProxy(req, res, type) {
|
|
try {
|
|
const buffer = await fetchStickerBuffer(type);
|
|
res.statusCode = 200;
|
|
res.setHeader('Content-Type', 'image/png');
|
|
res.setHeader('Content-Length', buffer.length);
|
|
res.setHeader('Cache-Control', 'public, max-age=86400');
|
|
res.end(buffer);
|
|
} catch (err) {
|
|
logger.warn('failed to proxy sticker', { type, error: err.message });
|
|
sendJson(res, err.code || 404, { error: err.message || 'Sticker Not Found' });
|
|
}
|
|
}
|
|
|
|
async function handleImageProxy(req, res, url) {
|
|
try {
|
|
const image = await fetchCachedImage(url);
|
|
res.statusCode = 200;
|
|
res.setHeader('Content-Type', image.contentType);
|
|
res.setHeader('Content-Length', image.buffer.length);
|
|
res.setHeader('Cache-Control', 'public, max-age=86400');
|
|
res.end(image.buffer);
|
|
} catch (err) {
|
|
logger.warn('failed to proxy image', { url, error: err.message });
|
|
sendJson(res, err.code || 404, { error: err.message || 'Image Not Found' });
|
|
}
|
|
}
|
|
|
|
async function handleSendMessageRequest(payload) {
|
|
const message = await sendFriendMessage(payload.id, payload.msg);
|
|
const encoded = await encodeSteamMessage(message, true);
|
|
rememberSelfMessage(encoded);
|
|
|
|
broadcastWs({
|
|
type: 'message',
|
|
data: encoded,
|
|
});
|
|
|
|
return encoded;
|
|
}
|
|
|
|
function rememberSelfImageUrl(uid, imageUrl) {
|
|
const urlKey = `${uid}:${imageUrl}`;
|
|
recentSelfImageUrls.set(urlKey, Date.now() + 15000);
|
|
|
|
// Also remember that we sent *any* image to this uid recently,
|
|
// so we can suppress echo messages that contain image URLs even
|
|
// if Steam transforms the URL format.
|
|
const uidKey = `img:${uid}`;
|
|
recentSelfImageUrls.set(uidKey, Date.now() + 15000);
|
|
|
|
const timer = setTimeout(() => {
|
|
recentSelfImageUrls.delete(urlKey);
|
|
recentSelfImageUrls.delete(uidKey);
|
|
}, 15000);
|
|
|
|
if (typeof timer.unref === 'function') {
|
|
timer.unref();
|
|
}
|
|
}
|
|
|
|
function wasRecentImageEcho(data) {
|
|
// Direct match: message text is exactly the remembered image URL
|
|
const directKey = `${data.id}:${data.message}`;
|
|
const directExpiry = recentSelfImageUrls.get(directKey);
|
|
if (directExpiry && directExpiry >= Date.now()) {
|
|
return true;
|
|
}
|
|
if (directExpiry) {
|
|
recentSelfImageUrls.delete(directKey);
|
|
}
|
|
|
|
// Extract image URLs from the message and check each one,
|
|
// because Steam may echo the URL wrapped in BBCode like
|
|
// [img src=URL ...]...[/img] or [img]URL[/img].
|
|
const urls = extractImageUrls(data.message);
|
|
for (const url of urls) {
|
|
const key = `${data.id}:${url}`;
|
|
const expiresAt = recentSelfImageUrls.get(key);
|
|
if (!expiresAt) {
|
|
continue;
|
|
}
|
|
if (expiresAt < Date.now()) {
|
|
recentSelfImageUrls.delete(key);
|
|
continue;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// Fallback: if we recently sent any image to this uid and
|
|
// the echo message contains any URL or image BBCode, suppress
|
|
// it even if the exact URL didn't match (Steam may rewrite
|
|
// the URL or use a host without a file extension).
|
|
const messageText = String(data.message || '');
|
|
const looksLikeImageEcho = urls.length > 0
|
|
|| /https?:\/\/\S*(?:image|img|ugc|media|cdn)\S*/i.test(messageText)
|
|
|| /\[img[\s\]]/i.test(messageText);
|
|
if (looksLikeImageEcho) {
|
|
const uidKey = `img:${data.id}`;
|
|
const uidExpiry = recentSelfImageUrls.get(uidKey);
|
|
if (uidExpiry && uidExpiry >= Date.now()) {
|
|
return true;
|
|
}
|
|
if (uidExpiry) {
|
|
recentSelfImageUrls.delete(uidKey);
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
async function handleSendImageRequest(payload, { senderWs } = {}) {
|
|
const imageUrl = await sendImageToUser(payload.id, payload.img, payload.url);
|
|
const data = await appendOutgoingImageLog(payload.id, imageUrl);
|
|
|
|
// Remember the image URL so the friendMessageEcho (which echoes
|
|
// the image URL as a text message) gets deduplicated.
|
|
rememberSelfImageUrl(payload.id, imageUrl);
|
|
|
|
// Broadcast to all clients except the sender (who gets image_sent).
|
|
const encoded = JSON.stringify({ type: 'image', data });
|
|
wss.clients.forEach((ws) => {
|
|
if (ws !== senderWs && ws.readyState === WebSocketImpl.OPEN) {
|
|
ws.send(encoded);
|
|
}
|
|
});
|
|
|
|
return data;
|
|
}
|
|
|
|
async function handleHttp(req, res) {
|
|
const requestUrl = new URL(req.url, 'http://127.0.0.1');
|
|
|
|
if (requiresHttpAuth(req, chatConfig) && !isAuthorized(req, chatConfig.auth)) {
|
|
sendAuthRequired(res);
|
|
return;
|
|
}
|
|
|
|
if (req.method === 'GET' && requestUrl.pathname === '/') {
|
|
serveStaticFile(res, 'index.html');
|
|
return;
|
|
}
|
|
|
|
if (req.method === 'GET' && requestUrl.pathname === '/api/config') {
|
|
sendJson(res, 200, { wsPath: chatConfig.wsPath });
|
|
return;
|
|
}
|
|
|
|
if (req.method === 'GET' && requestUrl.pathname === '/api/emoticons') {
|
|
try {
|
|
const data = await getEmoticonList();
|
|
sendJson(res, 200, data);
|
|
} catch (err) {
|
|
logger.error('failed to get emoticon list', err);
|
|
sendJson(res, err.code || 500, { error: err.message || 'Internal Server Error' });
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (req.method === 'GET' && requestUrl.pathname.startsWith('/proxy/sticker/')) {
|
|
const type = decodeURIComponent(requestUrl.pathname.slice('/proxy/sticker/'.length));
|
|
await handleStickerProxy(req, res, type);
|
|
return;
|
|
}
|
|
|
|
if (req.method === 'GET' && requestUrl.pathname === '/proxy/image') {
|
|
await handleImageProxy(req, res, requestUrl.searchParams.get('url') || '');
|
|
return;
|
|
}
|
|
|
|
if (req.method === 'GET' && requestUrl.pathname === '/history') {
|
|
try {
|
|
const items = await readChatHistory({
|
|
id: requestUrl.searchParams.get('id') || undefined,
|
|
limit: requestUrl.searchParams.get('limit') || undefined,
|
|
});
|
|
sendJson(res, 200, { items });
|
|
} catch (err) {
|
|
logger.error('failed to read chat history', err);
|
|
sendJson(res, 500, { error: err.message || 'Internal Server Error' });
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (req.method === 'GET' && requestUrl.pathname === '/conversations') {
|
|
try {
|
|
const items = await readConversationSummaries({
|
|
limit: requestUrl.searchParams.get('limit') || undefined,
|
|
});
|
|
sendJson(res, 200, { items });
|
|
} catch (err) {
|
|
logger.error('failed to read conversations', err);
|
|
sendJson(res, 500, { error: err.message || 'Internal Server Error' });
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (req.method === 'GET' && requestUrl.pathname === '/api/friends') {
|
|
try {
|
|
const items = await getFriendsList();
|
|
sendJson(res, 200, { items });
|
|
} catch (err) {
|
|
logger.error('failed to get friends list', err);
|
|
sendJson(res, 500, { error: err.message || 'Internal Server Error' });
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (req.method === 'GET' && requestUrl.pathname === '/api/groups') {
|
|
try {
|
|
const items = await getGroupsList();
|
|
sendJson(res, 200, { items });
|
|
} catch (err) {
|
|
logger.error('failed to get groups list', err);
|
|
sendJson(res, 500, { error: err.message || 'Internal Server Error' });
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (req.method === 'GET') {
|
|
const ext = path.extname(requestUrl.pathname);
|
|
if (ext && STATIC_CONTENT_TYPES[ext]) {
|
|
serveStaticFile(res, requestUrl.pathname);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (req.method !== 'POST') {
|
|
sendJson(res, 404, { error: 'Not Found' });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const payload = await readJsonBody(req);
|
|
|
|
if (requestUrl.pathname === '/img' || requestUrl.pathname === '/image') {
|
|
const data = await handleSendImageRequest(payload);
|
|
sendJson(res, 200, data);
|
|
return;
|
|
}
|
|
|
|
if (requestUrl.pathname === '/' || requestUrl.pathname === '/message') {
|
|
const data = await handleSendMessageRequest(payload);
|
|
sendJson(res, 200, data);
|
|
return;
|
|
}
|
|
|
|
sendJson(res, 404, { error: 'Not Found' });
|
|
} catch (err) {
|
|
logger.error('An error occurred while processing the request', err);
|
|
sendJson(res, err.code || 500, { error: err.message || 'Internal Server Error' });
|
|
}
|
|
}
|
|
|
|
async function handleWsCommand(ws, payload) {
|
|
const request = normalizeWsRequest(payload);
|
|
|
|
switch (request.action) {
|
|
case 'send_message': {
|
|
const data = await handleSendMessageRequest(request);
|
|
sendWs(ws, {
|
|
type: 'message_sent',
|
|
requestId: request.requestId,
|
|
data,
|
|
});
|
|
return;
|
|
}
|
|
case 'send_image': {
|
|
const data = await handleSendImageRequest(request, { senderWs: ws });
|
|
sendWs(ws, {
|
|
type: 'image_sent',
|
|
requestId: request.requestId,
|
|
data,
|
|
});
|
|
return;
|
|
}
|
|
case 'get_history': {
|
|
const items = await readChatHistory({
|
|
id: request.id,
|
|
limit: request.limit,
|
|
});
|
|
sendWs(ws, {
|
|
type: 'history',
|
|
requestId: request.requestId,
|
|
data: {
|
|
items,
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
case 'get_conversations': {
|
|
const items = await readConversationSummaries({
|
|
limit: request.limit,
|
|
});
|
|
sendWs(ws, {
|
|
type: 'conversations',
|
|
requestId: request.requestId,
|
|
data: {
|
|
items,
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
case 'get_emoticons': {
|
|
const emoticonData = await getEmoticonList();
|
|
sendWs(ws, {
|
|
type: 'emoticons',
|
|
requestId: request.requestId,
|
|
data: emoticonData,
|
|
});
|
|
return;
|
|
}
|
|
case 'get_friends': {
|
|
const items = await getFriendsList();
|
|
sendWs(ws, {
|
|
type: 'friends',
|
|
requestId: request.requestId,
|
|
data: { items },
|
|
});
|
|
return;
|
|
}
|
|
case 'get_groups': {
|
|
const items = await getGroupsList();
|
|
sendWs(ws, {
|
|
type: 'groups',
|
|
requestId: request.requestId,
|
|
data: { items },
|
|
});
|
|
return;
|
|
}
|
|
case 'ping':
|
|
sendWs(ws, {
|
|
type: 'pong',
|
|
requestId: request.requestId,
|
|
data: {
|
|
now: new Date().toISOString(),
|
|
},
|
|
});
|
|
return;
|
|
default: {
|
|
const error = new Error(`Unsupported WebSocket message type: ${payload.type}`);
|
|
error.code = 400;
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
function handleWs(ws) {
|
|
logger.info('WebSocket connection established');
|
|
sendWs(ws, {
|
|
type: 'ready',
|
|
data: {
|
|
wsPath: chatConfig.wsPath,
|
|
},
|
|
});
|
|
|
|
ws.on('message', async (message) => {
|
|
let payload;
|
|
try {
|
|
payload = JSON.parse(message.toString());
|
|
} catch (err) {
|
|
sendWs(ws, {
|
|
type: 'error',
|
|
message: 'Invalid JSON',
|
|
});
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await handleWsCommand(ws, payload);
|
|
} catch (err) {
|
|
logger.error('WebSocket command failed', err);
|
|
sendWs(ws, {
|
|
type: 'error',
|
|
requestId: payload.requestId,
|
|
message: err.message || 'Internal Server Error',
|
|
});
|
|
}
|
|
});
|
|
|
|
ws.on('close', () => {
|
|
logger.info('WebSocket connection closed');
|
|
});
|
|
|
|
ws.on('error', (err) => {
|
|
logger.error('WebSocket error', err);
|
|
});
|
|
}
|
|
|
|
async function start() {
|
|
if (started || !chatConfig.enabled) {
|
|
return;
|
|
}
|
|
started = true;
|
|
|
|
await client.steamLoginPromise;
|
|
|
|
steamUser.chat.on('friendMessage', (message) => {
|
|
broadcastSteamMessage(message, false).catch((err) => {
|
|
logger.error('failed to broadcast friend message', err);
|
|
});
|
|
});
|
|
|
|
steamUser.chat.on('friendMessageEcho', (message) => {
|
|
broadcastSteamMessage(message, true, { dedupe: true }).catch((err) => {
|
|
logger.error('failed to broadcast echoed friend message', err);
|
|
});
|
|
});
|
|
|
|
wss.on('connection', handleWs);
|
|
|
|
await new Promise((resolve, reject) => {
|
|
server.listen(chatConfig.port, chatConfig.host, (err) => {
|
|
if (err) {
|
|
reject(err);
|
|
return;
|
|
}
|
|
|
|
logger.info('chat server started', {
|
|
host: chatConfig.host,
|
|
port: chatConfig.port,
|
|
wsPath: chatConfig.wsPath,
|
|
});
|
|
resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
return {
|
|
chatConfig,
|
|
server,
|
|
wss,
|
|
start,
|
|
sendFriendMessage,
|
|
sendImageToUser,
|
|
getEmoticonList,
|
|
getFriendsList,
|
|
getGroupsList,
|
|
readChatHistory,
|
|
readConversationSummaries,
|
|
fetchStickerBuffer,
|
|
fetchCachedImage,
|
|
handleSendMessageRequest,
|
|
handleSendImageRequest,
|
|
handleHttp,
|
|
handleWs,
|
|
handleWsCommand,
|
|
broadcastSteamMessage,
|
|
encodeSteamMessage,
|
|
parseImageBuffer,
|
|
parseLogLines,
|
|
readJsonBody,
|
|
wasRecentlyBroadcasted,
|
|
rememberSelfMessage,
|
|
sendWs,
|
|
broadcastWs,
|
|
};
|
|
}
|
|
|
|
let defaultChatService = null;
|
|
|
|
if (!process.env.STEAM_CHAT_DISABLE_AUTOSTART) {
|
|
defaultChatService = createChatService();
|
|
defaultChatService.start().catch((err) => {
|
|
console.error('Error during chat service initialization', err);
|
|
});
|
|
}
|
|
|
|
module.exports = {
|
|
CHAT_LOG_FILE,
|
|
STICKER_CACHE_DIR,
|
|
IMAGE_CACHE_DIR,
|
|
createChatService,
|
|
normalizeAuthConfig,
|
|
normalizeChatConfig,
|
|
normalizeHistoryEntry,
|
|
normalizeWsRequest,
|
|
normalizeIpAddress,
|
|
parseForwardedHeader,
|
|
getClientIp,
|
|
isLanIp,
|
|
isAuthEnabled,
|
|
parseBasicAuthHeader,
|
|
isAuthorized,
|
|
requiresHttpAuth,
|
|
sanitizeLimit,
|
|
extractStickerType,
|
|
extractEmoticonNames,
|
|
extractImageUrls,
|
|
extractOpenGraphEmbeds,
|
|
buildSteamEmoticonUrl,
|
|
buildSteamStickerCandidateUrls,
|
|
buildStickerCachePath,
|
|
buildImageCachePaths,
|
|
guessImageContentType,
|
|
buildConversationPreview,
|
|
sortHistoryItems,
|
|
buildConversationSummaries,
|
|
buildMessageKey,
|
|
defaultChatService,
|
|
};
|