diff --git a/website/matrix-bridge.html b/website/matrix-bridge.html new file mode 100644 index 0000000..904ec3f --- /dev/null +++ b/website/matrix-bridge.html @@ -0,0 +1,13 @@ + + + + + + Matrix Chat Bridge + + + + + + + diff --git a/website/matrix-bridge.js b/website/matrix-bridge.js new file mode 100644 index 0000000..b5175b8 --- /dev/null +++ b/website/matrix-bridge.js @@ -0,0 +1,234 @@ +/** + * Matrix Chat Bridge - Iframe communication layer for CSP-restricted hosts + * Handles Matrix API calls via postMessage from parent window + */ + +// Allow requests from these origins +const ALLOWED_ORIGINS = [ + 'https://litruv.neocities.org', + 'https://lit.ruv.wtf', + 'http://localhost:3000', + 'http://localhost:8080', + 'http://127.0.0.1:3000', + 'http://127.0.0.1:8080' +]; + +/** + * Matrix API configuration + */ +const MATRIX_CONFIG = { + homeserver: 'https://chat.ruv.wtf', + publicReadToken: 'syt_bGl0cnV2X3B1YmxpY19yZWFk_eFrYVXSXPbLiqREQnFsU_2Nc6ZE' +}; + +/** + * Make a Matrix API call + * @param {string} endpoint - API endpoint path + * @param {object} options - Fetch options + * @param {string|null} accessToken - Optional access token + * @returns {Promise} + */ +async function matrixApiFetch(endpoint, options = {}, accessToken = null) { + const url = `${MATRIX_CONFIG.homeserver}${endpoint}`; + const headers = { + 'Content-Type': 'application/json', + ...options.headers + }; + + if (accessToken) { + headers['Authorization'] = `Bearer ${accessToken}`; + } + + try { + const response = await fetch(url, { + ...options, + headers + }); + + const data = await response.json(); + + if (!response.ok) { + return { error: true, ...data }; + } + + return data; + } catch (error) { + return { + error: true, + errcode: 'M_NETWORK_ERROR', + error: error.message + }; + } +} + +/** + * Handle Matrix API requests from parent window + */ +window.addEventListener('message', async (event) => { + // Verify origin + if (!ALLOWED_ORIGINS.includes(event.origin)) { + console.warn('[Matrix Bridge] Rejected message from unauthorized origin:', event.origin); + return; + } + + const { type, requestId, payload } = event.data; + + if (!type || !requestId) { + return; + } + + let result; + + try { + switch (type) { + case 'matrix:api': + result = await handleApiRequest(payload); + break; + + case 'matrix:auth': + result = await handleAuth(payload); + break; + + case 'matrix:sendMessage': + result = await handleSendMessage(payload); + break; + + case 'matrix:fetchPresence': + result = await handleFetchPresence(payload); + break; + + case 'matrix:fetchLastMessage': + result = await handleFetchLastMessage(payload); + break; + + case 'matrix:sync': + result = await handleSync(payload); + break; + + case 'matrix:resolveAlias': + result = await handleResolveAlias(payload); + break; + + default: + result = { + error: true, + errcode: 'M_UNKNOWN_REQUEST', + error: `Unknown request type: ${type}` + }; + } + } catch (error) { + result = { + error: true, + errcode: 'M_BRIDGE_ERROR', + error: error.message + }; + } + + // Send response back to parent + event.source.postMessage({ + type: `${type}:response`, + requestId, + payload: result + }, event.origin); +}); + +/** + * Handle generic Matrix API request + */ +async function handleApiRequest({ endpoint, method = 'GET', body = null, accessToken = null }) { + return await matrixApiFetch( + `/_matrix/client/r0${endpoint}`, + { + method, + body: body ? JSON.stringify(body) : undefined + }, + accessToken + ); +} + +/** + * Handle authentication request + */ +async function handleAuth({ username, password }) { + return await matrixApiFetch('/_matrix/client/r0/login', { + method: 'POST', + body: JSON.stringify({ + type: 'm.login.password', + identifier: { + type: 'm.id.user', + user: username + }, + password: password + }) + }); +} + +/** + * Handle send message request + */ +async function handleSendMessage({ roomId, content, accessToken, txnId }) { + const msgtype = content.msgtype || 'm.text'; + const endpoint = `/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${encodeURIComponent(txnId)}`; + + return await matrixApiFetch(endpoint, { + method: 'PUT', + body: JSON.stringify(content) + }, accessToken); +} + +/** + * Handle fetch presence request + */ +async function handleFetchPresence({ userId }) { + return await matrixApiFetch( + `/_matrix/client/r0/presence/${encodeURIComponent(userId)}/status`, + { method: 'GET' }, + MATRIX_CONFIG.publicReadToken + ); +} + +/** + * Handle fetch last message request + */ +async function handleFetchLastMessage({ roomId }) { + return await matrixApiFetch( + `/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/messages?dir=b&limit=1`, + { method: 'GET' }, + MATRIX_CONFIG.publicReadToken + ); +} + +/** + * Handle sync request + */ +async function handleSync({ accessToken, since, timeout = 0 }) { + let endpoint = `/_matrix/client/r0/sync?timeout=${timeout}`; + if (since) { + endpoint += `&since=${encodeURIComponent(since)}`; + } + + return await matrixApiFetch(endpoint, { + method: 'GET' + }, accessToken); +} + +/** + * Handle room alias resolution + */ +async function handleResolveAlias({ roomAlias }) { + return await matrixApiFetch( + `/_matrix/client/r0/directory/room/${encodeURIComponent(roomAlias)}`, + { method: 'GET' }, + MATRIX_CONFIG.publicReadToken + ); +} + +// Notify parent that bridge is ready +window.addEventListener('load', () => { + if (window.parent !== window) { + window.parent.postMessage({ + type: 'matrix:bridge:ready' + }, '*'); + } + console.log('[Matrix Bridge] Ready and listening for requests'); +}); diff --git a/website/terminal.js b/website/terminal.js index 4cc57ad..bb93995 100644 --- a/website/terminal.js +++ b/website/terminal.js @@ -50,7 +50,8 @@ const TERMINAL_SOUND_FILES = { const terminalSoundState = { startupPlayed: false, - startupUnlockBound: false + startupUnlockBound: false, + disabled: false }; /** @@ -69,6 +70,10 @@ function pickRandomValue(values) { * @returns {Promise} True when playback starts */ async function playSoundFile(filePath, volume) { + if (terminalSoundState.disabled) { + return false; + } + try { const sound = new Audio(filePath); sound.preload = 'auto'; @@ -76,6 +81,9 @@ async function playSoundFile(filePath, volume) { await sound.play(); return true; } catch (error) { + if (!error || error.name !== 'NotAllowedError') { + terminalSoundState.disabled = true; + } return false; } } @@ -350,10 +358,141 @@ let chatMode = { }; const MATRIX_PUBLIC_READ_TOKEN = 'syt_Z2VuZXJhbGNoYXQtcmVhZG9ubHk_sikLltUtfbHlztnanEVm_2icJ1o'; +const IS_NEOCITIES_HOST = window.location.hostname.endsWith('neocities.org'); +const MATRIX_BRIDGE_URL = 'https://lit.ruv.wtf/matrix-bridge.html'; + +/** + * Matrix Bridge - Iframe-based communication for CSP-restricted hosts + */ +const matrixBridge = { + iframe: null, + ready: false, + pendingRequests: new Map(), + requestCounter: 0 +}; + +/** + * Initialize the Matrix iframe bridge + * @returns {Promise} True if bridge loaded successfully + */ +async function initMatrixBridge() { + if (matrixBridge.iframe) { + return matrixBridge.ready; + } + + return new Promise((resolve) => { + const iframe = document.createElement('iframe'); + iframe.src = MATRIX_BRIDGE_URL; + iframe.style.display = 'none'; + iframe.style.position = 'absolute'; + iframe.style.width = '0'; + iframe.style.height = '0'; + iframe.style.border = 'none'; + + const timeout = setTimeout(() => { + console.error('[Matrix Bridge] Failed to load iframe bridge'); + matrixBridge.ready = false; + resolve(false); + }, 10000); + + window.addEventListener('message', (event) => { + if (event.data && event.data.type === 'matrix:bridge:ready') { + clearTimeout(timeout); + matrixBridge.ready = true; + console.log('[Matrix Bridge] Iframe bridge ready'); + resolve(true); + } + }); + + document.body.appendChild(iframe); + matrixBridge.iframe = iframe; + }); +} + +/** + * Send a request to the Matrix bridge via postMessage + * @param {string} type - Request type (e.g. 'matrix:auth', 'matrix:sendMessage') + * @param {object} payload - Request payload + * @returns {Promise} Response from bridge + */ +function matrixBridgeRequest(type, payload) { + return new Promise((resolve, reject) => { + if (!matrixBridge.iframe || !matrixBridge.ready) { + reject(new Error('Matrix bridge not initialized')); + return; + } + + const requestId = `req_${++matrixBridge.requestCounter}`; + const timeout = setTimeout(() => { + matrixBridge.pendingRequests.delete(requestId); + reject(new Error('Matrix bridge request timeout')); + }, 30000); + + matrixBridge.pendingRequests.set(requestId, { resolve, reject, timeout }); + + matrixBridge.iframe.contentWindow.postMessage({ + type, + requestId, + payload + }, MATRIX_BRIDGE_URL); + }); +} + +/** + * Handle responses from the Matrix bridge + */ +window.addEventListener('message', (event) => { + if (event.origin !== new URL(MATRIX_BRIDGE_URL).origin) { + return; + } + + const { type, requestId, payload } = event.data; + + if (!type || !requestId || !type.includes(':response')) { + return; + } + + const pending = matrixBridge.pendingRequests.get(requestId); + if (pending) { + clearTimeout(pending.timeout); + matrixBridge.pendingRequests.delete(requestId); + pending.resolve(payload); + } +}); // Matrix API helper const matrixApi = async (endpoint, method = 'GET', body = null) => { if (!window.matrixSession) return null; + + // Use iframe bridge on Neocities + if (IS_NEOCITIES_HOST) { + if (!matrixBridge.ready) { + const initialized = await initMatrixBridge(); + if (!initialized) { + return { + errcode: 'M_BRIDGE_UNAVAILABLE', + error: 'Matrix bridge failed to initialize' + }; + } + } + + try { + const result = await matrixBridgeRequest('matrix:api', { + endpoint, + method, + body, + accessToken: window.matrixSession.accessToken + }); + return result; + } catch (error) { + return { + errcode: 'M_BRIDGE_ERROR', + error: error.message + }; + } + } + + // Direct API call for non-CSP-restricted hosts const homeserver = 'https://chat.ruv.wtf'; const url = `${homeserver}/_matrix/client/r0${endpoint}`; const headers = { @@ -378,24 +517,91 @@ const matrixApi = async (endpoint, method = 'GET', body = null) => { * @returns {Promise<{time: string, sender: string, text: string, timestamp: number} | null>} Last message summary or null */ async function fetchPublicLastMessage(homeserver, roomAlias) { + // Use iframe bridge on Neocities + if (IS_NEOCITIES_HOST) { + if (!matrixBridge.ready) { + await initMatrixBridge(); + } + + if (!matrixBridge.ready) { + return null; + } + + try { + // Resolve room alias first + let roomId = null; + try { + const resolveResult = await matrixBridgeRequest('matrix:resolveAlias', { roomAlias }); + if (resolveResult && !resolveResult.error && resolveResult.room_id) { + roomId = resolveResult.room_id; + } + } catch (error) { + // Continue with fallback + } + + if (!roomId) { + roomId = '!RkOwQGTlDJwZbNxGeS:b.ruv.wtf'; + } + + // Fetch last message + const result = await matrixBridgeRequest('matrix:fetchLastMessage', { roomId }); + if (!result || result.error || !Array.isArray(result.chunk)) { + return null; + } + + const lastTextEvent = result.chunk.find((event) => { + return event && event.type === 'm.room.message' && event.content && event.content.msgtype === 'm.text'; + }); + + if (!lastTextEvent) { + return null; + } + + const timestamp = typeof lastTextEvent.origin_server_ts === 'number' + ? new Date(lastTextEvent.origin_server_ts) + : new Date(); + const time = timestamp.toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit' + }); + const sender = typeof lastTextEvent.sender === 'string' + ? lastTextEvent.sender.split(':')[0].replace(/^@/, '') + : 'unknown'; + const text = lastTextEvent.content.body || ''; + const timestampMs = typeof lastTextEvent.origin_server_ts === 'number' + ? lastTextEvent.origin_server_ts + : Date.now(); + + return { time, sender, text, timestamp: timestampMs }; + } catch (error) { + return null; + } + } + + // Direct API call for non-CSP-restricted hosts const resolvedAlias = encodeURIComponent(roomAlias); const authHeaders = { Authorization: `Bearer ${MATRIX_PUBLIC_READ_TOKEN}` }; - const roomResponse = await fetch(`${homeserver}/_matrix/client/r0/directory/room/${resolvedAlias}`, { - headers: authHeaders - }); - if (!roomResponse.ok) { - return null; + let roomId = null; + try { + const roomResponse = await fetch(`${homeserver}/_matrix/client/r0/directory/room/${resolvedAlias}`, { + headers: authHeaders + }); + if (roomResponse.ok) { + const roomData = await roomResponse.json(); + roomId = roomData && roomData.room_id ? roomData.room_id : null; + } + } catch (error) { + roomId = null; } - const roomData = await roomResponse.json(); - if (!roomData || !roomData.room_id) { - return null; + if (!roomId) { + roomId = '!RkOwQGTlDJwZbNxGeS:b.ruv.wtf'; } - const resolvedRoomId = encodeURIComponent(roomData.room_id); + const resolvedRoomId = encodeURIComponent(roomId); const messagesResponse = await fetch(`${homeserver}/_matrix/client/r0/rooms/${resolvedRoomId}/messages?dir=b&limit=30`, { headers: authHeaders }); @@ -441,6 +647,28 @@ async function fetchPublicLastMessage(homeserver, roomAlias) { * @returns {Promise} Presence state (online/offline/unavailable) or null when unavailable */ async function fetchPublicPresence(homeserver, userId) { + // Use iframe bridge on Neocities + if (IS_NEOCITIES_HOST) { + if (!matrixBridge.ready) { + await initMatrixBridge(); + } + + if (!matrixBridge.ready) { + return null; + } + + try { + const result = await matrixBridgeRequest('matrix:fetchPresence', { userId }); + if (result && !result.error && typeof result.presence === 'string') { + return result.presence; + } + return null; + } catch (error) { + return null; + } + } + + // Direct API call for non-CSP-restricted hosts const authHeaders = { Authorization: `Bearer ${MATRIX_PUBLIC_READ_TOKEN}` }; @@ -507,12 +735,16 @@ async function writeStartupChatMotd() { const lastMessageAge = result.latest ? formatTimeAgo(result.latest.timestamp) - : 'unknown'; + : null; const onlineText = result.litruvPresence === 'online' ? 'online' : 'offline'; - term.writeln(` #generalchat · last message ${lastMessageAge} · @litruv:b.ruv.wtf ${onlineText}`); + if (lastMessageAge) { + term.writeln(` #generalchat · last message ${lastMessageAge} · @litruv:b.ruv.wtf ${onlineText}`); + } else { + term.writeln(` #generalchat · @litruv:b.ruv.wtf ${onlineText}`); + } term.writeln(''); } catch (error) { // Ignore MOTD fetch issues to avoid blocking terminal startup. @@ -1024,6 +1256,15 @@ async function enterChatMode() { // Fetch initial messages await syncChatMessages(); + // Show channel presence checker for litruv account + const presenceTime = new Date().toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit' + }); + const litruvPresence = await fetchPublicPresence('https://chat.ruv.wtf', '@litruv:b.ruv.wtf'); + const litruvOnlineText = litruvPresence === 'online' ? 'online' : 'offline'; + term.writeln(`\x1b[90m[${presenceTime}]\x1b[0m \x1b[93mSystem:\x1b[0m @litruv:b.ruv.wtf is ${litruvOnlineText}`); + // Show nickname setup hint in message history when display name is still default const showNicknameHint = await shouldShowNicknameHint(); if (showNicknameHint) { @@ -1064,8 +1305,8 @@ function exitChatMode() { chatMode.mentionLoadedAt = 0; resetMentionAutocomplete(); term.clear(); - term.writeln(' Exited chat mode.\r\n'); - term.write(promptColored); + term.writeln(' Exited chat mode.'); + writePrompt(); showInlineInput(); updateQuickCommands('terminal'); } @@ -1635,7 +1876,15 @@ function getWelcomeBanner() { // Prompt (plain version for display, we handle colors separately) const promptText = 'user@lit.ruv.wtf $ '; -const promptColored = '\r\n\x1b[1;32muser@lit.ruv.wtf\x1b[0m $ '; +const promptColored = '\x1b[1;32muser@lit.ruv.wtf\x1b[0m $ '; + +/** + * Write the shell prompt at the current cursor position. + * @returns {void} + */ +function writePrompt() { + term.write(promptColored); +} /** * Position the inline input at the cursor location @@ -1800,7 +2049,7 @@ async function submitInlineInput() { // Show prompt if not in chat mode if (!chatMode.active) { - term.write(promptColored); + writePrompt(); // Delay to ensure terminal has rendered before positioning setTimeout(() => { positionInlineInput(); @@ -1832,7 +2081,7 @@ async function init() { await writeStartupChatMotd(); await attemptStartupSoundPlayback(); - term.write(promptColored); + writePrompt(); // Add inline input after a short delay to ensure terminal is rendered setTimeout(() => { @@ -1919,7 +2168,7 @@ async function runQuickCommand(command) { // Show prompt if not in chat mode if (!chatMode.active) { - term.write(promptColored); + writePrompt(); // Delay to ensure terminal has rendered before positioning setTimeout(() => { positionInlineInput();