369 lines
9.6 KiB
JavaScript
369 lines
9.6 KiB
JavaScript
import WebSocket from 'ws';
|
|
|
|
/**
|
|
* ChatBot - A simple bot SDK for Realms chat
|
|
*
|
|
* Mirrors the existing XMPP bot API for easy migration.
|
|
*
|
|
* @example
|
|
* const bot = new ChatBot('MyBot');
|
|
*
|
|
* bot.messageHandler = (message) => {
|
|
* console.log(`${message.username}: ${message.content}`);
|
|
* if (message.content === '!hello') {
|
|
* bot.print('Hello there!');
|
|
* }
|
|
* };
|
|
*
|
|
* bot.connect('your-api-key', 'wss://example.com/chat/ws')
|
|
* .then(() => bot.joinRoom('realm-id'))
|
|
* .catch(console.error);
|
|
*/
|
|
class ChatBot {
|
|
/**
|
|
* Create a new ChatBot instance
|
|
* @param {string} name - Display name for the bot (informational only, actual name comes from API key owner)
|
|
*/
|
|
constructor(name) {
|
|
this.name = name;
|
|
this.ws = null;
|
|
this.apiKey = null;
|
|
this.serverUrl = null;
|
|
this.realmId = null;
|
|
this.connected = false;
|
|
this.reconnectAttempts = 0;
|
|
this.maxReconnectAttempts = 5;
|
|
this.reconnectDelay = 1000;
|
|
this.shouldReconnect = true;
|
|
|
|
// Event handlers
|
|
this.messageHandler = null;
|
|
this.onConnect = null;
|
|
this.onDisconnect = null;
|
|
this.onError = null;
|
|
this.onJoin = null;
|
|
this.onParticipantJoin = null;
|
|
this.onParticipantLeave = null;
|
|
}
|
|
|
|
/**
|
|
* Connect to the chat server
|
|
* @param {string} apiKey - Your bot API key from the Settings page
|
|
* @param {string} serverUrl - WebSocket server URL (e.g., 'wss://example.com/chat/ws')
|
|
* @returns {Promise<void>}
|
|
*/
|
|
connect(apiKey, serverUrl) {
|
|
return new Promise((resolve, reject) => {
|
|
this.apiKey = apiKey;
|
|
this.serverUrl = serverUrl;
|
|
this.shouldReconnect = true;
|
|
|
|
// SECURITY FIX: Don't put API key in URL (it gets logged by servers/proxies)
|
|
// Instead, send auth message after connection opens
|
|
this.ws = new WebSocket(serverUrl);
|
|
|
|
// Store resolve/reject for when we get the welcome message
|
|
this._connectResolve = resolve;
|
|
this._connectReject = reject;
|
|
|
|
this.ws.on('open', () => {
|
|
console.log(`[${this.name}] Connected to server, authenticating...`);
|
|
// SECURITY FIX: Send API key via message instead of URL
|
|
this._send({ type: 'auth', apiKey: apiKey });
|
|
// Don't resolve yet - wait for welcome message after API key validation
|
|
});
|
|
|
|
this.ws.on('message', (data) => {
|
|
try {
|
|
const message = JSON.parse(data.toString());
|
|
this._handleMessage(message);
|
|
} catch (e) {
|
|
console.error(`[${this.name}] Failed to parse message:`, e);
|
|
}
|
|
});
|
|
|
|
this.ws.on('close', () => {
|
|
console.log(`[${this.name}] Connection closed`);
|
|
this.connected = false;
|
|
|
|
if (this.onDisconnect) {
|
|
this.onDisconnect();
|
|
}
|
|
|
|
if (this.shouldReconnect && this.reconnectAttempts < this.maxReconnectAttempts) {
|
|
this._attemptReconnect();
|
|
}
|
|
});
|
|
|
|
this.ws.on('error', (error) => {
|
|
console.error(`[${this.name}] WebSocket error:`, error.message);
|
|
|
|
if (this.onError) {
|
|
this.onError(error);
|
|
}
|
|
|
|
if (this._connectReject) {
|
|
this._connectReject(error);
|
|
this._connectResolve = null;
|
|
this._connectReject = null;
|
|
}
|
|
});
|
|
|
|
// Timeout for connection/authentication
|
|
setTimeout(() => {
|
|
if (this._connectResolve) {
|
|
this._connectReject(new Error('Connection timeout - no welcome message received'));
|
|
this._connectResolve = null;
|
|
this._connectReject = null;
|
|
}
|
|
}, 15000);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Disconnect from the chat server
|
|
*/
|
|
disconnect() {
|
|
this.shouldReconnect = false;
|
|
if (this.ws) {
|
|
this.ws.close();
|
|
this.ws = null;
|
|
}
|
|
this.connected = false;
|
|
console.log(`[${this.name}] Disconnected`);
|
|
}
|
|
|
|
/**
|
|
* Join a chat room (realm)
|
|
* @param {string} realmId - The realm ID to join
|
|
* @returns {Promise<void>}
|
|
*/
|
|
joinRoom(realmId) {
|
|
return new Promise((resolve, reject) => {
|
|
if (!this.connected) {
|
|
reject(new Error('Not connected to server'));
|
|
return;
|
|
}
|
|
|
|
this.realmId = realmId;
|
|
|
|
// Store resolve/reject for when we get the join response
|
|
this._joinResolve = resolve;
|
|
this._joinReject = reject;
|
|
|
|
this._send({
|
|
type: 'join',
|
|
realmId: realmId
|
|
});
|
|
|
|
// Timeout after 10 seconds
|
|
setTimeout(() => {
|
|
if (this._joinResolve) {
|
|
this._joinReject(new Error('Join timeout'));
|
|
this._joinResolve = null;
|
|
this._joinReject = null;
|
|
}
|
|
}, 10000);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Send a message to the current room
|
|
* @param {string} message - The message to send
|
|
*/
|
|
print(message) {
|
|
if (!this.connected || !this.realmId) {
|
|
console.error(`[${this.name}] Cannot send message: not connected or not in a room`);
|
|
return;
|
|
}
|
|
|
|
this._send({
|
|
type: 'message',
|
|
content: message
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get the list of participants in the current room
|
|
* @returns {Promise<Array>}
|
|
*/
|
|
getParticipants() {
|
|
return new Promise((resolve, reject) => {
|
|
if (!this.connected || !this.realmId) {
|
|
reject(new Error('Not connected or not in a room'));
|
|
return;
|
|
}
|
|
|
|
this._participantsResolve = resolve;
|
|
this._participantsReject = reject;
|
|
|
|
this._send({
|
|
type: 'get_participants'
|
|
});
|
|
|
|
setTimeout(() => {
|
|
if (this._participantsResolve) {
|
|
this._participantsReject(new Error('Get participants timeout'));
|
|
this._participantsResolve = null;
|
|
this._participantsReject = null;
|
|
}
|
|
}, 10000);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Internal: Send a message over WebSocket
|
|
* @private
|
|
*/
|
|
_send(data) {
|
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
this.ws.send(JSON.stringify(data));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Internal: Handle incoming messages
|
|
* @private
|
|
*/
|
|
_handleMessage(msg) {
|
|
switch (msg.type) {
|
|
case 'welcome':
|
|
// Authentication successful - connection is ready
|
|
console.log(`[${this.name}] Authenticated as: ${msg.username}`);
|
|
this.connected = true;
|
|
this.reconnectAttempts = 0;
|
|
this.username = msg.username;
|
|
this.userId = msg.userId;
|
|
|
|
if (this._connectResolve) {
|
|
this._connectResolve();
|
|
this._connectResolve = null;
|
|
this._connectReject = null;
|
|
}
|
|
|
|
if (this.onConnect) {
|
|
this.onConnect();
|
|
}
|
|
break;
|
|
|
|
case 'message':
|
|
case 'new_message':
|
|
// Chat message from a user
|
|
if (this.messageHandler && msg.content) {
|
|
this.messageHandler({
|
|
userId: msg.userId,
|
|
username: msg.username,
|
|
content: msg.content,
|
|
timestamp: msg.timestamp,
|
|
userColor: msg.userColor,
|
|
avatarUrl: msg.avatarUrl,
|
|
isGuest: msg.isGuest,
|
|
isModerator: msg.isModerator,
|
|
isStreamer: msg.isStreamer
|
|
});
|
|
}
|
|
break;
|
|
|
|
case 'message_deleted':
|
|
// Message was deleted by a moderator - bots can ignore this
|
|
break;
|
|
|
|
case 'history':
|
|
// Chat history - bots can ignore this
|
|
break;
|
|
|
|
case 'join_success':
|
|
console.log(`[${this.name}] Joined room: ${this.realmId}`);
|
|
if (this._joinResolve) {
|
|
this._joinResolve();
|
|
this._joinResolve = null;
|
|
this._joinReject = null;
|
|
}
|
|
if (this.onJoin) {
|
|
this.onJoin(this.realmId);
|
|
}
|
|
break;
|
|
|
|
case 'error':
|
|
console.error(`[${this.name}] Server error:`, msg.error);
|
|
|
|
// If we're still connecting and get an error, reject the connect promise
|
|
if (this._connectReject) {
|
|
this._connectReject(new Error(msg.error));
|
|
this._connectResolve = null;
|
|
this._connectReject = null;
|
|
}
|
|
|
|
if (this._joinReject && msg.error.includes('join')) {
|
|
this._joinReject(new Error(msg.error));
|
|
this._joinResolve = null;
|
|
this._joinReject = null;
|
|
}
|
|
if (this.onError) {
|
|
this.onError(new Error(msg.error));
|
|
}
|
|
break;
|
|
|
|
case 'participant_joined':
|
|
if (this.onParticipantJoin) {
|
|
this.onParticipantJoin({
|
|
userId: msg.userId,
|
|
username: msg.username,
|
|
isGuest: msg.isGuest
|
|
});
|
|
}
|
|
break;
|
|
|
|
case 'participant_left':
|
|
if (this.onParticipantLeave) {
|
|
this.onParticipantLeave({
|
|
userId: msg.userId,
|
|
username: msg.username
|
|
});
|
|
}
|
|
break;
|
|
|
|
case 'participants':
|
|
if (this._participantsResolve) {
|
|
this._participantsResolve(msg.participants || []);
|
|
this._participantsResolve = null;
|
|
this._participantsReject = null;
|
|
}
|
|
break;
|
|
|
|
case 'system':
|
|
// System messages (user joined, left, etc.)
|
|
console.log(`[${this.name}] System: ${msg.content}`);
|
|
break;
|
|
|
|
default:
|
|
// Unknown message type, ignore
|
|
break;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Internal: Attempt to reconnect
|
|
* @private
|
|
*/
|
|
_attemptReconnect() {
|
|
this.reconnectAttempts++;
|
|
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
|
|
|
|
console.log(`[${this.name}] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
|
|
|
|
setTimeout(async () => {
|
|
try {
|
|
await this.connect(this.apiKey, this.serverUrl);
|
|
if (this.realmId) {
|
|
await this.joinRoom(this.realmId);
|
|
}
|
|
} catch (e) {
|
|
console.error(`[${this.name}] Reconnect failed:`, e.message);
|
|
}
|
|
}, delay);
|
|
}
|
|
}
|
|
|
|
export default ChatBot;
|
|
export { ChatBot };
|