'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: '' }] }, })), }); } 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 };