forked from wmantly/mc-bot-town
here
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
'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 };
|
||||
Reference in New Issue
Block a user