chore: CRM-NEW baseline

This commit is contained in:
wangliang
2026-10-23 10:35:28 +08:00
commit aa499f6dbf
111 changed files with 15126 additions and 0 deletions
+260
View File
@@ -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 };