const http = require('http'); const fs = require('fs'); const path = require('path'); const { sendAPNs } = require('./apns'); const PORT = 3004; const DATA_DIR = path.join(require('os').homedir(), '.shenyan-bridge'); const INBOX = path.join(DATA_DIR, 'inbox.jsonl'); const OUTBOX = path.join(DATA_DIR, 'outbox.jsonl'); const PUSHBOX = path.join(DATA_DIR, 'pushbox.jsonl'); const NOTES = path.join(DATA_DIR, 'notes.jsonl'); const DEVICE_TOKEN_FILE = path.join(DATA_DIR, 'device-token.txt'); // ensure data dir and files if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true }); [INBOX, OUTBOX, PUSHBOX, NOTES].forEach(f => { if (!fs.existsSync(f)) fs.writeFileSync(f, ''); }); function json(res, data, code = 200) { res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(data)); } function readJSONL(file) { try { const raw = fs.readFileSync(file, 'utf-8').trim(); if (!raw) return []; return raw.split('\n').map(l => JSON.parse(l)); } catch { return []; } } function appendJSONL(file, obj) { fs.appendFileSync(file, JSON.stringify(obj) + '\n', 'utf-8'); } function writeJSONL(file, arr) { const lines = arr.map(e => JSON.stringify(e)).join('\n'); fs.writeFileSync(file, lines + (arr.length > 0 ? '\n' : ''), 'utf-8'); } function getNextId(file) { const entries = readJSONL(file); return entries.length > 0 ? Math.max(...entries.map(e => e.id)) + 1 : 1; } function getMacIP() { try { const c = require('child_process'); return c.execSync('ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1').toString().trim(); } catch { return 'unknown'; } } const server = http.createServer((req, res) => { res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } // ---- app sends message to claude ---- if (req.method === 'POST' && req.url === '/send') { let body = ''; req.on('data', c => body += c); req.on('end', () => { try { const { text } = JSON.parse(body); if (!text || !text.trim()) return json(res, { error: 'empty' }, 400); const entry = { id: getNextId(INBOX), text: text.trim(), time: Date.now(), status: 'pending' }; appendJSONL(INBOX, entry); json(res, { ok: true, id: entry.id }); } catch (e) { json(res, { error: e.message }, 400); } }); return; } // ---- app polls for claude's responses ---- if (req.method === 'GET' && req.url.startsWith('/poll')) { const url = new URL(req.url, `http://localhost:${PORT}`); const since = parseInt(url.searchParams.get('since') || '0'); const type = url.searchParams.get('type') || 'response'; // 'response' or 'push' if (type === 'push') { const entries = readJSONL(PUSHBOX).filter(e => e.id > since); json(res, entries); } else { const entries = readJSONL(OUTBOX).filter(e => e.id > since); json(res, entries); } return; } // ---- claude writes a response back ---- if (req.method === 'POST' && req.url === '/respond') { let body = ''; req.on('data', c => body += c); req.on('end', () => { try { const { replyTo, text } = JSON.parse(body); if (!text) return json(res, { error: 'empty' }, 400); const entry = { id: getNextId(OUTBOX), replyTo, text, time: Date.now() }; appendJSONL(OUTBOX, entry); json(res, { ok: true, id: entry.id }); } catch (e) { json(res, { error: e.message }, 400); } }); return; } // ---- claude proactively pushes to app ---- if (req.method === 'POST' && req.url === '/push') { let body = ''; req.on('data', c => body += c); req.on('end', async () => { try { const { title, body: msgBody } = JSON.parse(body); if (!title && !msgBody) return json(res, { error: 'empty' }, 400); const entry = { id: getNextId(PUSHBOX), title: title || '', body: msgBody || '', time: Date.now() }; appendJSONL(PUSHBOX, entry); // try APNs if device token is registered let apnsOk = false; try { if (fs.existsSync(DEVICE_TOKEN_FILE)) { const token = fs.readFileSync(DEVICE_TOKEN_FILE, 'utf-8').trim(); if (token) { await sendAPNs(token, title, msgBody); apnsOk = true; } } } catch (e) { console.error('APNs failed:', e.message); } json(res, { ok: true, id: entry.id, apns: apnsOk }); } catch (e) { json(res, { error: e.message }, 400); } }); return; } // ---- app registers its device token ---- if (req.method === 'POST' && req.url === '/register-device') { let body = ''; req.on('data', c => body += c); req.on('end', () => { try { const { token } = JSON.parse(body); if (!token) return json(res, { error: 'no token' }, 400); fs.writeFileSync(DEVICE_TOKEN_FILE, token.trim(), 'utf-8'); console.log('device registered:', token.slice(0, 16) + '...'); json(res, { ok: true }); } catch (e) { json(res, { error: e.message }, 400); } }); return; } // ---- clear push messages that have been read ---- if (req.method === 'POST' && req.url === '/push/clear') { let body = ''; req.on('data', c => body += c); req.on('end', () => { try { const { ids } = JSON.parse(body); if (!Array.isArray(ids)) return json(res, { error: 'bad ids' }, 400); const entries = readJSONL(PUSHBOX); const idSet = new Set(ids); writeJSONL(PUSHBOX, entries.filter(e => !idSet.has(e.id))); json(res, { ok: true }); } catch (e) { json(res, { error: e.message }, 400); } }); return; } // ---- app knocks on the door ---- if (req.method === 'POST' && req.url === '/knock') { const entry = { id: getNextId(INBOX), type: 'knock', text: '—— 叩门 ——', time: Date.now(), status: 'pending' }; appendJSONL(INBOX, entry); json(res, { ok: true }); return; } // ---- get full conversation history ---- if (req.method === 'GET' && req.url === '/history') { const inbox = readJSONL(INBOX).filter(e => e.type !== 'knock'); const outbox = readJSONL(OUTBOX); const msgs = []; for (const e of inbox) { if (e.text) msgs.push({ id: `u-${e.id}`, role: 'user', text: e.text, time: e.time }); } for (const e of outbox) { if (e.text) msgs.push({ id: `s-${e.id}`, role: 'shenyan', text: e.text, time: e.time }); } msgs.sort((a, b) => a.time - b.time); json(res, msgs); return; } // ---- fridge notes ---- if (req.method === 'GET' && req.url === '/notes') { json(res, readJSONL(NOTES)); return; } if (req.method === 'POST' && req.url === '/notes/add') { let body = ''; req.on('data', c => body += c); req.on('end', () => { try { const { title, body: noteBody } = JSON.parse(body); if (!title && !noteBody) return json(res, { error: 'empty' }, 400); const entry = { id: getNextId(NOTES), title: title || '', body: noteBody || '', time: Date.now(), replies: [] }; appendJSONL(NOTES, entry); json(res, { ok: true, id: entry.id }); } catch (e) { json(res, { error: e.message }, 400); } }); return; } if (req.method === 'POST' && req.url === '/notes/reply') { let body = ''; req.on('data', c => body += c); req.on('end', () => { try { const { noteId, text } = JSON.parse(body); if (!text) return json(res, { error: 'empty' }, 400); const notes = readJSONL(NOTES); const idx = notes.findIndex(n => n.id === noteId); if (idx < 0) return json(res, { error: 'not found' }, 404); notes[idx].replies = notes[idx].replies || []; notes[idx].replies.push({ text, time: Date.now() }); writeJSONL(NOTES, notes); // also notify inbox so 沈晏 sees the reply appendJSONL(INBOX, { id: getNextId(INBOX), type: 'fridge_reply', text: `[冰箱贴回复] ${notes[idx].title}: ${text}`, time: Date.now(), status: 'pending' }); json(res, { ok: true }); } catch (e) { json(res, { error: e.message }, 400); } }); return; } res.writeHead(404); res.end('not found'); }); server.listen(PORT, '0.0.0.0', () => { console.log(`shenyan-bridge :${PORT} | mac ip: ${getMacIP()}`); console.log(` POST /send - app -> claude`); console.log(` GET /poll - app polls responses / pushes`); console.log(` POST /respond - claude -> app`); console.log(` POST /push - claude proactive notify`); });