mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-19 07:08:33 +02:00
fix: chat scrollback and admin control
This commit is contained in:
+172
@@ -284,6 +284,7 @@
|
||||
<button class="tab-btn" data-tab="payouts">Payouts</button>
|
||||
<button class="tab-btn" data-tab="games">Games</button>
|
||||
<button class="tab-btn" data-tab="users">Users</button>
|
||||
<button class="tab-btn" data-tab="chat">Chat</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Overview -->
|
||||
@@ -406,6 +407,40 @@
|
||||
<div class="pagination" id="usersPagination"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Chat -->
|
||||
<div id="tab-chat" class="tab-content">
|
||||
<div class="table-container">
|
||||
<div class="table-header">
|
||||
<h3>Chat Moderation</h3>
|
||||
<div style="display: flex; gap: 10px; align-items: center;">
|
||||
<label style="color:#888; font-size:12px;">
|
||||
<input type="checkbox" id="showDeletedMessages" onchange="loadChat()"> Show deleted
|
||||
</label>
|
||||
<button onclick="loadChat()" style="background:#055; color:#0f0; border:1px solid #0f0; padding:4px 10px; cursor:pointer;">🔄 Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
<p style="color:#888; margin: 10px 0; font-size:12px;">
|
||||
Delete offensive messages (they'll be removed from all clients). Ban users from chat if needed.
|
||||
</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Time</th>
|
||||
<th>Player</th>
|
||||
<th>Message</th>
|
||||
<th>User ID</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="chatTable">
|
||||
<tr><td colspan="7" class="loading">Loading...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -500,6 +535,7 @@
|
||||
else if (tab === 'payouts') loadPayouts();
|
||||
else if (tab === 'games') loadGames();
|
||||
else if (tab === 'users') loadUsers();
|
||||
else if (tab === 'chat') loadChat();
|
||||
}
|
||||
|
||||
async function loadDashboard() {
|
||||
@@ -806,6 +842,142 @@
|
||||
loadUsers(0);
|
||||
}
|
||||
|
||||
// Chat moderation functions
|
||||
async function loadChat() {
|
||||
const includeDeleted = document.getElementById('showDeletedMessages')?.checked || false;
|
||||
|
||||
try {
|
||||
const url = `/api/admin/chat?limit=100&includeDeleted=${includeDeleted}`;
|
||||
const res = await fetch(url, { headers: { 'X-Admin-Key': apiKey } });
|
||||
const data = await res.json();
|
||||
|
||||
const tbody = document.getElementById('chatTable');
|
||||
if (!data.messages?.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="loading">No chat messages</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = data.messages.map(m => {
|
||||
const isDeleted = !!m.deletedAt;
|
||||
const isBanned = m.chatBanned;
|
||||
const rowStyle = isDeleted ? 'opacity: 0.5; text-decoration: line-through;' : '';
|
||||
|
||||
return `
|
||||
<tr style="${rowStyle}">
|
||||
<td>${m.id}</td>
|
||||
<td>${formatTimeAgo(new Date(m.timestamp))}</td>
|
||||
<td>${m.playerId || m.username || '-'}</td>
|
||||
<td style="max-width: 300px; overflow: hidden; text-overflow: ellipsis;" title="${escapeHtml(m.message)}">${escapeHtml(m.message)}</td>
|
||||
<td>${m.userId || '-'}</td>
|
||||
<td>
|
||||
${isDeleted ? '<span class="status-badge status-failed">Deleted</span>' : ''}
|
||||
${isBanned ? '<span class="status-badge status-failed">Banned</span>' : ''}
|
||||
${!isDeleted && !isBanned ? '<span class="status-badge status-completed">Active</span>' : ''}
|
||||
</td>
|
||||
<td>
|
||||
${!isDeleted ? `<button class="btn btn-primary" onclick="deleteMessage(${m.id})" style="background:#c33;">Delete</button>` : ''}
|
||||
${m.userId && !isBanned ? `<button class="btn btn-primary" onclick="banUser(${m.userId})" style="background:#a00; margin-left:4px;">Ban</button>` : ''}
|
||||
${m.userId && isBanned ? `<button class="btn btn-primary" onclick="unbanUser(${m.userId})" style="background:#060; margin-left:4px;">Unban</button>` : ''}
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
} catch (e) {
|
||||
console.error('Failed to load chat:', e);
|
||||
document.getElementById('chatTable').innerHTML = '<tr><td colspan="7" class="loading">Error loading chat</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteMessage(messageId) {
|
||||
if (!confirm('Delete this message? It will be removed from all clients.')) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/admin/chat/${messageId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-Admin-Key': apiKey,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ reason: 'Admin deleted' })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
loadChat();
|
||||
} else {
|
||||
alert('Failed to delete: ' + (data.message || 'Unknown error'));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to delete message:', e);
|
||||
alert('Error deleting message');
|
||||
}
|
||||
}
|
||||
|
||||
async function banUser(userId) {
|
||||
const reason = prompt('Ban reason (optional):');
|
||||
if (reason === null) return; // Cancelled
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/admin/users/${userId}/chat-ban`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Admin-Key': apiKey,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ banned: true, reason: reason || 'Admin banned' })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
loadChat();
|
||||
} else {
|
||||
alert('Failed to ban: ' + (data.message || 'Unknown error'));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to ban user:', e);
|
||||
alert('Error banning user');
|
||||
}
|
||||
}
|
||||
|
||||
async function unbanUser(userId) {
|
||||
if (!confirm('Unban this user from chat?')) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/admin/users/${userId}/chat-ban`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Admin-Key': apiKey,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ banned: false })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
loadChat();
|
||||
} else {
|
||||
alert('Failed to unban: ' + (data.message || 'Unknown error'));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to unban user:', e);
|
||||
alert('Error unbanning user');
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function formatTimeAgo(date) {
|
||||
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
|
||||
if (seconds < 60) return 'just now';
|
||||
if (seconds < 3600) return Math.floor(seconds / 60) + 'm ago';
|
||||
if (seconds < 86400) return Math.floor(seconds / 3600) + 'h ago';
|
||||
return Math.floor(seconds / 86400) + 'd ago';
|
||||
}
|
||||
|
||||
// Auto-refresh every 30 seconds
|
||||
setInterval(() => {
|
||||
if (apiKey && currentTab === 'overview') {
|
||||
|
||||
+2
-1
@@ -205,7 +205,8 @@
|
||||
<!-- Chat input in its own row -->
|
||||
<div class="input-container">
|
||||
<form id="chatForm" action="">
|
||||
<input id="chatInput" autocomplete="off" placeholder="Type a command..." />
|
||||
<input id="chatInput" autocomplete="off" placeholder="Type a command..." maxlength="200" />
|
||||
<span id="charCounter" style="color:#666; font-size:10px; margin-left:5px; min-width:45px;">0/200</span>
|
||||
<button type="submit">Send</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -21,22 +21,39 @@ const InputHandler = {
|
||||
},
|
||||
|
||||
setupChatForm: function() {
|
||||
const MAX_CHAT_LENGTH = 200;
|
||||
|
||||
// Character counter update
|
||||
$('#chatInput').on('input', function() {
|
||||
const len = $(this).val().length;
|
||||
const counter = $('#charCounter');
|
||||
counter.text(len + '/' + MAX_CHAT_LENGTH);
|
||||
// Change color when approaching limit
|
||||
if (len >= MAX_CHAT_LENGTH) {
|
||||
counter.css('color', '#f66');
|
||||
} else if (len >= MAX_CHAT_LENGTH * 0.8) {
|
||||
counter.css('color', '#fa0');
|
||||
} else {
|
||||
counter.css('color', '#666');
|
||||
}
|
||||
});
|
||||
|
||||
$('#chatForm').submit(function(e) {
|
||||
e.preventDefault();
|
||||
var msg = $('#chatInput').val().trim();
|
||||
|
||||
|
||||
// Don't send empty messages
|
||||
if (!msg) return false;
|
||||
|
||||
|
||||
// If the message is "enter", always send it to server
|
||||
// The server will handle queueing logic and timing
|
||||
if (msg.toLowerCase() === 'enter') {
|
||||
socket.emit('chat message', msg);
|
||||
|
||||
|
||||
// Don't automatically switch to waiting screen - let the server control screen state
|
||||
// The server will send appropriate events (payment_created, waiting_status, etc.)
|
||||
// to control what the user sees
|
||||
|
||||
|
||||
// Add visual feedback that request was sent
|
||||
$('#messages').append($('<li style="color: #0f0;">').text("🔑 Processing game entry request..."));
|
||||
UI.scrollChat();
|
||||
@@ -44,8 +61,9 @@ const InputHandler = {
|
||||
// Send other chat messages normally
|
||||
socket.emit('chat message', msg);
|
||||
}
|
||||
|
||||
|
||||
$('#chatInput').val('');
|
||||
$('#charCounter').text('0/' + MAX_CHAT_LENGTH).css('color', '#666');
|
||||
return false;
|
||||
});
|
||||
|
||||
|
||||
@@ -120,7 +120,9 @@ const SocketHandlers = {
|
||||
socket.on('status_update', this.onStatusUpdate);
|
||||
socket.on('chat_broadcast', this.onChatBroadcast);
|
||||
socket.on('chat_history', this.onChatHistory);
|
||||
|
||||
socket.on('chat_deleted', this.onChatDeleted);
|
||||
socket.on('chat_error', this.onChatError);
|
||||
|
||||
// Game state handlers
|
||||
socket.on('waiting_status', this.onWaitingStatus);
|
||||
socket.on('queue_joined', this.onQueueJoined);
|
||||
@@ -298,27 +300,61 @@ const SocketHandlers = {
|
||||
|
||||
onChatHistory: function(data) {
|
||||
if (!data || !data.messages || !Array.isArray(data.messages)) return;
|
||||
|
||||
|
||||
// Add a separator for history messages
|
||||
if (data.messages.length > 0) {
|
||||
$('#messages').append($('<li class="chat-history-header" style="color: #666; font-style: italic; border-bottom: 1px solid #333; margin-bottom: 5px; padding-bottom: 5px;">').text('--- Recent Chat History ---'));
|
||||
}
|
||||
|
||||
// Add each historical message
|
||||
|
||||
// Add each historical message with data-msg-id for deletion support
|
||||
data.messages.forEach(function(msg) {
|
||||
const msgElement = $('<li style="color: #888;">');
|
||||
const username = msg.username || (msg.socketId ? msg.socketId.substring(0, 6) : 'anon');
|
||||
msgElement.html('<strong>' + username + ':</strong> ' + msg.message);
|
||||
if (msg.id) {
|
||||
msgElement.attr('data-msg-id', msg.id);
|
||||
}
|
||||
const username = msg.playerId || msg.username || (msg.socketId ? msg.socketId.substring(0, 6) : 'anon');
|
||||
const isSystem = msg.type === 'system' || msg.socketId === 'system';
|
||||
if (isSystem) {
|
||||
msgElement.addClass('status');
|
||||
}
|
||||
// Add timestamp if available
|
||||
let timeStr = '';
|
||||
if (msg.timestamp) {
|
||||
timeStr = '<span style="color:#555;font-size:10px;">[' + SocketHandlers._formatTimeAgo(new Date(msg.timestamp)) + ']</span> ';
|
||||
}
|
||||
msgElement.html(timeStr + '<strong>' + username + ':</strong> ' + msg.message);
|
||||
$('#messages').append(msgElement);
|
||||
});
|
||||
|
||||
|
||||
if (data.messages.length > 0) {
|
||||
$('#messages').append($('<li class="chat-history-footer" style="color: #666; font-style: italic; border-top: 1px solid #333; margin-top: 5px; padding-top: 5px;">').text('--- End of History ---'));
|
||||
}
|
||||
|
||||
|
||||
UI.scrollChat();
|
||||
},
|
||||
|
||||
onChatDeleted: function(data) {
|
||||
if (!data || !data.messageId) return;
|
||||
// Remove deleted message from view with fade effect
|
||||
$('#messages li[data-msg-id="' + data.messageId + '"]').fadeOut(200, function() {
|
||||
$(this).remove();
|
||||
});
|
||||
},
|
||||
|
||||
onChatError: function(data) {
|
||||
if (!data || !data.message) return;
|
||||
$('#messages').append($('<li class="error" style="color:#f66;">').text(data.message));
|
||||
UI.scrollChat();
|
||||
},
|
||||
|
||||
_formatTimeAgo: function(date) {
|
||||
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
|
||||
if (seconds < 60) return 'just now';
|
||||
if (seconds < 3600) return Math.floor(seconds / 60) + 'm ago';
|
||||
if (seconds < 86400) return Math.floor(seconds / 3600) + 'h ago';
|
||||
return Math.floor(seconds / 86400) + 'd ago';
|
||||
},
|
||||
|
||||
onWaitingStatus: function(data) {
|
||||
if (data.status === 'waiting') {
|
||||
$('#messages').append($('<li style="color:#ff0;">').text(data.message));
|
||||
|
||||
@@ -996,6 +996,93 @@ app.post('/api/admin/queue/remove', adminAuth, asyncHandler(async (req, res) =>
|
||||
});
|
||||
}));
|
||||
|
||||
// =============================================================================
|
||||
// ADMIN CHAT MODERATION ENDPOINTS
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* GET /api/admin/chat
|
||||
* List recent chat messages for moderation
|
||||
* Query: ?limit=100&includeDeleted=false
|
||||
*/
|
||||
app.get('/api/admin/chat', adminAuth, asyncHandler(async (req, res) => {
|
||||
const limit = Math.min(parseInt(req.query.limit) || 100, 500);
|
||||
const includeDeleted = req.query.includeDeleted === 'true';
|
||||
|
||||
const messages = await socketHandlers.chatHandler.chatHistory.getMessagesForAdmin(limit, includeDeleted);
|
||||
|
||||
res.json({
|
||||
messages,
|
||||
count: messages.length
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/chat/:id
|
||||
* Soft delete a chat message
|
||||
* Body: { reason?: string }
|
||||
*/
|
||||
app.delete('/api/admin/chat/:id', adminAuth, asyncHandler(async (req, res) => {
|
||||
const messageId = parseInt(req.params.id);
|
||||
const { reason } = req.body;
|
||||
|
||||
if (!messageId || isNaN(messageId)) {
|
||||
throw new ValidationError('Invalid message ID');
|
||||
}
|
||||
|
||||
const deleted = await socketHandlers.chatHandler.chatHistory.deleteMessage(
|
||||
messageId,
|
||||
'admin',
|
||||
reason || 'Admin deleted'
|
||||
);
|
||||
|
||||
if (deleted) {
|
||||
// Broadcast deletion to all connected clients
|
||||
io.emit('chat_deleted', { messageId });
|
||||
console.log(`[Admin] Chat message deleted: id=${messageId}, reason="${reason || 'No reason provided'}"`);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: deleted,
|
||||
message: deleted ? 'Message deleted' : 'Message not found or already deleted'
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* POST /api/admin/users/:id/chat-ban
|
||||
* Ban or unban a user from chat
|
||||
* Body: { banned: boolean, reason?: string }
|
||||
*/
|
||||
app.post('/api/admin/users/:id/chat-ban', adminAuth, asyncHandler(async (req, res) => {
|
||||
const userId = parseInt(req.params.id);
|
||||
const { banned, reason } = req.body;
|
||||
|
||||
if (!userId || isNaN(userId)) {
|
||||
throw new ValidationError('Invalid user ID');
|
||||
}
|
||||
|
||||
if (typeof banned !== 'boolean') {
|
||||
throw new ValidationError('banned must be a boolean');
|
||||
}
|
||||
|
||||
await db.query(`
|
||||
UPDATE users
|
||||
SET chat_banned = $2,
|
||||
chat_banned_at = CASE WHEN $2 THEN NOW() ELSE NULL END,
|
||||
chat_banned_reason = $3
|
||||
WHERE id = $1
|
||||
`, [userId, banned, reason || null]);
|
||||
|
||||
console.log(`[Admin] User ${userId} chat ${banned ? 'banned' : 'unbanned'}: reason="${reason || 'No reason provided'}"`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
userId,
|
||||
banned,
|
||||
reason: reason || null
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* GET /api/admin/users/search
|
||||
* Search for users by socket ID prefix or payout address
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
-- Chat Moderation Migration
|
||||
-- Adds moderation capabilities to chat system
|
||||
|
||||
-- Add moderation fields to chat_messages
|
||||
ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id);
|
||||
ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS player_id VARCHAR(10);
|
||||
ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMP;
|
||||
ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS deleted_by VARCHAR(255);
|
||||
ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS delete_reason VARCHAR(255);
|
||||
|
||||
-- Index for non-deleted messages
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_messages_active
|
||||
ON chat_messages(created_at DESC)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
-- Add chat ban fields to users table
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS chat_banned BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS chat_banned_at TIMESTAMP;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS chat_banned_reason VARCHAR(255);
|
||||
|
||||
-- Seed initial messages (only insert if table is empty or no system messages exist)
|
||||
-- These give context and make the chat feel alive on fresh deploy
|
||||
INSERT INTO chat_messages (socket_id, username, player_id, message, message_type, created_at)
|
||||
SELECT 'system', 'SERVER', 'SERVER', msg, 'system', ts
|
||||
FROM (VALUES
|
||||
('Welcome to Wownerogue! Use arrow keys or WASD to move.', NOW() - INTERVAL '1 hour'),
|
||||
('Escape the dungeon to win. Collect treasure for bonus payout!', NOW() - INTERVAL '59 minutes'),
|
||||
('Watch out for monsters - they''re fast!', NOW() - INTERVAL '58 minutes'),
|
||||
('Type /help for commands.', NOW() - INTERVAL '57 minutes'),
|
||||
('Good luck and have fun!', NOW() - INTERVAL '56 minutes')
|
||||
) AS seed_data(msg, ts)
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM chat_messages WHERE socket_id = 'system' LIMIT 1
|
||||
);
|
||||
|
||||
-- Comment on new columns
|
||||
COMMENT ON COLUMN chat_messages.user_id IS 'Reference to users table for registered users';
|
||||
COMMENT ON COLUMN chat_messages.player_id IS 'Display ID like PiMjKP shown in chat';
|
||||
COMMENT ON COLUMN chat_messages.deleted_at IS 'Soft delete timestamp for moderation';
|
||||
COMMENT ON COLUMN chat_messages.deleted_by IS 'Admin who deleted the message';
|
||||
COMMENT ON COLUMN chat_messages.delete_reason IS 'Reason for deletion';
|
||||
COMMENT ON COLUMN users.chat_banned IS 'Whether user is banned from chat';
|
||||
COMMENT ON COLUMN users.chat_banned_at IS 'When the chat ban was applied';
|
||||
COMMENT ON COLUMN users.chat_banned_reason IS 'Reason for chat ban';
|
||||
@@ -223,13 +223,42 @@ class ChatHandler {
|
||||
const trimmed = msg.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
// Message length limit (reject, don't truncate)
|
||||
const MAX_MESSAGE_LENGTH = 200;
|
||||
if (trimmed.length > MAX_MESSAGE_LENGTH) {
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'warning',
|
||||
`Message too long (${trimmed.length}/${MAX_MESSAGE_LENGTH} characters). Please shorten it.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get user info for ban check and user_id
|
||||
let userId = null;
|
||||
if (this.gameModeManager) {
|
||||
try {
|
||||
const userRow = await this.gameModeManager.getOrCreateUser(socket.id);
|
||||
userId = userRow?.id || null;
|
||||
|
||||
// Check if user is chat banned
|
||||
if (userId && await this.chatHistory.isUserChatBanned(userId)) {
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'error',
|
||||
'You have been banned from chat.');
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
// Continue without user lookup - allow message
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.warn('User lookup for chat failed:', e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Additional rate limit for broadcast messages (more restrictive)
|
||||
const now = Date.now();
|
||||
const last = this._chatLastSent.get(socket.id) || 0;
|
||||
const BROADCAST_COOLDOWN = 2000; // 2 seconds between broadcasts
|
||||
|
||||
|
||||
if (now - last < BROADCAST_COOLDOWN) {
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'warning',
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'warning',
|
||||
`Please wait ${Math.ceil((BROADCAST_COOLDOWN - (now - last)) / 1000)} seconds before sending another message.`);
|
||||
return;
|
||||
}
|
||||
@@ -237,25 +266,27 @@ class ChatHandler {
|
||||
this._chatLastSent.set(socket.id, now);
|
||||
|
||||
// Complete HTML entity escaping to prevent XSS
|
||||
// Note: length already validated above, slice is just a safety net
|
||||
const safe = trimmed
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
.slice(0, 300);
|
||||
.slice(0, MAX_MESSAGE_LENGTH);
|
||||
const username = socket.id.substring(0, 6);
|
||||
|
||||
// Save message to history (async, don't block broadcast)
|
||||
|
||||
// Save message to history with user_id (async, don't block broadcast)
|
||||
this.chatHistory.saveMessage({
|
||||
socketId: socket.id,
|
||||
username: username,
|
||||
message: safe,
|
||||
type: 'chat'
|
||||
type: 'chat',
|
||||
userId: userId
|
||||
}).catch(err => {
|
||||
console.error('Failed to save chat message:', err.message);
|
||||
});
|
||||
|
||||
|
||||
this.broadcastManager.broadcastChatMessage(username, safe, now, socket.id);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ class ChatHistoryManager {
|
||||
|
||||
// Cleanup old messages periodically (once per day)
|
||||
this._cleanupInterval = null;
|
||||
this._maxMessageAge = 30 * 24 * 60 * 60 * 1000; // 30 days in ms
|
||||
this._maxMessageAge = 7 * 24 * 60 * 60 * 1000; // 7 days in ms
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -119,19 +119,23 @@ class ChatHistoryManager {
|
||||
* Save a chat message
|
||||
* @param {Object} messageData - Message data
|
||||
* @param {string} messageData.socketId - Socket ID of sender
|
||||
* @param {string} messageData.username - Display username
|
||||
* @param {string} messageData.username - Display username (playerId)
|
||||
* @param {string} messageData.message - Message content
|
||||
* @param {string} messageData.type - Message type (chat, system, event)
|
||||
* @param {number} [messageData.userId] - Optional user ID from database
|
||||
* @returns {Object} Saved message with id and timestamp
|
||||
*/
|
||||
async saveMessage({ socketId, username, message, type = 'chat' }) {
|
||||
async saveMessage({ socketId, username, message, type = 'chat', userId = null }) {
|
||||
const timestamp = Date.now();
|
||||
const playerId = username; // username IS the playerId (6-char display ID)
|
||||
const messageObj = {
|
||||
socketId,
|
||||
username,
|
||||
playerId,
|
||||
message,
|
||||
type,
|
||||
timestamp
|
||||
timestamp,
|
||||
userId
|
||||
};
|
||||
|
||||
// Add to in-memory cache immediately
|
||||
@@ -139,7 +143,7 @@ class ChatHistoryManager {
|
||||
if (this._recentMessagesCache.length > this._cacheSize) {
|
||||
this._recentMessagesCache.shift();
|
||||
}
|
||||
|
||||
|
||||
// Also add to fallback array
|
||||
this._inMemoryHistory.push(messageObj);
|
||||
if (this._inMemoryHistory.length > this._inMemoryFallbackSize) {
|
||||
@@ -150,11 +154,11 @@ class ChatHistoryManager {
|
||||
if (this.db) {
|
||||
try {
|
||||
const result = await this.db.query(`
|
||||
INSERT INTO chat_messages (socket_id, username, message, message_type, created_at)
|
||||
VALUES ($1, $2, $3, $4, NOW())
|
||||
INSERT INTO chat_messages (socket_id, username, player_id, user_id, message, message_type, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, NOW())
|
||||
RETURNING id
|
||||
`, [socketId, username, message, type]);
|
||||
|
||||
`, [socketId, username, playerId, userId, message, type]);
|
||||
|
||||
messageObj.id = result.rows[0]?.id;
|
||||
} catch (error) {
|
||||
console.error('Failed to persist chat message:', error.message);
|
||||
@@ -167,36 +171,46 @@ class ChatHistoryManager {
|
||||
|
||||
/**
|
||||
* Get recent messages for a new user
|
||||
* @param {number} count - Number of messages to retrieve (default: 50)
|
||||
* Uses hybrid strategy: last hour of messages, OR last 10 messages if < 10 in last hour
|
||||
* @param {number} maxCount - Maximum number of messages to retrieve (default: 50)
|
||||
* @param {number} minCount - Minimum messages to show if recent activity is low (default: 10)
|
||||
* @returns {Array} Array of recent messages
|
||||
*/
|
||||
async getRecentMessages(count = 50) {
|
||||
const limit = Math.min(count, this._cacheSize);
|
||||
|
||||
// If cache is initialized, use it
|
||||
if (this._cacheInitialized && this._recentMessagesCache.length > 0) {
|
||||
return this._recentMessagesCache.slice(-limit);
|
||||
}
|
||||
|
||||
// If cache is empty, try in-memory fallback
|
||||
if (this._inMemoryHistory.length > 0) {
|
||||
return this._inMemoryHistory.slice(-limit);
|
||||
}
|
||||
|
||||
// If nothing in memory, try database
|
||||
async getRecentMessages(maxCount = 50, minCount = 10) {
|
||||
// Try database first for hybrid strategy
|
||||
if (this.db) {
|
||||
try {
|
||||
// Hybrid query: Get messages from last hour, OR last N messages if fewer than minCount
|
||||
const result = await this.db.query(`
|
||||
SELECT id, socket_id, username, message, message_type, created_at
|
||||
FROM chat_messages
|
||||
ORDER BY created_at DESC
|
||||
WITH recent_hour AS (
|
||||
SELECT id, socket_id, username, player_id, message, message_type, created_at
|
||||
FROM chat_messages
|
||||
WHERE deleted_at IS NULL
|
||||
AND created_at > NOW() - INTERVAL '1 hour'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $1
|
||||
),
|
||||
fallback AS (
|
||||
SELECT id, socket_id, username, player_id, message, message_type, created_at
|
||||
FROM chat_messages
|
||||
WHERE deleted_at IS NULL
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2
|
||||
)
|
||||
SELECT * FROM (
|
||||
SELECT * FROM recent_hour
|
||||
UNION
|
||||
SELECT * FROM fallback
|
||||
) combined
|
||||
ORDER BY created_at ASC
|
||||
LIMIT $1
|
||||
`, [limit]);
|
||||
|
||||
return result.rows.reverse().map(row => ({
|
||||
`, [maxCount, minCount]);
|
||||
|
||||
return result.rows.map(row => ({
|
||||
id: row.id,
|
||||
socketId: row.socket_id,
|
||||
username: row.username,
|
||||
playerId: row.player_id || row.username,
|
||||
message: row.message,
|
||||
type: row.message_type,
|
||||
timestamp: new Date(row.created_at).getTime()
|
||||
@@ -205,7 +219,21 @@ class ChatHistoryManager {
|
||||
console.error('Failed to get recent messages from DB:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Fallback to cache (filter out deleted messages)
|
||||
const limit = Math.min(maxCount, this._cacheSize);
|
||||
|
||||
if (this._cacheInitialized && this._recentMessagesCache.length > 0) {
|
||||
const activeMessages = this._recentMessagesCache.filter(m => !m.deletedAt);
|
||||
return activeMessages.slice(-limit);
|
||||
}
|
||||
|
||||
// Final fallback to in-memory
|
||||
if (this._inMemoryHistory.length > 0) {
|
||||
const activeMessages = this._inMemoryHistory.filter(m => !m.deletedAt);
|
||||
return activeMessages.slice(-limit);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -237,6 +265,102 @@ class ChatHistoryManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user is banned from chat
|
||||
* @param {number} userId - User ID to check
|
||||
* @returns {boolean} True if user is banned
|
||||
*/
|
||||
async isUserChatBanned(userId) {
|
||||
if (!userId || !this.db) return false;
|
||||
|
||||
try {
|
||||
const result = await this.db.query(
|
||||
'SELECT chat_banned FROM users WHERE id = $1',
|
||||
[userId]
|
||||
);
|
||||
return result.rows[0]?.chat_banned === true;
|
||||
} catch (error) {
|
||||
console.error('Failed to check chat ban status:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft delete a message (admin moderation)
|
||||
* @param {number} messageId - Message ID to delete
|
||||
* @param {string} deletedBy - Admin identifier
|
||||
* @param {string} reason - Reason for deletion
|
||||
* @returns {boolean} True if deleted successfully
|
||||
*/
|
||||
async deleteMessage(messageId, deletedBy = 'admin', reason = null) {
|
||||
if (!this.db) return false;
|
||||
|
||||
try {
|
||||
const result = await this.db.query(`
|
||||
UPDATE chat_messages
|
||||
SET deleted_at = NOW(), deleted_by = $2, delete_reason = $3
|
||||
WHERE id = $1 AND deleted_at IS NULL
|
||||
RETURNING id
|
||||
`, [messageId, deletedBy, reason]);
|
||||
|
||||
if (result.rowCount > 0) {
|
||||
// Also remove from in-memory caches
|
||||
this._recentMessagesCache = this._recentMessagesCache.filter(m => m.id !== messageId);
|
||||
this._inMemoryHistory = this._inMemoryHistory.filter(m => m.id !== messageId);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('Failed to delete chat message:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get messages for admin view (includes deleted)
|
||||
* @param {number} limit - Number of messages
|
||||
* @param {boolean} includeDeleted - Whether to include deleted messages
|
||||
* @returns {Array} Messages for admin
|
||||
*/
|
||||
async getMessagesForAdmin(limit = 100, includeDeleted = false) {
|
||||
if (!this.db) return [];
|
||||
|
||||
try {
|
||||
const result = await this.db.query(`
|
||||
SELECT
|
||||
c.id, c.socket_id, c.username, c.player_id, c.user_id,
|
||||
c.message, c.message_type, c.created_at,
|
||||
c.deleted_at, c.deleted_by, c.delete_reason,
|
||||
u.payout_address, u.chat_banned
|
||||
FROM chat_messages c
|
||||
LEFT JOIN users u ON c.user_id = u.id
|
||||
${includeDeleted ? '' : 'WHERE c.deleted_at IS NULL'}
|
||||
ORDER BY c.created_at DESC
|
||||
LIMIT $1
|
||||
`, [limit]);
|
||||
|
||||
return result.rows.map(row => ({
|
||||
id: row.id,
|
||||
socketId: row.socket_id,
|
||||
username: row.username,
|
||||
playerId: row.player_id,
|
||||
userId: row.user_id,
|
||||
message: row.message,
|
||||
type: row.message_type,
|
||||
timestamp: new Date(row.created_at).getTime(),
|
||||
createdAt: row.created_at,
|
||||
deletedAt: row.deleted_at,
|
||||
deletedBy: row.deleted_by,
|
||||
deleteReason: row.delete_reason,
|
||||
payoutAddress: row.payout_address,
|
||||
chatBanned: row.chat_banned
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Failed to get admin messages:', error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get message count for statistics
|
||||
* @returns {Object} Statistics about chat messages
|
||||
|
||||
Reference in New Issue
Block a user