299 lines
10 KiB
JavaScript
299 lines
10 KiB
JavaScript
'use strict';
|
|
|
|
const express = require('express');
|
|
const { CJbot } = require('../model/minecraft');
|
|
|
|
// In-memory ring buffer for chat messages
|
|
const MAX_MESSAGES = 500;
|
|
const messages = [];
|
|
let messageId = 0;
|
|
|
|
function addMessage(type, from, text, botName) {
|
|
messages.push({
|
|
id: ++messageId,
|
|
type, // 'chat', 'whisper', 'system', 'bot'
|
|
from,
|
|
text,
|
|
botName,
|
|
timestamp: Date.now(),
|
|
});
|
|
if (messages.length > MAX_MESSAGES) messages.splice(0, messages.length - MAX_MESSAGES);
|
|
}
|
|
|
|
// Hook into all bots' chat events (called once per bot connection)
|
|
const hookedBots = new Set();
|
|
|
|
function hookBot(bot) {
|
|
const name = bot.name;
|
|
if (hookedBots.has(name)) return;
|
|
hookedBots.add(name);
|
|
|
|
// Re-hook on each spawn (reconnection creates a new mineflayer bot)
|
|
const attach = () => {
|
|
if (!bot.bot) return;
|
|
bot.bot.on('chat', (from, message) => {
|
|
addMessage('chat', from, message, name);
|
|
});
|
|
bot.bot.on('whisper', (from, message) => {
|
|
addMessage('whisper', from, message, name);
|
|
});
|
|
bot.bot.on('message', (jsonMsg, position) => {
|
|
if (position === 'game_info') return; // skip action bar
|
|
const text = jsonMsg.toString();
|
|
// Skip empty or already-captured chat/whisper
|
|
if (!text || text.startsWith('<')) return;
|
|
addMessage('system', null, text, name);
|
|
});
|
|
};
|
|
|
|
// If the bot is already connected, attach now
|
|
if (bot.bot) attach();
|
|
|
|
// Also attach on every future spawn
|
|
const origConnect = bot.connect.bind(bot);
|
|
bot.connect = async function (...args) {
|
|
const result = await origConnect(...args);
|
|
attach();
|
|
return result;
|
|
};
|
|
}
|
|
|
|
// Periodically check for new bots to hook
|
|
setInterval(() => {
|
|
for (const [name, bot] of Object.entries(CJbot.bots)) {
|
|
hookBot(bot);
|
|
}
|
|
}, 2000);
|
|
|
|
// Also hook any bots that exist right now
|
|
for (const [name, bot] of Object.entries(CJbot.bots)) {
|
|
hookBot(bot);
|
|
}
|
|
|
|
function createRouter() {
|
|
const router = express.Router();
|
|
|
|
// Get messages, optionally filtering by ?since=<id> for polling
|
|
router.get('/api/chat/messages', (req, res) => {
|
|
try {
|
|
const since = parseInt(req.query.since) || 0;
|
|
const filtered = since ? messages.filter(m => m.id > since) : messages.slice(-100);
|
|
res.json({ messages: filtered, lastId: messageId });
|
|
} catch (error) {
|
|
console.error('API Error /api/chat/messages:', error);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Send a chat message as a bot
|
|
router.post('/api/chat/send', async (req, res) => {
|
|
try {
|
|
const { botName, message, whisperTo } = req.body;
|
|
if (!message) return res.status(400).json({ error: 'Missing message' });
|
|
|
|
// Find a bot to send from
|
|
let bot = null;
|
|
if (botName && CJbot.bots[botName]) {
|
|
bot = CJbot.bots[botName];
|
|
} else {
|
|
// Use first connected bot
|
|
for (const b of Object.values(CJbot.bots)) {
|
|
if (b.isReady) { bot = b; break; }
|
|
}
|
|
}
|
|
|
|
if (!bot || !bot.isReady) {
|
|
return res.status(503).json({ error: 'No connected bot available' });
|
|
}
|
|
|
|
if (whisperTo) {
|
|
await bot.whisper(whisperTo, message);
|
|
addMessage('bot', bot.bot.entity.username, `/msg ${whisperTo} ${message}`, bot.name);
|
|
} else {
|
|
await bot.say(message);
|
|
addMessage('bot', bot.bot.entity.username, message, bot.name);
|
|
}
|
|
|
|
res.json({ status: 'sent' });
|
|
} catch (error) {
|
|
console.error('API Error /api/chat/send:', error);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
return router;
|
|
}
|
|
|
|
const webUI = {
|
|
tabId: 'chat',
|
|
tabLabel: 'Chat',
|
|
tabOrder: 5,
|
|
html: `
|
|
<div id="chatArea">
|
|
<div class="chat-messages" id="chatMessages">
|
|
<div style="padding:20px;color:#6b7280;text-align:center">Loading chat...</div>
|
|
</div>
|
|
<div class="chat-input-area">
|
|
<select id="chatBot" style="padding:8px;border:1px solid #374151;border-radius:6px;background:#111827;color:#e5e7eb;font-size:.85em">
|
|
<option value="">Any bot</option>
|
|
</select>
|
|
<input type="text" id="chatWhisper" placeholder="Whisper to (optional)" style="width:120px;padding:8px;border:1px solid #374151;border-radius:6px;background:#111827;color:#e5e7eb;font-size:.85em">
|
|
<input type="text" id="chatInput" placeholder="Type a message..." autocomplete="off" style="flex:1;padding:8px 12px;border:1px solid #374151;border-radius:6px;background:#111827;color:#e5e7eb;font-size:.9em">
|
|
<button id="chatSendBtn" style="background:#2563eb;color:#fff;border:none;padding:8px 16px;border-radius:6px;cursor:pointer;font-size:.85em">Send</button>
|
|
</div>
|
|
</div>
|
|
`,
|
|
css: `
|
|
#chatArea{display:flex;flex-direction:column;height:calc(100vh - 140px)}
|
|
.chat-messages{flex:1;overflow-y:auto;padding:12px;background:#0f172a;border:1px solid #374151;border-radius:8px;margin-bottom:12px;font-family:'Consolas','Monaco',monospace;font-size:.85em;line-height:1.6}
|
|
.chat-msg{padding:2px 0;word-wrap:break-word}
|
|
.chat-msg .chat-time{color:#4b5563;font-size:.8em;margin-right:6px}
|
|
.chat-msg .chat-from{font-weight:600}
|
|
.chat-msg.type-chat .chat-from{color:#60a5fa}
|
|
.chat-msg.type-whisper .chat-from{color:#a78bfa}
|
|
.chat-msg.type-whisper{background:rgba(167,139,250,.08);padding:2px 4px;border-radius:3px}
|
|
.chat-msg.type-system{color:#6b7280;font-style:italic}
|
|
.chat-msg.type-bot .chat-from{color:#f59e0b}
|
|
.chat-input-area{display:flex;gap:8px;align-items:center}
|
|
.chat-filter-bar{display:flex;gap:8px;align-items:center;margin-bottom:8px}
|
|
.chat-filter-bar input{padding:8px 12px;border:1px solid #374151;border-radius:6px;background:#111827;color:#e5e7eb;font-size:.85em;flex:1}
|
|
.chat-filter-bar label{font-size:.8em;color:#9ca3af;display:flex;align-items:center;gap:4px;cursor:pointer}
|
|
.chat-filter-bar label input[type=checkbox]{accent-color:#2563eb}
|
|
`,
|
|
onTabActive: 'onChatTabActive',
|
|
js: `
|
|
let chatLastId=0, chatInterval=null, chatAutoScroll=true, chatFilter='';
|
|
let chatShowTypes={chat:true,whisper:true,system:true,bot:true};
|
|
|
|
function onChatTabActive() {
|
|
loadChatMessages();
|
|
populateBotSelect();
|
|
if (!chatInterval) chatInterval = setInterval(() => { if (currentTab === 'chat') pollChat(); }, 1500);
|
|
}
|
|
|
|
async function loadChatMessages() {
|
|
try {
|
|
const r = await fetch('/api/chat/messages');
|
|
if (!r.ok) return;
|
|
const d = await r.json();
|
|
chatLastId = d.lastId || 0;
|
|
renderChatMessages(d.messages || []);
|
|
} catch(e) {}
|
|
}
|
|
|
|
async function pollChat() {
|
|
try {
|
|
const r = await fetch('/api/chat/messages?since=' + chatLastId);
|
|
if (!r.ok) return;
|
|
const d = await r.json();
|
|
if (d.messages && d.messages.length > 0) {
|
|
chatLastId = d.lastId || chatLastId;
|
|
appendChatMessages(d.messages);
|
|
}
|
|
} catch(e) {}
|
|
}
|
|
|
|
function formatChatTime(ts) {
|
|
const d = new Date(ts);
|
|
return d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'});
|
|
}
|
|
|
|
function renderChatLine(msg) {
|
|
const time = '<span class="chat-time">' + formatChatTime(msg.timestamp) + '</span>';
|
|
if (msg.type === 'system') {
|
|
return '<div class="chat-msg type-system">' + time + escHtml(msg.text) + '</div>';
|
|
}
|
|
const label = msg.type === 'whisper' ? ' whispers: ' : ': ';
|
|
return '<div class="chat-msg type-' + msg.type + '">' +
|
|
time +
|
|
'<span class="chat-from">' + escHtml(msg.from || '???') + '</span>' +
|
|
label + escHtml(msg.text) +
|
|
'</div>';
|
|
}
|
|
|
|
function matchesFilter(msg) {
|
|
if (!chatShowTypes[msg.type]) return false;
|
|
if (!chatFilter) return true;
|
|
const q = chatFilter.toLowerCase();
|
|
return (msg.from && msg.from.toLowerCase().includes(q)) ||
|
|
(msg.text && msg.text.toLowerCase().includes(q));
|
|
}
|
|
|
|
function renderChatMessages(msgs) {
|
|
const container = document.getElementById('chatMessages');
|
|
const filtered = msgs.filter(matchesFilter);
|
|
if (filtered.length === 0) {
|
|
container.innerHTML = '<div style="padding:20px;color:#6b7280;text-align:center">No messages yet</div>';
|
|
return;
|
|
}
|
|
container.innerHTML = filtered.map(renderChatLine).join('');
|
|
if (chatAutoScroll) container.scrollTop = container.scrollHeight;
|
|
}
|
|
|
|
function appendChatMessages(msgs) {
|
|
const container = document.getElementById('chatMessages');
|
|
// Remove placeholder if present
|
|
const placeholder = container.querySelector('div[style]');
|
|
if (placeholder && container.children.length === 1 && placeholder.textContent.includes('No messages')) {
|
|
container.innerHTML = '';
|
|
}
|
|
const filtered = msgs.filter(matchesFilter);
|
|
for (const msg of filtered) {
|
|
container.insertAdjacentHTML('beforeend', renderChatLine(msg));
|
|
}
|
|
// Trim old messages from DOM
|
|
while (container.children.length > 500) container.removeChild(container.firstChild);
|
|
if (chatAutoScroll) container.scrollTop = container.scrollHeight;
|
|
}
|
|
|
|
async function populateBotSelect() {
|
|
try {
|
|
const r = await fetch('/api/bots');
|
|
if (!r.ok) return;
|
|
const d = await r.json();
|
|
const sel = document.getElementById('chatBot');
|
|
const current = sel.value;
|
|
sel.innerHTML = '<option value="">Any bot</option>' +
|
|
Object.entries(d.bots || {}).filter(([,b]) => b.connected).map(([name]) =>
|
|
'<option value="' + escHtml(name) + '">' + escHtml(name) + '</option>'
|
|
).join('');
|
|
sel.value = current;
|
|
} catch(e) {}
|
|
}
|
|
|
|
async function sendChatMessage() {
|
|
const input = document.getElementById('chatInput');
|
|
const message = input.value.trim();
|
|
if (!message) return;
|
|
const botName = document.getElementById('chatBot').value || undefined;
|
|
const whisperTo = document.getElementById('chatWhisper').value.trim() || undefined;
|
|
try {
|
|
const r = await fetch('/api/chat/send', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({ botName, message, whisperTo })
|
|
});
|
|
if (r.ok) {
|
|
input.value = '';
|
|
setTimeout(pollChat, 300);
|
|
} else {
|
|
const d = await r.json();
|
|
showToast(d.error || 'Failed to send', 'error');
|
|
}
|
|
} catch(e) { showToast('Network error', 'error'); }
|
|
}
|
|
|
|
document.getElementById('chatInput').addEventListener('keydown', e => {
|
|
if (e.key === 'Enter') { e.preventDefault(); sendChatMessage(); }
|
|
});
|
|
document.getElementById('chatSendBtn').addEventListener('click', sendChatMessage);
|
|
|
|
// Auto-scroll toggle: disable if user scrolls up, re-enable at bottom
|
|
document.getElementById('chatMessages').addEventListener('scroll', function() {
|
|
chatAutoScroll = this.scrollTop + this.clientHeight >= this.scrollHeight - 30;
|
|
});
|
|
`,
|
|
};
|
|
|
|
module.exports = { name: 'Chat', createRouter, webUI };
|