const axios = require('axios'); const WebSocket = require('ws'); const fs = require('fs'); const path = require('path'); const { loadConfig, saveConfig } = require('../core/config'); const MATCHA_BASE = 'https://butter.lat'; const MATCHA_API = `${MATCHA_BASE}/api/matcha`; const MATCHA_WS = 'wss://butter.lat/api/matcha/ws'; class MatchaService { constructor() { this.token = null; this.user = null; this.ws = null; this.wsConnected = false; this.mainWindow = null; this.heartbeatInterval = null; this.reconnectTimeout = null; this.reconnectAttempts = 0; this.maxReconnectAttempts = 5; this.lastMessageSentAt = 0; this.gameRunning = false; } // ========================================================================= // LIFECYCLE // ========================================================================= init(mainWindow) { this.mainWindow = mainWindow; this.token = this._loadToken(); if (this.token) { this._connectWs(); this._startHeartbeat(); // Load cached user info const config = loadConfig(); if (config.matchaUserId && config.matchaHandle) { this.user = { id: config.matchaUserId, handle: config.matchaHandle }; } } } destroy() { // Best-effort offline heartbeat if (this.token) { this.sendHeartbeat('offline').catch(() => {}); } this._stopHeartbeat(); this._disconnectWs(); this.mainWindow = null; } // ========================================================================= // TOKEN MANAGEMENT // ========================================================================= _loadToken() { const config = loadConfig(); return config.matchaToken || null; } _saveToken(token) { this.token = token; saveConfig({ matchaToken: token }); } _saveUser(user) { this.user = user; if (user) { saveConfig({ matchaUserId: user.id, matchaHandle: user.handle }); } } _clearAuth() { this.token = null; this.user = null; this._stopHeartbeat(); this._disconnectWs(); // Set to null — saveConfig merges, and JSON.stringify preserves null values, // but this effectively marks them as cleared for _loadToken() checks saveConfig({ matchaToken: null, matchaUserId: null, matchaHandle: null }); } _authHeaders() { return this.token ? { Authorization: `Bearer ${this.token}` } : {}; } // ========================================================================= // AUTH // ========================================================================= async register(username, password, password2) { try { const res = await axios.post(`${MATCHA_API}/register`, { username, password, password2, deferCreate: true }); return { ok: true, data: res.data }; } catch (err) { return this._handleError(err); } } async confirmRegistration(pendingId, proofId) { try { const res = await axios.post(`${MATCHA_API}/register/confirm`, { pendingId, proofId }); if (res.data.token) { this._saveToken(res.data.token); if (res.data.user) this._saveUser(res.data.user); this._connectWs(); this._startHeartbeat(); } return { ok: true, data: res.data }; } catch (err) { return this._handleError(err); } } async login(handle, password) { try { const res = await axios.post(`${MATCHA_API}/login`, { handle, password }); if (res.data.token) { this._saveToken(res.data.token); if (res.data.user) this._saveUser(res.data.user); this._connectWs(); this._startHeartbeat(); } return { ok: true, data: res.data }; } catch (err) { return this._handleError(err); } } async logout() { await this.sendHeartbeat('offline').catch(() => {}); this._clearAuth(); return { ok: true }; } getAuthState() { return { authenticated: !!this.token, user: this.user, wsConnected: this.wsConnected }; } // ========================================================================= // PROFILE // ========================================================================= async getMe() { try { const res = await axios.get(`${MATCHA_API}/me`, { headers: this._authHeaders() }); if (res.data?.user) this._saveUser({ id: res.data.user.id, handle: res.data.user.handle }); return { ok: true, data: res.data }; } catch (err) { return this._handleError(err); } } async getUser(userId) { try { const res = await axios.get(`${MATCHA_API}/users/${encodeURIComponent(userId)}`, { headers: this._authHeaders() }); return { ok: true, data: res.data }; } catch (err) { return this._handleError(err); } } // ========================================================================= // FRIENDS // ========================================================================= async getFriends() { try { const res = await axios.get(`${MATCHA_API}/friends`, { headers: this._authHeaders() }); return { ok: true, data: res.data }; } catch (err) { return this._handleError(err); } } async sendFriendRequest(handle) { try { const res = await axios.post(`${MATCHA_API}/friends/request`, { toHandle: handle }, { headers: this._authHeaders() }); return { ok: true, data: res.data }; } catch (err) { return this._handleError(err); } } async acceptFriend(requestId) { try { const res = await axios.post(`${MATCHA_API}/friends/request/accept`, { id: requestId }, { headers: this._authHeaders() }); return { ok: true, data: res.data }; } catch (err) { return this._handleError(err); } } async rejectFriend(requestId) { try { const res = await axios.post(`${MATCHA_API}/friends/request/reject`, { id: requestId }, { headers: this._authHeaders() }); return { ok: true, data: res.data }; } catch (err) { return this._handleError(err); } } async cancelFriendRequest(requestId) { try { const res = await axios.post(`${MATCHA_API}/friends/request/cancel`, { id: requestId }, { headers: this._authHeaders() }); return { ok: true, data: res.data }; } catch (err) { return this._handleError(err); } } async removeFriend(friendId) { try { const res = await axios.post(`${MATCHA_API}/friends/remove`, { friendId }, { headers: this._authHeaders() }); return { ok: true, data: res.data }; } catch (err) { return this._handleError(err); } } // ========================================================================= // MESSAGES // ========================================================================= async getMessages(withTarget, cursor, after) { try { const params = { with: withTarget }; if (cursor) params.cursor = cursor; if (after) params.after = after; const res = await axios.get(`${MATCHA_API}/messages`, { headers: this._authHeaders(), params }); return { ok: true, data: res.data }; } catch (err) { return this._handleError(err); } } async sendMessage(to, body, replyTo) { // Enforce 800ms throttle const now = Date.now(); const elapsed = now - this.lastMessageSentAt; if (elapsed < 800) { return { ok: false, error: 'Please wait before sending another message' }; } this.lastMessageSentAt = now; // Try WebSocket first if (this.wsConnected && this.ws && this.ws.readyState === WebSocket.OPEN) { const msg = { type: 'send', to, body }; if (replyTo) msg.replyTo = replyTo; this.ws.send(JSON.stringify(msg)); return { ok: true, data: { sent: true, via: 'ws' } }; } // Fallback to HTTP try { const payload = { to, body }; if (replyTo) payload.replyTo = replyTo; const res = await axios.post(`${MATCHA_API}/messages/send`, payload, { headers: this._authHeaders() }); return { ok: true, data: res.data }; } catch (err) { return this._handleError(err); } } async deleteMessage(messageId) { try { const res = await axios.post(`${MATCHA_API}/messages/${messageId}/delete`, {}, { headers: this._authHeaders() }); return { ok: true, data: res.data }; } catch (err) { return this._handleError(err); } } // ========================================================================= // UNREAD // ========================================================================= async getUnread() { try { const res = await axios.get(`${MATCHA_API}/unread`, { headers: this._authHeaders() }); return { ok: true, data: res.data }; } catch (err) { return this._handleError(err); } } async clearUnread(withTarget) { try { const res = await axios.post(`${MATCHA_API}/unread/clear`, { with: withTarget }, { headers: this._authHeaders() }); return { ok: true, data: res.data }; } catch (err) { return this._handleError(err); } } // ========================================================================= // HEARTBEAT // ========================================================================= async sendHeartbeat(state) { if (!this.token) return; try { await axios.post(`${MATCHA_API}/heartbeat`, { state: state || 'online' }, { headers: this._authHeaders() }); } catch (err) { console.log('[Matcha] Heartbeat failed:', err.message); } } _startHeartbeat() { this._stopHeartbeat(); this.heartbeatInterval = setInterval(() => { const state = this.gameRunning ? 'in_game' : 'online'; this.sendHeartbeat(state); }, 30000); // Send initial heartbeat this.sendHeartbeat('online'); } _stopHeartbeat() { if (this.heartbeatInterval) { clearInterval(this.heartbeatInterval); this.heartbeatInterval = null; } } setGameRunning(running) { this.gameRunning = running; } // ========================================================================= // AVATAR // ========================================================================= async uploadAvatar(filePath, mode) { try { const crypto = require('crypto'); const fileBuffer = fs.readFileSync(filePath); if (fileBuffer.length > 1024 * 1024) { return { ok: false, error: 'Avatar too large (max 1MB)' }; } const hash = crypto.createHash('sha256').update(fileBuffer).digest('hex'); const endpoint = mode === 'custom' ? `${MATCHA_API}/avatar/custom` : `${MATCHA_API}/avatar`; const res = await axios.post(endpoint, fileBuffer, { headers: { ...this._authHeaders(), 'Content-Type': 'image/png', 'x-avatar-hash': hash, 'x-avatar-enable': '1', 'x-avatar-force': '1', 'Cache-Control': 'no-store' }, maxContentLength: 1024 * 1024 }); return { ok: true, data: res.data }; } catch (err) { return this._handleError(err); } } async deleteAvatar() { try { const res = await axios.delete(`${MATCHA_API}/avatar`, { headers: this._authHeaders() }); return { ok: true, data: res.data }; } catch (err) { return this._handleError(err); } } // ========================================================================= // WEBSOCKET // ========================================================================= _connectWs() { if (this.ws) this._disconnectWs(); try { this.ws = new WebSocket(MATCHA_WS); this.ws.on('open', () => { console.log('[Matcha] WebSocket connected'); this.reconnectAttempts = 0; // Authenticate if (this.token) { this.ws.send(JSON.stringify({ type: 'auth', token: this.token })); } }); this.ws.on('message', (raw) => { try { const data = JSON.parse(raw.toString()); this._handleWsMessage(data); } catch (err) { console.error('[Matcha] WS parse error:', err.message); } }); this.ws.on('close', (code) => { console.log('[Matcha] WebSocket closed:', code); this.wsConnected = false; this._sendToRenderer('matcha:ws:disconnected'); // Handle ban if (code === 4003) { this._sendToRenderer('matcha:ws:banned', { reason: 'Account banned' }); return; } // Auto-reconnect if we have a token if (this.token && code !== 4003) { this._scheduleReconnect(); } }); this.ws.on('error', (err) => { console.error('[Matcha] WS error:', err.message); }); } catch (err) { console.error('[Matcha] WS connect failed:', err.message); this._scheduleReconnect(); } } _disconnectWs() { if (this.reconnectTimeout) { clearTimeout(this.reconnectTimeout); this.reconnectTimeout = null; } if (this.ws) { try { this.ws.close(); } catch (e) {} this.ws = null; } this.wsConnected = false; } _scheduleReconnect() { if (this.reconnectTimeout) return; // Exponential backoff capped at 30s, no hard limit (matches Butter's infinite reconnect) const delay = Math.min(2000 * Math.pow(2, this.reconnectAttempts), 30000); this.reconnectAttempts++; // Notify renderer after several failures so it can show a banner if (this.reconnectAttempts >= this.maxReconnectAttempts) { this._sendToRenderer('matcha:ws:max-retries'); } console.log(`[Matcha] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`); this.reconnectTimeout = setTimeout(() => { this.reconnectTimeout = null; if (this.token) this._connectWs(); }, delay); } _handleWsMessage(data) { switch (data.type) { case 'authed': this.wsConnected = true; if (data.user) this._saveUser(data.user); this._sendToRenderer('matcha:ws:connected', { user: this.user }); break; case 'message': console.log('[Matcha] WS message received:', JSON.stringify(data).substring(0, 200)); this._sendToRenderer('matcha:ws:message', data); break; case 'message_deleted': this._sendToRenderer('matcha:ws:message-deleted', data); break; case 'avatar_updated': this._sendToRenderer('matcha:ws:avatar-updated', data); break; case 'banned': this._sendToRenderer('matcha:ws:banned', data); break; case 'announcement': this._sendToRenderer('matcha:ws:announcement', data); break; case 'error': console.log('[Matcha] WS error:', data.message || data.error || JSON.stringify(data)); // If auth error, treat as ban/disconnect if (data.message === 'Not authed' || data.error === 'Not authed') { this._clearAuth(); this._sendToRenderer('matcha:ws:disconnected'); } break; default: console.log('[Matcha] Unknown WS message type:', data.type); } } manualReconnect() { this.reconnectAttempts = 0; if (this.token) this._connectWs(); } // ========================================================================= // HELPERS // ========================================================================= _sendToRenderer(channel, data) { if (this.mainWindow && !this.mainWindow.isDestroyed()) { this.mainWindow.webContents.send(channel, data); } } _handleError(err) { if (err.response) { const status = err.response.status; const msg = err.response.data?.error || err.response.data?.message || err.message; if (status === 401) { // Token expired or invalid this._clearAuth(); this._sendToRenderer('matcha:ws:disconnected'); } return { ok: false, error: msg, status }; } return { ok: false, error: err.message }; } } module.exports = new MatchaService();