This commit is contained in:
2026-02-22 20:27:09 -05:00
parent 7b326a112e
commit 92024c8a64
37 changed files with 6785 additions and 3344 deletions
+298
View File
@@ -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 };