feat: friend list, bot API, ETH addressing, deep links, docs overhaul

Tier 1 — New features:
- E2E encrypted friend list: server stores opaque blob (POST/GET /v1/friends),
  protocol-level encrypt/decrypt with HKDF-derived key, 4 tests
- Telegram Bot API compatibility: /bot/register, /bot/:token/getUpdates,
  sendMessage, getMe — TG-style Update objects with proper message mapping
- ETH address resolution: GET /v1/resolve/:address (0x.../alias/@.../fp),
  bidirectional ETH↔fp mapping stored on key registration
- Seed recovery: /seed command in TUI + web client
- URL deep links: /message/@alias, /message/0xABC, /group/#ops
- Group members with online status in GET /groups/:name/members

Tier 2 — UX polish:
- TUI: /friend, /friend <addr>, /unfriend <addr> with presence checking
- Web: friend commands, showGroupMembers() on group join
- Web: ETH address in header, clickable addresses (click→peer or copy)
- Bot: full WireMessage→TG Update mapping (encrypted base64, CallSignal,
  FileHeader, bot_message JSON)

Documentation:
- USAGE.md rewritten: complete user guide with all commands
- SERVER.md rewritten: full admin guide with all 50+ endpoints
- CLIENT.md rewritten: architecture, commands, keyboard, storage
- LLM_HELP.md created: 1083-word token-optimized reference for helper LLM

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-03-29 07:31:54 +04:00
parent dbf5d136cf
commit 7b72f7cba5
15 changed files with 2181 additions and 1023 deletions

View File

@@ -171,6 +171,9 @@ const WEB_HTML: &str = r##"<!DOCTYPE html>
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; }
@@ -207,6 +210,7 @@ const WEB_HTML: &str = r##"<!DOCTYPE html>
<div id="chat" class="screen">
<div id="chat-header">
<span class="tag tag-fp" id="hdr-fp"></span>
<span class="tag" id="hdr-eth" style="background:#1a1a3e;color:#4fc3f7;font-size:0.8em;cursor:pointer" title=""></span>
<span>→</span>
<input id="peer-input" placeholder="Paste peer fingerprint..." autocomplete="off">
<span class="tag-server" id="hdr-server"></span>
@@ -230,6 +234,7 @@ 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
@@ -298,6 +303,33 @@ 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() {
@@ -442,6 +474,43 @@ async function sendEncrypted(peerFP, plaintext) {
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:';
@@ -454,6 +523,7 @@ function connectWebSocket() {
ws.onopen = () => {
dbg('WebSocket connected');
addSys('Real-time connection established');
handleDeepLink();
};
ws.onmessage = async (event) => {
@@ -669,7 +739,11 @@ function addMsg(from, text, isSelf, messageId) {
const status = (sentMsgReceipts[messageId] && sentMsgReceipts[messageId].status) || 'sent';
receiptHtml = ' <span class="receipt" style="color:' + receiptColor(status) + '"> ' + receiptIndicator(status) + '</span>';
}
d.innerHTML = '<span class="ts">' + ts() + '</span> ' + lock + '<span style="color:' + color + ';font-weight:bold">' + esc(from) + '</span>: ' + esc(text) + receiptHtml;
d.innerHTML = '<span class="ts">' + ts() + '</span> ' + lock + '<span style="color:' + color + ';font-weight:bold">' + makeAddressClickable(esc(from)) + '</span>: ' + makeAddressClickable(esc(text)) + 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
@@ -724,8 +798,21 @@ async function enterChat() {
await registerKey();
addSys('Identity loaded: ' + myFingerprint);
addSys('Key registered with server');
// Fetch ETH address from server
try {
const resolveResp = await fetch(SERVER + '/v1/resolve/' + normFP(myFingerprint));
const resolveData = await resolveResp.json();
if (resolveData.eth_address) {
myEthAddress = resolveData.eth_address;
addSys('ETH: ' + myEthAddress);
document.getElementById('hdr-eth').textContent = myEthAddress.slice(0, 10) + '...';
document.getElementById('hdr-eth').title = myEthAddress;
}
} catch(e) { dbg('ETH resolve failed:', e); }
addSys('v' + VERSION + ' | DM: paste peer fingerprint or @alias above');
addSys('/alias · /g · /gleave · /gkick · /gmembers · /glist · /file · /info');
addSys('/alias · /g · /gleave · /gkick · /gmembers · /glist · /friend · /file · /info');
const savedPeer = localStorage.getItem('wz-peer');
if (savedPeer) $peerInput.value = savedPeer;
@@ -758,6 +845,22 @@ async function groupJoin(name) {
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);
@@ -767,6 +870,7 @@ async function groupSwitch(name) {
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() {
@@ -838,6 +942,7 @@ async function doSend() {
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; }
@@ -871,6 +976,11 @@ async function doSend() {
} 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; }
@@ -970,6 +1080,32 @@ async function doSend() {
}
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
@@ -1021,6 +1157,9 @@ document.getElementById('btn-show-recover').onclick = () => document.getElementB
document.getElementById('btn-recover').onclick = () => doRecover();
document.getElementById('btn-enter').onclick = () => enterChat();
document.getElementById('send-btn').onclick = () => doSend();
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];