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} */ 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} */ 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} */ 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 };