Files
LogiFlowandClaude 6fc38d335b feat: serveur MCP, heures calmes, mode mission, widget glanceable, barge-in (v2.4.0) (#14)
Serveur MCP local
- Endpoint /mcp (JSON-RPC 2.0, Streamable HTTP, réponses JSON) sur le serveur
  webhook : initialize, tools/list, tools/call, ping. Même secret que le
  webhook. 14 outils : capture_screen (image MCP), lock_session, open_app,
  open_url, media_key, clipboard_get/set, find_files, speak_text,
  notify_user, set_hud_state, get_app_status, get_conversation_history,
  show_message. Les outils UI transitent par IPC vers le renderer.
- Les mêmes actions système sont proposées au modèle en chat completions.

Notifications
- Heures calmes (plage horaire, franchissement de minuit), mots prioritaires,
  échecs de crons toujours lus, résumé vocal des messages entrants (n premières
  phrases), thème nuit automatique, compteur de non-lus.

Conversation
- Mode mission : bouton dans la barre de commande, second modèle Hermes
  (hermes.missionModel) pour les tâches longues.
- Barge-in : la parole de l'utilisateur coupe la voix ; seuil d'énergie et
  ratio relevés pendant la synthèse pour ignorer l'écho.

Widget compact
- Bandeau glanceable : état, dernière phrase, non-lus, indicateurs heures
  calmes et mission.

Tests : 28 tests unitaires (heures calmes, priorité, résumé), e2e Electron
sous Xvfb : MCP initialize/tools/list/tools/call (presse-papiers, capture en
image, état et message via le renderer), bandeau compact, aucune erreur.


Claude-Session: https://claude.ai/code/session_017Wn5VX9HNbJ7N54hR24u9Y

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-04 00:47:34 +02:00

138 lines
5.2 KiB
TypeScript

/**
* Local webhook server: Hermes (cron deliveries, gateway mirrors, scripts) pushes JSON here
* and EveFlow displays / speaks the result. POST /eveflow/hook, GET /health.
*/
import http from 'node:http';
import { timingSafeEqual } from 'node:crypto';
import type { BrowserWindow } from 'electron';
import { IPC, type WebhookStatus } from '../shared/ipc';
import { normalizeHermesPush } from '../shared/hermesPush';
import { log } from './logger';
import { handleMcpHttp, MCP_PATH } from './mcp';
export const WEBHOOK_PATH = '/eveflow/hook';
const MAX_BODY_BYTES = 2 * 1024 * 1024;
export interface WebhookOptions {
port: number;
secret?: string;
host?: string;
}
let server: http.Server | null = null;
let status: WebhookStatus = { listening: false, port: 7842, path: WEBHOOK_PATH, secretConfigured: false };
function secretMatches(provided: string | undefined, expected: string): boolean {
if (!provided) return false;
const a = Buffer.from(provided);
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
}
export function getWebhookStatus(): WebhookStatus {
return status;
}
export function stopWebhookServer(): Promise<void> {
return new Promise((resolve) => {
if (!server) return resolve();
const current = server;
server = null;
status = { ...status, listening: false };
current.closeAllConnections();
current.close(() => resolve());
});
}
export async function startWebhookServer(getWindow: () => BrowserWindow | null, options: WebhookOptions): Promise<WebhookStatus> {
await stopWebhookServer();
const { port, secret } = options;
// Without a shared secret the hook only listens on loopback: anyone on the LAN could otherwise inject messages.
const host = options.host ?? (secret ? '0.0.0.0' : '127.0.0.1');
if (host !== '127.0.0.1' && !secret) log('WARN', 'webhook', `listening on ${host} without a secret`);
server = http.createServer((req, res) => {
const json = (code: number, payload: unknown) => {
res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
res.end(JSON.stringify(payload));
};
if (req.method === 'GET' && (req.url === '/health' || req.url === '/')) {
return json(200, { ok: true, app: 'eveflow', path: WEBHOOK_PATH, mcp: MCP_PATH });
}
const isMcp = !!req.url && (req.url === MCP_PATH || req.url.startsWith(`${MCP_PATH}?`));
if (!isMcp && (req.method !== 'POST' || !req.url?.startsWith(WEBHOOK_PATH))) {
return json(404, { error: 'Not Found' });
}
if (secret) {
const auth = req.headers.authorization?.replace(/^Bearer\s+/i, '');
const custom = req.headers['x-eveflow-secret'];
const provided = typeof custom === 'string' ? custom : auth;
if (!secretMatches(provided, secret)) {
log('WARN', 'webhook', `rejected push from ${req.socket.remoteAddress}: bad secret`);
return json(401, { error: 'Unauthorized' });
}
}
const chunks: Buffer[] = [];
let bytes = 0;
let tooLarge = false;
req.on('data', (chunk: Buffer) => {
if (tooLarge) return;
bytes += chunk.length;
if (bytes > MAX_BODY_BYTES) {
tooLarge = true;
res.writeHead(413, { 'Content-Type': 'application/json', Connection: 'close' });
res.end(JSON.stringify({ error: 'Payload too large' }), () => req.socket.destroy());
return;
}
chunks.push(chunk);
});
req.on('end', () => {
if (tooLarge) return;
// Concatenate before decoding so multibyte UTF-8 (accents, emoji) split across chunks stays intact.
const body = Buffer.concat(chunks).toString('utf8');
if (isMcp) {
handleMcpHttp(getWindow(), req, res, body).catch((err: Error) => {
log('ERROR', 'mcp', err.message);
if (!res.headersSent) json(500, { error: err.message });
});
return;
}
try {
const raw: unknown = body.trim() ? JSON.parse(body) : {};
const events = normalizeHermesPush(raw);
log('INFO', 'webhook', `${events.length} event(s) from ${events[0]?.source ?? '?'} (${req.socket.remoteAddress})`);
const win = getWindow();
if (win && !win.isDestroyed()) {
for (const event of events) win.webContents.send(IPC.hermesPush, event);
}
json(200, { ok: true, events: events.length });
} catch (err) {
log('WARN', 'webhook', `invalid JSON payload: ${(err as Error).message}`);
json(400, { error: 'Invalid JSON' });
}
});
});
return new Promise((resolve) => {
const instance = server!;
instance.on('error', (err: NodeJS.ErrnoException) => {
log('ERROR', 'webhook', `server error: ${err.message}`);
status = { listening: false, port, path: WEBHOOK_PATH, secretConfigured: !!secret, error: err.message };
if (server === instance) server = null;
resolve(status);
});
if (!Number.isInteger(port) || port < 1 || port > 65535) {
instance.emit('error', new Error(`port invalide : ${port}`));
return;
}
instance.listen(port, host, () => {
log('INFO', 'webhook', `listening on http://${host}:${port}${WEBHOOK_PATH}`);
status = { listening: true, port, path: WEBHOOK_PATH, secretConfigured: !!secret };
resolve(status);
});
});
}