Files
featherChat/warzone/crates/warzone-server/src/routes/web.rs
2026-03-29 14:47:54 +04:00

1400 lines
53 KiB
Rust

use axum::{
http::header,
response::{Html, IntoResponse},
routing::get,
Router,
};
use crate::state::AppState;
pub fn routes() -> Router<AppState> {
Router::new()
.route("/", get(web_ui))
.route("/wasm/warzone_wasm.js", get(wasm_js))
.route("/wasm/warzone_wasm_bg.wasm", get(wasm_binary))
.route("/manifest.json", get(pwa_manifest))
.route("/sw.js", get(service_worker))
.route("/icon.svg", get(pwa_icon))
}
async fn web_ui() -> Html<&'static str> {
Html(WEB_HTML)
}
async fn wasm_js() -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "application/javascript")],
include_str!("../../../../wasm-pkg/warzone_wasm.js"),
)
}
async fn wasm_binary() -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "application/wasm")],
include_bytes!("../../../../wasm-pkg/warzone_wasm_bg.wasm").as_slice(),
)
}
async fn pwa_manifest() -> impl IntoResponse {
([(header::CONTENT_TYPE, "application/manifest+json")], r##"{
"name": "Warzone Messenger",
"short_name": "Warzone",
"description": "End-to-end encrypted messenger",
"start_url": "/",
"display": "standalone",
"background_color": "#0a0a1a",
"theme_color": "#0a0a1a",
"icons": [{"src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable"}]
}"##)
}
async fn service_worker() -> impl IntoResponse {
([(header::CONTENT_TYPE, "application/javascript")], r##"
const CACHE = 'wz-v14';
const SHELL = ['/', '/wasm/warzone_wasm.js', '/wasm/warzone_wasm_bg.wasm', '/icon.svg', '/manifest.json'];
self.addEventListener('install', e => {
e.waitUntil(caches.open(CACHE).then(c => c.addAll(SHELL)));
self.skipWaiting();
});
self.addEventListener('activate', e => {
e.waitUntil(caches.keys().then(keys =>
Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)))
));
e.waitUntil(clients.claim());
});
self.addEventListener('fetch', e => {
const url = new URL(e.request.url);
// API calls: network only
if (url.pathname.startsWith('/v1/')) return;
// WS: skip
if (url.protocol === 'ws:' || url.protocol === 'wss:') return;
// Network first, cache fallback (ensures updates are picked up immediately)
e.respondWith(
fetch(e.request).then(resp => {
if (resp.ok && SHELL.includes(url.pathname)) {
const clone = resp.clone();
caches.open(CACHE).then(c => c.put(e.request, clone));
}
return resp;
}).catch(() => caches.match(e.request).then(cached => {
if (cached) return cached;
if (e.request.mode === 'navigate') return caches.match('/');
}))
);
});
"##)
}
async fn pwa_icon() -> impl IntoResponse {
([(header::CONTENT_TYPE, "image/svg+xml")], r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="100" fill="#0a0a1a"/>
<path d="M128 140h256c22 0 40 18 40 40v152c0 22-18 40-40 40H210l-70 52v-52h-12c-22 0-40-18-40-40V180c0-22 18-40 40-40z" fill="#e94560"/>
<circle cx="200" cy="256" r="18" fill="#0a0a1a"/>
<circle cx="256" cy="256" r="18" fill="#0a0a1a"/>
<circle cx="312" cy="256" r="18" fill="#0a0a1a"/>
<rect x="180" y="340" width="30" height="8" rx="4" fill="#4ade80"/>
<rect x="220" y="340" width="30" height="8" rx="4" fill="#4ade80"/>
</svg>"##)
}
const WEB_HTML: &str = r##"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover">
<meta name="theme-color" content="#0a0a1a">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Warzone">
<meta name="mobile-web-app-capable" content="yes">
<link rel="manifest" href="/manifest.json">
<link rel="icon" href="/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/icon.svg">
<title>Warzone</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; overflow: hidden; }
body { background: #0a0a1a; color: #c8d6e5; font-family: 'JetBrains Mono', 'Fira Code', 'Courier New', monospace;
display: flex; flex-direction: column; height: 100dvh; font-size: 14px; }
/* Setup screen */
.screen { display: none; flex-direction: column; flex: 1; }
.screen.active { display: flex; }
#setup { align-items: center; justify-content: center; padding: 20px; }
#setup h1 { color: #e94560; font-size: 1.8em; margin-bottom: 4px; }
#setup .sub { color: #555; margin-bottom: 24px; font-size: 0.8em; }
.fp-display { background: #111; border: 1px solid #333; padding: 12px 20px; border-radius: 6px;
font-size: 1.1em; color: #4ade80; letter-spacing: 1px; margin: 8px 0; font-family: monospace;
user-select: all; cursor: pointer; }
.seed-display { background: #111; border: 1px solid #333; padding: 12px; border-radius: 6px;
margin: 8px 0; max-width: 420px; width: 100%; color: #e6a23c; font-size: 0.8em;
line-height: 1.8; text-align: center; user-select: all; word-break: break-all; }
.warn { color: #e94560; font-size: 0.75em; margin: 6px 0; }
.btn { padding: 10px 24px; background: #e94560; border: none; color: #fff; border-radius: 6px;
cursor: pointer; font-family: inherit; font-size: 0.9em; margin: 4px; }
.btn:hover { background: #c73e54; }
.btn-alt { background: #1a1a3e; border: 1px solid #444; }
.btn-alt:hover { background: #252550; }
#recover-area { display: none; margin-top: 12px; max-width: 420px; width: 100%; }
#recover-area textarea { width: 100%; padding: 10px; background: #1a1a2e; border: 1px solid #333;
color: #c8d6e5; border-radius: 6px; font-family: inherit; min-height: 50px;
resize: none; margin-bottom: 8px; font-size: 14px; }
/* Chat screen */
#chat-header { padding: 6px 10px; background: #111; border-bottom: 1px solid #222;
display: flex; align-items: center; gap: 8px; font-size: 0.8em; flex-wrap: wrap; }
#chat-header .tag { padding: 1px 6px; border-radius: 3px; font-size: 0.85em; }
.tag-fp { background: #0a2e0a; color: #4ade80; }
.tag-peer { background: #2e2e0a; color: #e6a23c; }
.tag-server { color: #444; }
#chat-header input { background: #1a1a2e; border: 1px solid #333; color: #e6a23c; padding: 2px 6px;
border-radius: 3px; font-family: inherit; font-size: 0.85em; width: 280px; }
#messages { flex: 1; overflow-y: scroll; padding: 8px 10px; -webkit-overflow-scrolling: touch; min-height: 0; }
.msg { padding: 2px 0; font-size: 0.85em; white-space: pre-wrap; word-wrap: break-word; }
.msg code { background: #1a1a3e; padding: 1px 4px; border-radius: 3px; font-size: 0.95em; color: #4fc3f7; }
.msg pre { background: #0d0d20; padding: 8px; border-radius: 4px; margin: 4px 0; overflow-x: auto; border: 1px solid #222; }
.msg pre code { background: none; padding: 0; }
.msg strong, .msg b { color: #fff; }
.msg em, .msg i { color: #e6a23c; }
.msg a { color: #4fc3f7; }
.msg blockquote { border-left: 3px solid #444; padding-left: 8px; color: #888; margin: 4px 0; }
.msg ul, .msg ol { padding-left: 20px; margin: 4px 0; }
.msg h1, .msg h2, .msg h3 { color: #fff; margin: 6px 0 2px; }
.msg h1 { font-size: 1.2em; } .msg h2 { font-size: 1.1em; } .msg h3 { font-size: 1em; }
.msg .ts { color: #333; margin-right: 4px; }
.msg .from-self { color: #4ade80; font-weight: bold; }
.msg .from-sys { color: #5e9ca0; font-style: italic; }
.msg .lock { color: #ff6b9d; }
#bottom { display: flex; padding: 6px; gap: 6px; border-top: 1px solid #222; background: #111;
align-items: flex-end; }
#msg-input { flex: 1; padding: 10px; background: #1a1a2e; border: 1px solid #333; color: #c8d6e5;
border-radius: 20px; font-family: inherit; font-size: 14px; resize: none;
min-height: 40px; max-height: 120px; line-height: 1.4; }
#send-btn { padding: 10px 16px; background: #e94560; border: none; color: #fff; border-radius: 20px;
cursor: pointer; font-size: 14px; min-height: 40px; }
#send-btn:hover { background: #c73e54; }
.addr { color: #4fc3f7; cursor: pointer; text-decoration: underline; }
.addr:hover { color: #81d4fa; }
@media (max-width: 500px) {
.msg { font-size: 0.8em; }
#chat-header input { width: 180px; }
}
</style>
</head>
<body>
<!-- Setup screen -->
<div id="setup" class="screen active">
<h1>WARZONE</h1>
<div class="sub">end-to-end encrypted messenger</div>
<div id="gen-section">
<button class="btn" id="btn-generate">Generate Identity</button>
<button class="btn btn-alt" id="btn-show-recover">Recover</button>
<div id="recover-area">
<textarea id="recover-input" placeholder="Paste your hex seed (64 chars)..." rows="2"></textarea>
<button class="btn" id="btn-recover">Recover Identity</button>
</div>
</div>
<div id="id-section" style="display:none">
<div>Your fingerprint:</div>
<div class="fp-display" id="my-fp" title="Click to copy"></div>
<div class="seed-display" id="my-seed"></div>
<div class="warn">SAVE YOUR SEED — only way to recover your identity</div>
<br>
<button class="btn" id="btn-enter">Enter Chat</button>
</div>
</div>
<!-- Chat screen -->
<div id="chat" class="screen">
<div id="chat-header">
<span class="tag tag-fp" id="hdr-fp" style="cursor:pointer" title="Click to copy"></span>
<span id="hdr-eth" style="display:none"></span>
<span>→</span>
<input id="peer-input" placeholder="Paste peer fingerprint..." autocomplete="off">
<span class="tag-server" id="hdr-server"></span>
</div>
<div id="messages"></div>
<div id="bottom">
<label id="file-btn" title="Send file" style="padding:10px;background:#1a1a2e;border:1px solid #333;color:#c8d6e5;border-radius:50%;cursor:pointer;min-width:40px;min-height:40px;text-align:center;line-height:20px;font-size:1.1em">&#128206;<input type="file" id="file-input" style="display:none"></label>
<textarea id="msg-input" placeholder="Message... (Enter to send)" rows="1"></textarea>
<button id="send-btn">&#9654;</button>
</div>
</div>
<script type="module">
import init, { WasmIdentity, WasmSession, decrypt_wire_message, self_test, debug_bundle_info, create_receipt } from '/wasm/warzone_wasm.js';
const SERVER = window.location.origin;
const $messages = document.getElementById('messages');
const $input = document.getElementById('msg-input');
const $peerInput = document.getElementById('peer-input');
// ── State ──
let wasmIdentity = null; // WasmIdentity from WASM
let myFingerprint = '';
let myEthAddress = '';
let mySeedHex = '';
let sessions = {}; // peerFP -> { session: WasmSession, data: base64 }
let peerBundles = {}; // peerFP -> bundle bytes
let pollTimer = null;
let ws = null; // WebSocket connection
let wasmReady = false;
const VERSION = '0.0.32';
let DEBUG = true; // toggle with /debug command
// ── Receipt tracking ──
let sentMsgReceipts = {}; // messageId -> { status: 'sent'|'delivered'|'read', el: DOM element }
function receiptIndicator(status) {
switch (status) {
case 'read': return '\u2713\u2713';
case 'delivered': return '\u2713\u2713';
case 'sent': default: return '\u2713';
}
}
function receiptColor(status) {
switch (status) {
case 'read': return '#67c7eb';
case 'delivered': return '#ccc';
case 'sent': default: return '#555';
}
}
function updateReceiptDisplay(messageId, status) {
const entry = sentMsgReceipts[messageId];
if (!entry) return;
// Only upgrade status: sent -> delivered -> read
const order = { sent: 0, delivered: 1, read: 2 };
if ((order[status] || 0) <= (order[entry.status] || 0)) return;
entry.status = status;
if (entry.el) {
entry.el.textContent = ' ' + receiptIndicator(status);
entry.el.style.color = receiptColor(status);
}
}
function sendReceipt(toPeerFP, messageId, receiptType) {
try {
const receiptBytes = create_receipt(normFP(myFingerprint), messageId, receiptType);
const fp = normFP(toPeerFP);
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ to: fp, from: normFP(myFingerprint), message: Array.from(receiptBytes) }));
} else {
fetch(SERVER + '/v1/messages/send', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ to: fp, from: normFP(myFingerprint), message: Array.from(receiptBytes) })
});
}
dbg('Sent', receiptType, 'receipt for', messageId, 'to', fp);
} catch(e) {
dbg('Failed to send receipt:', e);
}
}
function dbg(...args) {
if (DEBUG) console.log('[WZ]', ...args);
}
function normFP(fp) {
return fp.replace(/[^0-9a-fA-F]/g, '').toLowerCase();
}
function makeAddressClickable(text) {
// Match fingerprint format: xxxx:xxxx:xxxx:xxxx... (at least 4 groups)
text = text.replace(/([0-9a-f]{4}(?::[0-9a-f]{4}){3,})/gi, function(match) {
const fp = match.replace(/:/g, '');
return '<span class="addr" data-addr="' + fp + '" title="Click to message">' + match + '</span>';
});
// Match ETH addresses: 0x followed by 40 hex chars
text = text.replace(/(0x[0-9a-fA-F]{40})/g, function(match) {
return '<span class="addr" data-addr="' + match + '" title="Click to message">' + match + '</span>';
});
return text;
}
function handleAddrClick(addr) {
const input = document.getElementById('msg-input');
if (input && input.value.trim().length > 0) {
navigator.clipboard.writeText(addr).then(() => {
addSys('Copied: ' + addr);
});
} else {
$peerInput.value = addr;
currentGroup = null;
localStorage.setItem('wz-peer', addr);
addSys('Peer set to ' + addr.slice(0,16) + '...');
}
}
// ── WASM-based crypto (same as CLI: X25519 + ChaCha20 + Double Ratchet) ──
async function initWasm() {
await init('/wasm/warzone_wasm_bg.wasm');
wasmReady = true;
}
let mySpkSecretHex = '';
function initIdentityFromSeed(hexSeed) {
wasmIdentity = WasmIdentity.from_hex_seed(hexSeed);
myFingerprint = wasmIdentity.fingerprint();
mySeedHex = wasmIdentity.seed_hex();
// Restore or generate SPK secret
const savedSpk = localStorage.getItem('wz-spk');
if (savedSpk) {
wasmIdentity.set_spk_secret_hex(savedSpk);
mySpkSecretHex = savedSpk;
dbg('Restored SPK secret from localStorage');
} else {
mySpkSecretHex = wasmIdentity.spk_secret_hex();
localStorage.setItem('wz-spk', mySpkSecretHex);
dbg('Generated new SPK secret');
}
localStorage.setItem('wz-seed', mySeedHex);
localStorage.setItem('wz-fp', myFingerprint);
}
function generateNewIdentity() {
wasmIdentity = new WasmIdentity();
myFingerprint = wasmIdentity.fingerprint();
mySeedHex = wasmIdentity.seed_hex();
mySpkSecretHex = wasmIdentity.spk_secret_hex();
localStorage.setItem('wz-seed', mySeedHex);
localStorage.setItem('wz-fp', myFingerprint);
localStorage.setItem('wz-spk', mySpkSecretHex);
dbg('New identity, SPK secret stored');
}
function loadSavedIdentity() {
const saved = localStorage.getItem('wz-seed');
if (!saved) { dbg('No saved seed'); return false; }
try {
initIdentityFromSeed(saved);
dbg('Loaded identity:', myFingerprint, 'has SPK:', !!mySpkSecretHex);
return true;
} catch(e) {
dbg('Failed to load identity:', e);
localStorage.removeItem('wz-seed');
return false;
}
}
async function registerKey() {
const fp = normFP(myFingerprint);
const bundleBytes = wasmIdentity.bundle_bytes();
myEthAddress = wasmIdentity.eth_address();
dbg('Registering key, fp:', fp, 'bundle size:', bundleBytes.length, 'eth:', myEthAddress);
await fetch(SERVER + '/v1/keys/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fingerprint: fp, bundle: Array.from(bundleBytes), eth_address: myEthAddress })
});
dbg('Key registered');
}
async function fetchPeerBundle(peerFP) {
const fp = normFP(peerFP);
if (peerBundles[fp]) return peerBundles[fp];
const resp = await fetch(SERVER + '/v1/keys/' + fp);
if (!resp.ok) throw new Error('Peer not registered');
const data = await resp.json();
const bytes = Uint8Array.from(atob(data.bundle), c => c.charCodeAt(0));
peerBundles[fp] = bytes;
return bytes;
}
function generateMsgId() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
const r = Math.random() * 16 | 0;
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
});
}
async function sendEncrypted(peerFP, plaintext) {
const fp = normFP(peerFP);
dbg('sendEncrypted to:', fp, 'text length:', plaintext.length);
const bundleBytes = await fetchPeerBundle(fp);
dbg('Got peer bundle, size:', bundleBytes.length);
const msgId = generateMsgId();
let wireBytes;
if (sessions[fp]) {
dbg('Using existing session for', fp);
try {
const sess = WasmSession.restore(sessions[fp].data);
wireBytes = sess.encrypt_with_id(wasmIdentity, plaintext, msgId);
sessions[fp].data = sess.save();
} catch(e) {
dbg('Existing session encrypt failed, creating new:', e.message);
const sess = WasmSession.initiate(wasmIdentity, bundleBytes);
wireBytes = sess.encrypt_key_exchange_with_id(wasmIdentity, bundleBytes, plaintext, msgId);
sessions[fp] = { data: sess.save() };
}
} else {
dbg('New session (X3DH) for', fp);
const sess = WasmSession.initiate(wasmIdentity, bundleBytes);
wireBytes = sess.encrypt_key_exchange_with_id(wasmIdentity, bundleBytes, plaintext, msgId);
sessions[fp] = { data: sess.save() };
}
localStorage.setItem('wz-sessions', JSON.stringify(
Object.fromEntries(Object.entries(sessions).map(([k,v]) => [k, v.data]))
));
dbg('Sending wire message, size:', wireBytes.length, 'id:', msgId);
// Prefer WebSocket, fall back to HTTP
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
to: fp,
from: normFP(myFingerprint),
message: Array.from(wireBytes)
}));
dbg('Sent via WebSocket');
} else {
await fetch(SERVER + '/v1/messages/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
to: fp,
from: normFP(myFingerprint),
message: Array.from(wireBytes)
})
});
dbg('Sent via HTTP (WS not connected)');
}
return msgId;
}
// URL deep links: /message/@alias, /message/0xABC, /group/#ops
function handleDeepLink() {
const path = window.location.pathname;
if (path.startsWith('/message/')) {
const target = decodeURIComponent(path.slice(9));
if (target) {
setTimeout(() => {
$peerInput.value = target;
if (target.startsWith('@')) {
fetch(SERVER + '/v1/alias/resolve/' + target.slice(1)).then(r => r.json()).then(data => {
if (!data.error) {
$peerInput.value = data.fingerprint;
currentGroup = null;
localStorage.setItem('wz-peer', data.fingerprint);
addSys('Deep link: peer set to ' + target + ' (' + data.fingerprint.slice(0,16) + '...)');
} else {
addSys('Deep link: unknown alias ' + target);
}
});
} else {
currentGroup = null;
localStorage.setItem('wz-peer', target);
addSys('Deep link: peer set to ' + target.slice(0,16) + '...');
}
}, 500);
}
} else if (path.startsWith('/group/')) {
let group = decodeURIComponent(path.slice(7));
if (group.startsWith('#')) group = group.slice(1);
if (group) {
setTimeout(() => {
groupSwitch(group);
}, 500);
}
}
}
function connectWebSocket() {
const fp = normFP(myFingerprint);
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = proto + '//' + location.host + '/v1/ws/' + fp;
dbg('Connecting WebSocket:', wsUrl);
ws = new WebSocket(wsUrl);
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
dbg('WebSocket connected');
addSys('Real-time connection established');
handleDeepLink();
};
ws.onmessage = async (event) => {
if (typeof event.data === 'string') {
// Text frame — could be a bot message or missed call notification
try {
const json = JSON.parse(event.data);
if (json.type === 'missed_call') {
addSys('Missed call from ' + (json.data?.caller_fp || 'unknown'));
return;
}
if (json.type === 'bot_message') {
const botName = json.from_name || json.from || 'bot';
let msgText = json.text || '';
if (json.reply_markup && json.reply_markup.inline_keyboard) {
msgText += '\\n';
for (const row of json.reply_markup.inline_keyboard) {
for (const btn of row) {
msgText += ' [' + btn.text + '] ';
}
msgText += '\\n';
}
}
const useHtml = json.parse_mode === 'HTML';
addMsg('@' + botName, msgText, false, null, useHtml);
lastDmPeer = json.from ? normFP(json.from) : '';
return;
}
if (json.type === 'bot_edit') {
addSys('[bot updated: ' + (json.text || '') + ']');
return;
}
if (json.type === 'bot_document') {
addMsg('@' + (json.from || 'bot'), '[Document: ' + json.document + ']', false);
return;
}
} catch(e) {}
// If not JSON or unrecognized, try treating as binary
const bytes = new TextEncoder().encode(event.data);
dbg('WS text frame treated as bytes,', bytes.length, 'bytes');
await handleIncomingMessage(bytes);
return;
}
const bytes = new Uint8Array(event.data);
dbg('WS received', bytes.length, 'bytes');
await handleIncomingMessage(bytes);
};
ws.onclose = () => {
dbg('WebSocket closed, reconnecting in 3s...');
addSys('Connection lost, reconnecting...');
setTimeout(connectWebSocket, 3000);
};
ws.onerror = (e) => {
dbg('WebSocket error:', e);
};
}
async function handleIncomingMessage(bytes) {
dbg('Processing message,', bytes.length, 'bytes, sessions:', Object.keys(sessions));
// Quick check: try to parse as Receipt first (no session needed, no decrypt)
try {
const resultStr = decrypt_wire_message(mySeedHex, mySpkSecretHex, bytes, null);
const result = JSON.parse(resultStr);
if (result.type === 'receipt') {
dbg('Received', result.receipt_type, 'receipt for', result.message_id, 'from', result.sender);
updateReceiptDisplay(result.message_id, result.receipt_type);
return;
}
if (result.type === 'file_header') {
handleFileHeader(result);
return;
}
if (result.type === 'file_chunk') {
handleFileChunk(result);
return;
}
// It was a KeyExchange
dbg('Decrypted (KeyExchange) from:', result.sender);
const senderFP = normFP(result.sender);
sessions[senderFP] = { data: result.session_data };
localStorage.setItem('wz-sessions', JSON.stringify(
Object.fromEntries(Object.entries(sessions).map(([k,v]) => [k, v.data]))
));
let fromLabel = result.sender.slice(0, 19);
try {
const ar = await fetch(SERVER + '/v1/resolve/' + senderFP);
const ad = await ar.json();
if (ad.eth_address) fromLabel = ad.eth_address.slice(0, 12) + '...';
// Alias overrides ETH
const aw = await fetch(SERVER + '/v1/alias/whois/' + senderFP);
const adata = await aw.json();
if (adata.alias) fromLabel = '@' + adata.alias;
} catch(e) {}
addMsg(fromLabel, result.text, false);
if (result.message_id) sendReceipt(result.sender, result.message_id, 'delivered');
lastDmPeer = normFP(result.sender);
return;
} catch(e) {
dbg('KeyExchange/Receipt parse failed:', e.message || e);
}
// Second try: existing sessions
for (const [senderFP, sessData] of Object.entries(sessions)) {
try {
const resultStr = decrypt_wire_message(mySeedHex, mySpkSecretHex, bytes, sessData.data);
const result = JSON.parse(resultStr);
if (result.type === 'receipt') {
dbg('Received', result.receipt_type, 'receipt for', result.message_id);
updateReceiptDisplay(result.message_id, result.receipt_type);
return;
}
if (result.type === 'file_header') { handleFileHeader(result); return; }
if (result.type === 'file_chunk') { handleFileChunk(result); return; }
dbg('Decrypted with session', senderFP);
sessions[senderFP] = { data: result.session_data };
localStorage.setItem('wz-sessions', JSON.stringify(
Object.fromEntries(Object.entries(sessions).map(([k,v]) => [k, v.data]))
));
let fromLabel = result.sender.slice(0, 19);
try {
const rfp = normFP(result.sender);
const ar = await fetch(SERVER + '/v1/resolve/' + rfp);
const ad = await ar.json();
if (ad.eth_address) fromLabel = ad.eth_address.slice(0, 12) + '...';
const aw = await fetch(SERVER + '/v1/alias/whois/' + rfp);
const adata = await aw.json();
if (adata.alias) fromLabel = '@' + adata.alias;
} catch(e2) {}
addMsg(fromLabel, result.text, false);
if (result.message_id) sendReceipt(result.sender, result.message_id, 'delivered');
lastDmPeer = normFP(result.sender);
return;
} catch(e2) {
dbg('Session', senderFP, 'failed:', e2.message || e2);
}
}
// Last try: raw JSON file messages (from web file upload) or bot messages
try {
const str = new TextDecoder().decode(bytes);
const json = JSON.parse(str);
if (json.type === 'file_header') { handleFileHeader(json); return; }
if (json.type === 'file_chunk') { handleFileChunk(json); return; }
// Handle bot messages (plaintext JSON from bot API)
if (json.type === 'bot_message') {
const botName = json.from_name || json.from || 'bot';
let msgText = json.text || '';
// Handle inline keyboard if present
if (json.reply_markup && json.reply_markup.inline_keyboard) {
msgText += '\\n';
for (const row of json.reply_markup.inline_keyboard) {
for (const btn of row) {
msgText += ' [' + btn.text + '] ';
}
msgText += '\\n';
}
}
const useHtml = json.parse_mode === 'HTML';
addMsg('@' + botName, msgText, false, null, useHtml);
lastDmPeer = json.from ? normFP(json.from) : '';
return;
}
if (json.type === 'bot_edit') {
addSys('[bot updated message: ' + (json.text || '') + ']');
return;
}
if (json.type === 'bot_document') {
const caption = json.caption ? ' \u2014 ' + json.caption : '';
addMsg('@' + (json.from || 'bot'), '[Document: ' + json.document + caption + ']', false);
return;
}
} catch(e) {}
dbg('ALL decrypt attempts failed');
addSys('[message could not be decrypted]');
}
// Load saved sessions
try {
const saved = localStorage.getItem('wz-sessions');
if (saved) {
const parsed = JSON.parse(saved);
for (const [k, v] of Object.entries(parsed)) {
sessions[k] = { data: v };
}
}
} catch(e) {}
// ── UI ──
function ts() {
return new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function esc(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
function renderMd(text) {
let s = esc(text);
// Code blocks: ```...```
s = s.replace(/```(\w*)\n?([\s\S]*?)```/g, '<pre><code>$2</code></pre>');
// Inline code: `...`
s = s.replace(/`([^`]+)`/g, '<code>$1</code>');
// Bold: **...**
s = s.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
// Italic: *...*
s = s.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, '<em>$1</em>');
// Headers: ### ... (at line start)
s = s.replace(/^### (.+)$/gm, '<h3>$1</h3>');
s = s.replace(/^## (.+)$/gm, '<h2>$1</h2>');
s = s.replace(/^# (.+)$/gm, '<h1>$1</h1>');
// Links: [text](url)
s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
// Blockquotes: > ...
s = s.replace(/^&gt; (.+)$/gm, '<blockquote>$1</blockquote>');
// Unordered lists: - ...
s = s.replace(/^- (.+)$/gm, '<li>$1</li>');
s = s.replace(/(<li>.*<\/li>\n?)+/g, '<ul>$&</ul>');
// Line breaks
s = s.replace(/\n/g, '<br>');
// Clean up br inside pre
s = s.replace(/<pre><code>([\s\S]*?)<\/code><\/pre>/g, (m, code) => '<pre><code>' + code.replace(/<br>/g, '\n') + '</code></pre>');
return s;
}
const PEER_COLORS = ['#e6a23c','#f56c9d','#67c7eb','#b39ddb','#ff8a65','#81c784','#ce93d8','#4fc3f7','#ffb74d','#aed581','#f06292','#4dd0e1'];
function peerColor(name) {
let h = 0;
for (let i = 0; i < name.length; i++) h = ((h << 5) - h + name.charCodeAt(i)) | 0;
return PEER_COLORS[Math.abs(h) % PEER_COLORS.length];
}
// ── File transfer handling ──
function handleFileHeader(result) {
const id = result.id;
pendingFiles[id] = {
filename: result.filename,
file_size: result.file_size,
total_chunks: result.total_chunks,
sha256: result.sha256,
sender: result.sender,
chunks: new Array(result.total_chunks).fill(null),
received: 0,
};
addSys('Incoming file "' + result.filename + '" from ' + result.sender.slice(0,12) + ' (' + formatSize(result.file_size) + ', ' + result.total_chunks + ' chunks)');
}
function handleFileChunk(result) {
const pf = pendingFiles[result.id];
if (!pf) {
dbg('Received chunk for unknown file', result.id);
return;
}
// Decode hex data
const data = new Uint8Array(result.data.match(/.{1,2}/g).map(b => parseInt(b, 16)));
pf.chunks[result.chunk_index] = data;
pf.received++;
addSys('Receiving "' + pf.filename + '" [' + pf.received + '/' + pf.total_chunks + ']');
if (pf.received === pf.total_chunks) {
// Assemble file
let totalLen = 0;
for (const c of pf.chunks) totalLen += c.length;
const assembled = new Uint8Array(totalLen);
let offset = 0;
for (const c of pf.chunks) {
assembled.set(c, offset);
offset += c.length;
}
// Show clickable download link
const blob = new Blob([assembled]);
const url = URL.createObjectURL(blob);
const d = document.createElement('div');
d.className = 'msg';
d.innerHTML = '<span class="ts">' + ts() + '</span> &#128206; <a href="' + url + '" download="' + esc(pf.filename) + '" style="color:#67c7eb;background:#0f3460;padding:4px 10px;border-radius:4px;text-decoration:none">' + esc(pf.filename) + ' (' + formatSize(assembled.length) + ')</a> from ' + esc(pf.sender.slice(0,12));
$messages.appendChild(d);
$messages.scrollTop = $messages.scrollHeight;
delete pendingFiles[result.id];
}
}
function formatSize(n) {
if (n < 1024) return n + ' B';
if (n < 1048576) return (n/1024).toFixed(1) + ' KB';
return (n/1048576).toFixed(1) + ' MB';
}
function addMsg(from, text, isSelf, messageId, rawHtml) {
const d = document.createElement('div');
d.className = 'msg';
const color = isSelf ? '#4ade80' : peerColor(from);
const lock = isSelf ? '' : '<span class="lock">&#128274; </span>';
let receiptHtml = '';
if (isSelf && messageId) {
const status = (sentMsgReceipts[messageId] && sentMsgReceipts[messageId].status) || 'sent';
receiptHtml = ' <span class="receipt" style="color:' + receiptColor(status) + '"> ' + receiptIndicator(status) + '</span>';
}
const bodyHtml = rawHtml ? text : makeAddressClickable(renderMd(text));
d.innerHTML = '<span class="ts">' + ts() + '</span> ' + lock + '<span style="color:' + color + ';font-weight:bold">' + makeAddressClickable(esc(from)) + '</span>: ' + bodyHtml + receiptHtml;
// Attach click handler for .addr spans
d.querySelectorAll('.addr').forEach(el => {
el.addEventListener('click', () => handleAddrClick(el.dataset.addr));
});
$messages.appendChild(d);
$messages.scrollTop = $messages.scrollHeight;
// Store reference to the receipt span so we can update it later
if (isSelf && messageId) {
const receiptEl = d.querySelector('.receipt');
if (!sentMsgReceipts[messageId]) sentMsgReceipts[messageId] = { status: 'sent', el: null };
sentMsgReceipts[messageId].el = receiptEl;
}
}
function addSys(text) {
const d = document.createElement('div');
d.className = 'msg';
d.innerHTML = '<span class="ts">' + ts() + '</span> <span class="from-sys">' + esc(text) + '</span>';
$messages.appendChild(d);
$messages.scrollTop = $messages.scrollHeight;
}
// ── Actions ──
async function doGenerate() {
generateNewIdentity();
document.getElementById('my-fp').textContent = myFingerprint;
document.getElementById('my-seed').textContent = mySeedHex;
document.getElementById('gen-section').style.display = 'none';
document.getElementById('id-section').style.display = 'block';
}
async function doRecover() {
const input = document.getElementById('recover-input').value.trim().replace(/\s+/g, '');
try {
initIdentityFromSeed(input);
document.getElementById('my-fp').textContent = myFingerprint;
document.getElementById('my-seed').textContent = '(recovered)';
document.getElementById('gen-section').style.display = 'none';
document.getElementById('id-section').style.display = 'block';
} catch(e) {
alert('Invalid seed: ' + e.message);
}
}
let currentGroup = null; // if set, messages go to group
let lastDmPeer = null; // for /r reply
let pendingFiles = {}; // file_id -> { filename, chunks: [], total, received, sha256, sender }
async function enterChat() {
document.getElementById('setup').classList.remove('active');
document.getElementById('chat').classList.add('active');
document.getElementById('hdr-server').textContent = SERVER;
await registerKey();
// Show ETH in header, fallback to fingerprint
const hdrFp = document.getElementById('hdr-fp');
if (myEthAddress) {
hdrFp.textContent = myEthAddress.slice(0, 12) + '...';
hdrFp.title = myEthAddress;
hdrFp.onclick = function() { navigator.clipboard.writeText(myEthAddress); addSys('Copied: ' + myEthAddress); };
} else {
hdrFp.textContent = (myEthAddress ? myEthAddress.slice(0,12) + '...' : myFingerprint.slice(0,19));
hdrFp.title = myFingerprint;
}
addSys('Identity: ' + (myEthAddress || myFingerprint));
addSys('Key registered with server');
addSys('v' + VERSION + ' | DM: paste peer fingerprint or @alias above');
addSys('/alias · /g · /gleave · /gkick · /gmembers · /glist · /friend · /file · /info');
// Show system bots if available
try {
const botResp = await fetch(SERVER + '/v1/bot/list');
const botData = await botResp.json();
if (botData.ok && botData.bots && botData.bots.length > 0) {
addSys('');
addSys('Available bots:');
for (const b of botData.bots) {
addSys(' @' + b.name + ' — ' + b.description);
}
addSys('Message a bot: /peer @botname');
}
} catch(e) {}
const savedPeer = localStorage.getItem('wz-peer');
if (savedPeer) {
$peerInput.value = savedPeer;
}
connectWebSocket();
// Auto-join #ops if no peer/group set (create if needed)
if (!savedPeer) {
setTimeout(async () => {
try {
// Create #ops if it doesn't exist (ignore error if already exists)
await fetch(SERVER + '/v1/groups/create', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({name:'ops', creator: normFP(myFingerprint)}) });
// Join (no auth needed for join in current setup)
await fetch(SERVER + '/v1/groups/ops/join', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({fingerprint: normFP(myFingerprint)}) });
currentGroup = 'ops';
$peerInput.value = '#ops';
addSys('Welcome! You have been added to #ops');
} catch(e) { dbg('Auto-join #ops failed:', e); }
}, 500);
}
$input.focus();
}
// ── Group helpers ──
async function groupCreate(name) {
const resp = await fetch(SERVER + '/v1/groups/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, creator: normFP(myFingerprint) })
});
const data = await resp.json();
if (data.error) { addSys('Error: ' + data.error); return; }
addSys('Group "' + name + '" created. Join with /gjoin ' + name);
}
async function groupJoin(name) {
const resp = await fetch(SERVER + '/v1/groups/' + name + '/join', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fingerprint: normFP(myFingerprint) })
});
const data = await resp.json();
if (data.error) { addSys('Error: ' + data.error); return; }
addSys('Joined group "' + name + '" (' + data.members + ' members)');
}
async function showGroupMembers(groupName) {
try {
const resp = await fetch(SERVER + '/v1/groups/' + groupName + '/members');
const data = await resp.json();
if (data.members && data.members.length > 0) {
const online = data.members.filter(m => m.online).length;
addSys('Members of #' + groupName + ' (' + online + '/' + data.members.length + ' online):');
for (const m of data.members) {
const status = m.online ? '\u{1F7E2}' : '\u26AA';
const label = m.alias ? '@' + m.alias : m.fingerprint.slice(0, 16) + '...';
addSys(' ' + status + ' ' + label + (m.is_creator ? ' *' : ''));
}
}
} catch(e) { dbg('Failed to fetch members:', e); }
}
async function groupSwitch(name) {
// Auto-join
await groupJoin(name);
const resp = await fetch(SERVER + '/v1/groups/' + name);
const data = await resp.json();
if (data.error) { addSys('Error: ' + data.error); return; }
currentGroup = name;
$peerInput.value = '#' + name;
addSys('Switched to group "' + name + '" (' + data.count + ' members: ' + data.members.map(m => m.slice(0,8)).join(', ') + ')');
await showGroupMembers(name);
}
async function groupList() {
const resp = await fetch(SERVER + '/v1/groups');
const data = await resp.json();
if (!data.groups || data.groups.length === 0) { addSys('No groups'); return; }
for (const g of data.groups) {
addSys(' #' + g.name + ' (' + g.members + ' members)');
}
}
async function sendToGroup(groupName, text) {
// Get member list
const resp = await fetch(SERVER + '/v1/groups/' + groupName);
const data = await resp.json();
if (data.error) { addSys('Error: ' + data.error); return; }
const myFP = normFP(myFingerprint);
const members = data.members.filter(m => m !== myFP);
if (members.length === 0) { addSys('No other members in group'); return; }
// Encrypt for each member using WASM (same crypto as CLI)
const messages = [];
for (const memberFP of members) {
try {
const bundleBytes = await fetchPeerBundle(memberFP);
let wireBytes;
if (sessions[memberFP]) {
const sess = WasmSession.restore(sessions[memberFP].data);
wireBytes = sess.encrypt(wasmIdentity, text);
sessions[memberFP].data = sess.save();
} else {
const sess = WasmSession.initiate(wasmIdentity, bundleBytes);
wireBytes = sess.encrypt_key_exchange(wasmIdentity, bundleBytes, text);
sessions[memberFP] = { data: sess.save() };
}
messages.push({ to: memberFP, message: Array.from(wireBytes) });
} catch(e) {
// Skip members we can't encrypt for
}
}
// Save sessions
localStorage.setItem('wz-sessions', JSON.stringify(
Object.fromEntries(Object.entries(sessions).map(([k,v]) => [k, v.data]))
));
if (messages.length === 0) { addSys('No members to send to'); return; }
await fetch(SERVER + '/v1/groups/' + groupName + '/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ from: myFP, messages })
});
addMsg((myEthAddress ? myEthAddress.slice(0,12) + '...' : myFingerprint.slice(0,19)) + ' [' + groupName + ']', text, true, null);
}
// ── Send handler ──
async function doSend() {
const text = $input.value.trim();
$input.value = '';
$input.style.height = 'auto';
if (!text) return;
// Commands
if (text === '/info') {
const aliasResp = await fetch(SERVER + '/v1/alias/whois/' + normFP(myFingerprint));
const aliasData = await aliasResp.json();
const aliasStr = aliasData.alias ? ' (@' + aliasData.alias + ')' : '';
addSys('Fingerprint: ' + myFingerprint + aliasStr);
if (myEthAddress) addSys('ETH Address: ' + myEthAddress);
return;
}
if (text === '/clear') { $messages.innerHTML = ''; return; }
if (text === '/install') {
if (deferredInstall) { deferredInstall.prompt(); deferredInstall = null; }
else addSys('Install not available (already installed or not supported)');
return;
}
if (text === '/debug') { DEBUG = !DEBUG; addSys('Debug logging: ' + (DEBUG ? 'ON (check browser console)' : 'OFF')); return; }
if (text === '/reset') {
localStorage.clear();
addSys('localStorage cleared. Refresh the page to start fresh.');
return;
}
if (text === '/sessions') {
addSys('Sessions: ' + Object.keys(sessions).join(', '));
addSys('SPK secret: ' + (mySpkSecretHex ? mySpkSecretHex.slice(0,16) + '...' : 'NONE'));
return;
}
if (text === '/selftest') {
try {
const r = self_test();
addSys(r);
} catch(e) { addSys('Self-test FAILED: ' + e); }
return;
}
if (text === '/bundleinfo') {
try {
const r = debug_bundle_info(wasmIdentity);
addSys(r);
} catch(e) { addSys('Bundle info error: ' + e); }
return;
}
if (text === '/seed') {
addSys('Your recovery seed (keep secret!):');
addSys(wasmIdentity.mnemonic());
return;
}
if (text === '/quit') { window.close(); return; }
if (text === '/glist') { await groupList(); return; }
if (text === '/dm') { currentGroup = null; addSys('Switched to DM mode'); $peerInput.value = localStorage.getItem('wz-peer') || ''; return; }
if (text === '/aliases') {
const resp = await fetch(SERVER + '/v1/alias/list');
const data = await resp.json();
if (data.aliases.length === 0) { addSys('No aliases registered'); }
else { for (const a of data.aliases) addSys(' @' + a.alias + ' → ' + a.fingerprint.slice(0,16) + '...'); }
return;
}
if (text.startsWith('/alias ')) {
const name = text.slice(7).trim();
const resp = await fetch(SERVER + '/v1/alias/register', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ alias: name, fingerprint: normFP(myFingerprint) })
});
const data = await resp.json();
if (data.error) { addSys('Error: ' + data.error); }
else { addSys('Alias @' + data.alias + ' registered. Recovery key: ' + (data.recovery_key || 'N/A')); }
return;
}
if (text === '/unalias') {
const resp = await fetch(SERVER + '/v1/alias/unregister', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fingerprint: normFP(myFingerprint) })
});
const data = await resp.json();
if (data.error) addSys('Error: ' + data.error);
else addSys('Alias removed');
return;
}
if (text.startsWith('/admin-unalias ')) {
const parts = text.slice(15).trim().split(' ');
if (parts.length < 2) { addSys('Usage: /admin-unalias <alias> <admin-password>'); return; }
const resp = await fetch(SERVER + '/v1/alias/admin-remove', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ alias: parts[0], admin_password: parts.slice(1).join(' ') })
});
const data = await resp.json();
if (data.error) addSys('Error: ' + data.error);
else addSys('Alias @' + parts[0] + ' removed by admin');
return;
}
if (text.startsWith('/r ') || text.startsWith('/reply ')) {
const replyText = text.startsWith('/r ') ? text.slice(3) : text.slice(7);
if (!lastDmPeer) { addSys('No one to reply to'); return; }
$peerInput.value = lastDmPeer;
try {
await sendEncrypted(lastDmPeer, replyText.trim());
addMsg((myEthAddress ? myEthAddress.slice(0,12) + '...' : myFingerprint.slice(0,19)), replyText.trim(), true);
} catch(e) { addSys('Reply failed: ' + e.message); }
return;
}
if (text.startsWith('/p ') || text.startsWith('/peer ')) {
let val = text.startsWith('/p ') ? text.slice(3).trim() : text.slice(6).trim();
if (val.startsWith('@') || val.startsWith('0x') || val.startsWith('0X')) {
const endpoint = val.startsWith('@') ? '/v1/alias/resolve/' + val.slice(1) : '/v1/resolve/' + val;
const resp = await fetch(SERVER + endpoint);
const data = await resp.json();
if (data.error) { addSys('Cannot resolve ' + val + ': ' + data.error); return; }
$peerInput.value = data.fingerprint;
addSys(val + ' \u2192 ' + data.fingerprint.slice(0,16) + '...');
} else {
$peerInput.value = val;
}
currentGroup = null;
localStorage.setItem('wz-peer', $peerInput.value);
addSys('Peer set to ' + $peerInput.value.slice(0,16) + '...');
return;
}
if (text.startsWith('/gcreate ')) { await groupCreate(text.slice(9).trim()); return; }
if (text.startsWith('/gjoin ')) { await groupJoin(text.slice(7).trim()); return; }
if (text === '/gleave') {
if (!currentGroup) { addSys('Not in a group'); return; }
const r = await fetch(SERVER+'/v1/groups/'+currentGroup+'/leave',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({fingerprint:normFP(myFingerprint)})});
const d = await r.json();
if (d.error) addSys('Error: '+d.error); else { addSys('Left group "'+currentGroup+'"'); currentGroup=null; $peerInput.value=''; }
return;
}
if (text.startsWith('/gkick ')) {
if (!currentGroup) { addSys('Not in a group'); return; }
const target = text.slice(7).trim();
const r = await fetch(SERVER+'/v1/groups/'+currentGroup+'/kick',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({fingerprint:normFP(myFingerprint),target:target})});
const d = await r.json();
if (d.error) addSys('Error: '+d.error); else addSys('Kicked '+d.kicked);
return;
}
if (text === '/gmembers') {
if (!currentGroup) { addSys('Not in a group'); return; }
const r = await fetch(SERVER+'/v1/groups/'+currentGroup+'/members');
const d = await r.json();
if (d.error) { addSys('Error: '+d.error); return; }
addSys('Members of #'+currentGroup+':');
for (const m of d.members) {
const a = m.alias ? '@'+m.alias : m.fingerprint.slice(0,12)+'...';
addSys(' '+a+(m.is_creator?' ★':''));
}
return;
}
if (text === '/friend' || text === '/friends') {
try {
const resp = await fetch(SERVER + '/v1/friends', {
headers: { 'Authorization': 'Bearer ' + normFP(myFingerprint) }
});
const data = await resp.json();
if (data.data) {
addSys('Friends:');
addSys('(encrypted friend list stored on server -- use TUI for full friend management)');
} else {
addSys('No friends yet. Use /friend <address> to add.');
}
} catch(e) { addSys('Error: ' + e.message); }
return;
}
if (text.startsWith('/friend ')) {
const addr = text.slice(8).trim();
if (!addr) { addSys('Usage: /friend <address>'); return; }
addSys('Friend management requires TUI client (encrypted locally). Use warzone-client for full support.');
addSys('Hint: /friend in TUI to manage friends with E2E encryption.');
return;
}
if (text.startsWith('/unfriend ')) {
addSys('Friend management requires TUI client (encrypted locally).');
return;
}
if (text.startsWith('/g ')) { await groupSwitch(text.slice(3).trim()); return; }
// Send to group or DM
if (currentGroup) {
try {
await sendToGroup(currentGroup, text);
} catch(e) {
addSys('Group send failed: ' + e.message);
}
return;
}
// DM — resolve @alias if needed
let peer = $peerInput.value.trim();
if (!peer || peer.startsWith('#')) { addSys('Set a peer fingerprint/@alias or use /g <group>'); return; }
if (peer.startsWith('@') || peer.startsWith('0x') || peer.startsWith('0X')) {
const endpoint = peer.startsWith('@') ? '/v1/alias/resolve/' + peer.slice(1) : '/v1/resolve/' + peer;
const resp = await fetch(SERVER + endpoint);
const data = await resp.json();
if (data.error) { addSys('Cannot resolve ' + peer + ': ' + data.error); return; }
peer = data.fingerprint;
}
localStorage.setItem('wz-peer', $peerInput.value.trim());
// Check if peer is a bot — send plaintext instead of E2E
let isBotPeer = false;
const peerRaw = $peerInput.value.trim();
// Check by alias name if peer was set via @alias
if (peerRaw.startsWith('@')) {
const aname = peerRaw.slice(1).toLowerCase();
isBotPeer = aname.endsWith('bot') || aname.endsWith('_bot') || aname === 'botfather';
}
// Also check by fingerprint reverse-lookup
if (!isBotPeer) {
try {
const wr = await fetch(SERVER + '/v1/alias/whois/' + peer);
const wd = await wr.json();
if (wd.alias && (wd.alias.endsWith('bot') || wd.alias.endsWith('Bot') || wd.alias.endsWith('_bot') || wd.alias === 'botfather')) isBotPeer = true;
} catch(e) {}
}
if (isBotPeer) {
const msgId = crypto.randomUUID ? crypto.randomUUID() : Date.now().toString();
const botMsg = {type:'bot_message',id:msgId,from:normFP(myFingerprint),from_name:myEthAddress||myFingerprint.slice(0,19),text:text,timestamp:Math.floor(Date.now()/1000)};
await fetch(SERVER+'/v1/messages/send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({to:normFP(peer),from:normFP(myFingerprint),message:Array.from(new TextEncoder().encode(JSON.stringify(botMsg)))})});
addMsg((myEthAddress ? myEthAddress.slice(0,12)+'...' : myFingerprint.slice(0,19)), text, true, msgId);
return;
}
try {
const msgId = await sendEncrypted(peer, text);
sentMsgReceipts[msgId] = { status: 'sent', el: null };
addMsg((myEthAddress ? myEthAddress.slice(0,12) + '...' : myFingerprint.slice(0,19)), text, true, msgId);
} catch(e) {
addSys('Send failed: ' + e.message);
}
}
// Keyboard
$input.onkeydown = function(e) {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); doSend(); }
};
$input.addEventListener('input', function() {
this.style.height = 'auto';
this.style.height = Math.min(this.scrollHeight, 120) + 'px';
});
// Wire up buttons (module scope can't use onclick in HTML)
document.getElementById('btn-generate').onclick = () => doGenerate();
document.getElementById('btn-show-recover').onclick = () => document.getElementById('recover-area').style.display = 'block';
document.getElementById('btn-recover').onclick = () => doRecover();
document.getElementById('btn-enter').onclick = () => enterChat();
document.getElementById('send-btn').onclick = () => doSend();
document.getElementById('messages').onclick = () => { if (!window.getSelection().toString()) document.getElementById('msg-input').focus(); };
document.getElementById('hdr-eth').onclick = function() {
if (myEthAddress) navigator.clipboard.writeText(myEthAddress).then(() => addSys('Copied ETH address'));
};
document.getElementById('file-input').onchange = async function() {
if (!this.files.length) return;
const file = this.files[0];
if (file.size > 10 * 1024 * 1024) { addSys('File too large (max 10MB)'); this.value=''; return; }
const peer = $peerInput.value.trim();
if (!peer || peer.startsWith('#')) { addSys('Set a peer first'); this.value=''; return; }
const fp = normFP(peer);
const data = new Uint8Array(await file.arrayBuffer());
const CHUNK_SIZE = 65536;
const totalChunks = Math.ceil(data.length / CHUNK_SIZE);
const fileId = crypto.randomUUID();
// SHA-256 hash
const hashBuf = await crypto.subtle.digest('SHA-256', data);
const sha256 = Array.from(new Uint8Array(hashBuf)).map(b => b.toString(16).padStart(2,'0')).join('');
addSys('Sending "' + file.name + '" (' + formatSize(data.length) + ', ' + totalChunks + ' chunks)...');
// Send header via HTTP (file messages are large, WS might choke)
const headerWire = { type: 'file_header', id: fileId, sender: normFP(myFingerprint), filename: file.name, file_size: data.length, total_chunks: totalChunks, sha256: sha256 };
// For now send as raw JSON message (server treats as opaque bytes)
const headerBytes = new TextEncoder().encode(JSON.stringify(headerWire));
await fetch(SERVER + '/v1/messages/send', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ to: fp, from: normFP(myFingerprint), message: Array.from(headerBytes) })
});
for (let i = 0; i < totalChunks; i++) {
const chunk = data.slice(i * CHUNK_SIZE, (i+1) * CHUNK_SIZE);
const chunkWire = { type: 'file_chunk', id: fileId, sender: normFP(myFingerprint), filename: file.name, chunk_index: i, total_chunks: totalChunks, data: Array.from(chunk).map(b => b.toString(16).padStart(2,'0')).join('') };
const chunkBytes = new TextEncoder().encode(JSON.stringify(chunkWire));
await fetch(SERVER + '/v1/messages/send', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ to: fp, from: normFP(myFingerprint), message: Array.from(chunkBytes) })
});
addSys('Sent chunk ' + (i+1) + '/' + totalChunks);
}
addSys('File "' + file.name + '" sent');
this.value = '';
};
// PWA: service worker + install prompt
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(e => dbg('SW failed:', e));
}
let deferredInstall = null;
window.addEventListener('beforeinstallprompt', e => {
e.preventDefault();
deferredInstall = e;
addSys('Tip: install as app for fullscreen + notifications. Type /install');
});
// Initialize WASM and auto-load
(async function() {
try {
await initWasm();
if (loadSavedIdentity()) {
enterChat();
}
} catch(e) {
addSys('Failed to load WASM crypto: ' + e.message);
}
})();
</script>
</body>
</html>"##;