Files
mao1/shenyan-app/bridge-local/server.js
fyah 2a053ab88e 更新 shenyan-app: HealthKit+WeatherKit+冰箱贴+pare+三区布局
- HealthKitBridge.m: 自定义原生模块,心率/步数/睡眠采集
- WeatherKitBridge.swift/m: 天气模块,当前+每日+每小时预报
- pare.py: 身体数据陡度监控,阈值告警→inbox+冰箱贴
- cyberboss: 5-20分钟随机唤醒,pare→decide→push
- 冰箱贴: 纸质感卡片,黑字多级透明度,独立输入框
- 首页三区无视觉布局: 左门/中记录/右冰箱
- 端口3003→3004(VS Code占用)
- 聊天屏锚定底部滚动

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 19:14:32 +08:00

246 lines
8.6 KiB
JavaScript

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`);
});