chore: CRM-NEW baseline
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
'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 { 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 tools = options.tools || createTools(cfg);
|
||||
const identity = options.identity || createIdentity(cfg);
|
||||
const agent = options.agent || createAgent(cfg);
|
||||
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) || {}),
|
||||
});
|
||||
}
|
||||
|
||||
// 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 };
|
||||
Reference in New Issue
Block a user