Files
crm-ai/bridge/src/server.js
T

280 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
const http = require('http');
const { loadConfig } = require('./config');
const { createAuther } = require('./auth');
const { createTools, resolveSiteProfile } = require('./tools');
const { createIdentity } = require('./identity');
const { createLibreDeskWebhook } = require('./libredesk-webhook');
const { createAgent } = require('./agent');
const { createLLM } = require('./llm');
const { createReranker } = require('./reranker');
const { createVerification } = require('./verification');
const { createLibreDeskClient } = require('./libredesk');
const { createRetriever } = require('./rag');
const prom = require('./prom');
const { ToolError } = require('./errors');
const MAX_BODY_BYTES = 256 * 1024;
function readBody(req) {
return new Promise((resolve, reject) => {
let size = 0;
const chunks = [];
req.on('data', (chunk) => {
size += chunk.length;
if (size > MAX_BODY_BYTES) {
reject(new ToolError(413, 'payload_too_large', 'request body too large'));
req.destroy();
return;
}
chunks.push(chunk);
});
req.on('end', () => {
const raw = Buffer.concat(chunks).toString('utf8');
if (!raw) return resolve({});
try {
resolve(JSON.parse(raw));
} catch {
reject(new ToolError(400, 'invalid_json', 'request body must be valid JSON'));
}
});
req.on('error', reject);
});
}
function readRawBody(req) {
return new Promise((resolve, reject) => {
let size = 0;
const chunks = [];
req.on('data', (chunk) => {
size += chunk.length;
if (size > MAX_BODY_BYTES) {
reject(new ToolError(413, 'payload_too_large', 'request body too large'));
req.destroy();
return;
}
chunks.push(chunk);
});
req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('error', reject);
});
}
function sendJson(res, status, body, extraHeaders = {}) {
const payload = JSON.stringify(body);
res.writeHead(status, {
'content-type': 'application/json; charset=utf-8',
'content-length': Buffer.byteLength(payload),
...extraHeaders,
});
res.end(payload);
}
/** LibreDesk 在工具请求里注入的身份头;服务端必须以此为准,不能信任模型参数。 */
function contextFromHeaders(headers = {}) {
const h = (name) => String(headers[name] || '').trim();
return {
contactId: h('x-libredesk-contact-id'),
email: h('x-libredesk-contact-email').toLowerCase(),
externalId: h('x-libredesk-contact-external-id'),
contactType: h('x-libredesk-contact-type'),
conversationUuid: h('x-libredesk-conversation-uuid'),
inboxId: h('x-libredesk-inbox-id'),
verified: h('x-libredesk-contact-verified').toLowerCase() === 'true',
};
}
function createServer(options = {}) {
const cfg = options.config || loadConfig();
const auther = createAuther(cfg.toolsSharedKey);
const verification = options.verification || createVerification(cfg);
const client = options.client || createLibreDeskClient(cfg);
const retriever = options.retriever || (cfg.ragPgUrl ? createRetriever(cfg) : null);
const llm = options.llm || createLLM(cfg);
const reranker = options.reranker || createReranker(llm);
const tools = options.tools || createTools(cfg, { verification, retriever, client, reranker });
const identity = options.identity || createIdentity(cfg);
const agent = options.agent || createAgent(cfg, { verification, client, retriever, reranker, llm });
const libredeskWebhook = options.libredeskWebhook || createLibreDeskWebhook(cfg, { agent });
const byName = new Map(tools.map((t) => [t.name, t]));
const startedAt = Date.now();
// 站点来源白名单(IDENTITY_CORS_ORIGIN,逗号分隔;* 放行全部)
const allowedOrigins = String(cfg.identityCorsOrigin || '').split(',').map((s) => s.trim()).filter(Boolean);
function corsHeadersFor(origin) {
if (!origin) return {};
if (allowedOrigins.includes('*')) return { 'access-control-allow-origin': '*' };
if (allowedOrigins.includes(origin)) return { 'access-control-allow-origin': origin };
return {};
}
const server = http.createServer(async (req, res) => {
const url = (req.url || '').split('?')[0];
try {
if (req.method === 'GET' && url === '/healthz') {
return sendJson(res, 200, {
ok: true,
service: 'crm-tools',
uptime_sec: Math.round((Date.now() - startedAt) / 1000),
tools: [...byName.keys()],
verification_bypass: cfg.devBypassVerification,
sites: Object.keys((cfg.sites && cfg.sites.profiles) || {}),
});
}
// GET /metrics — Prometheus 文本指标(默认仅内网/LAN 暴露)
if (req.method === 'GET' && url === '/metrics') {
const body = prom.render();
res.writeHead(200, { 'content-type': 'text/plain; version=0.0.4; charset=utf-8', 'content-length': Buffer.byteLength(body) });
res.end(body);
return;
}
// POST /libredesk/webhook — 方案 A:LibreDesk 消息事件进入 bridge(HMAC 验签)
if (req.method === 'POST' && url === '/libredesk/webhook') {
const raw = await readRawBody(req);
const signature = req.headers['x-libredesk-signature'];
if (!libredeskWebhook.verifySignature(raw, cfg.libredeskWebhookSecret, signature)) {
return sendJson(res, 401, { ok: false, error: 'invalid_signature' });
}
let body = null;
try { body = raw.length ? JSON.parse(raw.toString('utf8')) : null; } catch { /* ignore */ }
// 先快速 ack,LibreDesk webhook 有 5s 超时且无重试
sendJson(res, 200, { ok: true });
setImmediate(() => {
libredeskWebhook.handle(body).catch((err) => {
console.error(JSON.stringify({ ts: new Date().toISOString(), webhook: 'handle_error', message: err.message }));
});
});
return;
}
// POST /identity — 站点登录态换 LibreDesk widget JWT(需 CORS 白名单)
if (url === '/identity') {
const origin = req.headers.origin;
const cors = corsHeadersFor(origin);
const isAllowed = Object.keys(cors).length > 0;
const preflightHeaders = {
...cors,
'access-control-allow-methods': 'POST, OPTIONS',
'access-control-allow-headers': 'Content-Type, Authorization',
'access-control-max-age': '600',
};
if (req.method === 'OPTIONS') {
if (!isAllowed) return sendJson(res, 403, { ok: false, error: 'origin_not_allowed' });
res.writeHead(204, preflightHeaders);
return res.end();
}
if (!isAllowed) return sendJson(res, 403, { ok: false, error: 'origin_not_allowed' });
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method_not_allowed' }, cors);
try {
const body = await readBody(req);
const out = await identity.issue(body);
return sendJson(res, 200, { ok: true, ...out }, cors);
} catch (err) {
if (err instanceof ToolError) {
return sendJson(res, err.status, { ok: false, error: err.code, message: err.message }, cors);
}
throw err;
}
}
if (!auther.enabled) {
return sendJson(res, 503, {
ok: false,
error: 'tools_key_not_configured',
message: 'Set TOOLS_SHARED_KEY and use the same value in the LibreDesk tool headers.',
});
}
if (!auther.verify(req.headers['x-tools-key'])) {
return sendJson(res, 401, { ok: false, error: 'unauthorized', message: 'invalid or missing X-Tools-Key' });
}
if (req.method === 'GET' && url === '/tools/manifest') {
return sendJson(res, 200, {
base_url: cfg.toolsPublicBaseUrl,
tools: [...byName.values()].map((t) => ({
name: t.name,
description: t.description,
method: 'POST',
url: `${cfg.toolsPublicBaseUrl}/tools/${t.name}`,
parameters: t.parameters,
requires_verification: t.requiresVerification,
requires_agent_approval: false,
auth: { headers: [{ key: 'X-Tools-Key', value: '<TOOLS_SHARED_KEY>' }] },
})),
});
}
const match = url.match(/^\/tools\/([a-z0-9_]+)$/);
if (req.method === 'POST' && match) {
const tool = byName.get(match[1]);
if (!tool) {
return sendJson(res, 404, { ok: false, error: 'unknown_tool', message: `unknown tool: ${match[1]}` });
}
const body = await readBody(req);
const ctx = contextFromHeaders(req.headers);
ctx.siteProfile = resolveSiteProfile(cfg, ctx);
// 仅本地联调可跳过验证;默认关闭,且会在启动时告警。
if (cfg.devBypassVerification && !ctx.verified) {
ctx.verified = true;
console.warn(JSON.stringify({ ts: new Date().toISOString(), warn: 'verification bypassed by TOOLS_DEV_BYPASS', tool: tool.name }));
}
if (tool.requiresVerification && !ctx.verified) {
return sendJson(res, 403, {
ok: false,
error: 'verification_required',
message:
'Ask the customer to verify their email with the one-time code, then call this tool again. Do not ask for personal data before verification.',
});
}
const started = Date.now();
const result = await tool.handler(body, ctx);
console.log(JSON.stringify({
ts: new Date().toISOString(),
tool: tool.name,
ms: Date.now() - started,
verified: ctx.verified,
inbox: ctx.inboxId || null,
site: ctx.siteProfile.site,
conversation: ctx.conversationUuid || null,
ok: true,
}));
return sendJson(res, 200, { ok: true, tool: tool.name, ...result });
}
return sendJson(res, 404, { ok: false, error: 'not_found' });
} catch (err) {
if (err instanceof ToolError) {
console.warn(JSON.stringify({ ts: new Date().toISOString(), error: err.code, status: err.status, message: err.message }));
return sendJson(res, err.status, { ok: false, error: err.code, message: err.message });
}
console.error(JSON.stringify({ ts: new Date().toISOString(), error: 'internal_error', message: err && err.message }));
return sendJson(res, 500, { ok: false, error: 'internal_error', message: 'Tool execution failed' });
}
});
return server;
}
if (require.main === module) {
const cfg = loadConfig();
const server = createServer({ config: cfg });
server.listen(cfg.port, '0.0.0.0', () => {
console.log(`[crm-tools] listening on :${cfg.port} — manifest: ${cfg.toolsPublicBaseUrl}/tools/manifest`);
if (!cfg.toolsSharedKey) console.warn('[crm-tools] TOOLS_SHARED_KEY is not set — all /tools/* calls will return 503');
if (cfg.devBypassVerification) console.warn('[crm-tools] TOOLS_DEV_BYPASS=true — verification checks are BYPASSED (local development only)');
});
const shutdown = () => server.close(() => process.exit(0));
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
}
module.exports = { createServer, contextFromHeaders };