初始化 LibreDesk 本地实例部署仓库

- docker compose 部署:app / db / redis / tls(nginx) / bridge-tools,宿主端口错开避免与本机服务冲突
- bridge-tools 内联进仓库(compose build 路径改为 ./bridge-tools),仓库自包含可部署
- 部署说明 README、crm-new 对照与 bridge-tools 方案文档、备份与隧道脚本
- .gitignore 排除 .env、config.toml、uploads/、tls 私钥/证书、backups/、logs/
This commit is contained in:
wangliang
2026-10-29 13:24:13 +08:00
commit 39986f10f2
26 changed files with 6345 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
'use strict';
const crypto = require('crypto');
/**
* 工具服务入口鉴权:LibreDesk 在工具的 Headers 配置里带 X-Tools-Key。
* 未配置 key 时 fail-closed(返回 enabled=false,server 会直接 503)。
*/
function createAuther(expectedKey) {
const key = String(expectedKey || '');
const expected = Buffer.from(key);
function verify(value) {
if (!key) return false;
const given = Buffer.from(String(value || ''));
if (given.length !== expected.length) return false;
return crypto.timingSafeEqual(given, expected);
}
return { enabled: key.length > 0, verify };
}
module.exports = { createAuther };
+65
View File
@@ -0,0 +1,65 @@
'use strict';
const fs = require('fs');
const path = require('path');
// 多站点配置:inbox 数字 ID -> 站点 profile(tenant / 语言 / 类目映射 / 搜索地址)
function loadSites(env) {
const fallback = {
defaultSite: 'www',
profiles: {
www: { site: 'www', tenantId: null, languageId: null, currencyCode: 'EUR', searchUrl: 'https://www.yehwang.com/search.html?q=', siteMap: 'site-map.json' },
},
inboxes: {},
};
const raw = env.SITES_JSON;
if (raw) {
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object') return parsed;
} catch {
// 配置坏了就用默认值
}
}
try {
const parsed = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'config', 'sites.json'), 'utf8'));
if (parsed && typeof parsed === 'object') return parsed;
} catch {
// 没有配置文件时保持默认
}
return fallback;
}
/**
* bridge-tools 配置。全部来自环境变量。
* 密钥类字段不落默认值;缺配置时对应工具返回 503,而不是静默降级。
*/
function loadConfig(env = process.env) {
const pick = (k, d) => (env[k] === undefined || env[k] === '' ? d : env[k]);
const num = (k, d) => {
const n = Number(pick(k, ''));
return Number.isFinite(n) && n > 0 ? n : d;
};
return {
port: num('TOOLS_PORT', 4100),
toolsSharedKey: String(pick('TOOLS_SHARED_KEY', '')),
toolsPublicBaseUrl: String(pick('TOOLS_PUBLIC_BASE_URL', 'http://bridge-tools:4100')).replace(/\/+$/, ''),
// 仅本地联调用:跳过 LibreDesk 注入的 X-Libredesk-Contact-Verified 校验。
devBypassVerification: /^(1|true|yes)$/i.test(String(pick('TOOLS_DEV_BYPASS', 'false'))),
siteSearchUrl: String(pick('SITE_SEARCH_URL', 'https://www.yehwang.com/search.html?q=')),
recommendUrl: String(pick('RECOMMEND_URL', '')),
orderApiUrl: String(pick('ORDER_API_URL', '')),
inventoryApiUrl: String(pick('INVENTORY_API_URL', '')),
memberRpcUrl: String(pick('MEMBER_RPC_URL', '')),
rpcTimeoutMs: num('RPC_TIMEOUT_MS', 8000),
productTimeoutMs: num('PRODUCT_TIMEOUT_MS', 15000),
sites: loadSites(env),
defaultTenantId: num('DEFAULT_TENANT_ID', 2),
defaultLanguageId: num('DEFAULT_LANGUAGE_ID', 1),
};
}
module.exports = { loadConfig };
+17
View File
@@ -0,0 +1,17 @@
'use strict';
/**
* 工具执行错误。status 会直接映射为 HTTP 状态码:
* - 4xx:LibreDesk 视为“工具失败”,AI 通常会告知客户无法完成/转人工
* - 2xx:即使没有数据也应当返回 2xx(让模型能继续对话)
*/
class ToolError extends Error {
constructor(status, code, message) {
super(message);
this.name = 'ToolError';
this.status = status;
this.code = code;
}
}
module.exports = { ToolError };
+42
View File
@@ -0,0 +1,42 @@
'use strict';
const CJK = /[\u3400-\u9fff]/;
function normalize(value) {
return String(value || '')
.toLowerCase()
.replace(/[^\p{L}\p{N}\s-]+/gu, ' ')
.replace(/\s+/g, ' ')
.trim();
}
// 把中文/别名商品词映射到 site-map 的英文类目,例如 戒指 -> Rings。
function translateProductQuery(query, entries) {
const original = String(query || '').trim();
const q = normalize(original);
if (!q || !Array.isArray(entries) || !entries.length) return { query: original, category: null, translated: false };
if (!CJK.test(q)) return { query: original, category: null, translated: false };
let best = null;
for (const entry of entries) {
const fields = [entry.title, String(entry.category || '').split('/').pop(), ...(entry.aliases || [])]
.map(normalize)
.filter((f) => f.length >= 2);
for (const field of fields) {
if (q.includes(field) || field.includes(q)) {
const score = field.length + (field === q ? 100 : 0);
if (!best || score > best.score) best = { entry, score, field };
}
}
}
if (!best) return { query: original, category: null, translated: false };
return {
query: best.entry.title || original,
category: { name: best.entry.title || null, url: best.entry.url || null },
translated: true,
matched_alias: best.field,
};
}
module.exports = { translateProductQuery, normalize };
+136
View File
@@ -0,0 +1,136 @@
'use strict';
// JSONRPC-TCP 客户端(Hyperf JSONRPC 协议)
// 协议:TCP + JSON 请求体 + "\r\n" 帧分隔;服务间内部通道,无鉴权。
// 支持 jsonrpc://host:port 与 http(s):// 双形态地址(后者降级走 HTTP)。
const net = require('net');
const DEFAULT_TIMEOUT = 8000;
/**
* 解析地址:jsonrpc://host:port 或 host:port 或 http(s)://host:port
* @returns {{protocol: 'jsonrpc'|'http', host: string, port: number, httpUrl: string|null}}
*/
function parseAddress(url) {
const s = String(url || '').trim();
if (s.startsWith('jsonrpc://')) {
const m = s.replace('jsonrpc://', '').match(/^([^:/]+):?(\d+)?/);
if (m) return { protocol: 'jsonrpc', host: m[1], port: m[2] ? parseInt(m[2], 10) : 9502, httpUrl: null };
}
if (/^https?:\/\//.test(s)) {
try {
const u = new URL(s);
return {
protocol: 'http',
host: u.hostname,
port: u.port ? parseInt(u.port, 10) : (u.protocol === 'https:' ? 443 : 80),
httpUrl: s,
};
} catch {
/* fallthrough */
}
}
const m = s.match(/^([^:/]+):?(\d+)?/);
if (m) return { protocol: 'jsonrpc', host: m[1], port: m[2] ? parseInt(m[2], 10) : 9502, httpUrl: null };
return { protocol: 'jsonrpc', host: s, port: 9502, httpUrl: null };
}
/**
* JSONRPC 调用(TCP)。
* @param {string} addr jsonrpc://host:port
* @param {string} method 如 /erp_order/getList
* @param {Array} [params] 位置参数数组
* @param {Array} [context] 请求上下文(可空)
*/
function jsonrpcCall(addr, method, params = [], context = [], timeout = DEFAULT_TIMEOUT) {
return new Promise((resolve, reject) => {
const { host, port } = parseAddress(addr);
const id = 'tools_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
const payload = JSON.stringify({ jsonrpc: '2.0', method, params, id, context }) + '\r\n';
const sock = net.createConnection({ host, port });
let buf = '';
let settled = false;
const finish = (fn, value) => {
if (settled) return;
settled = true;
clearTimeout(timer);
sock.destroy();
fn(value);
};
const timer = setTimeout(() => finish(reject, new Error(`JSONRPC timeout: ${method} @ ${host}:${port}`)), timeout);
sock.setTimeout(timeout);
sock.on('timeout', () => sock.destroy());
sock.on('connect', () => sock.write(payload));
sock.on('data', (chunk) => {
buf += chunk.toString('utf8');
const frames = buf.split('\r\n');
buf = frames.pop(); // 保留不完整尾帧
for (const frame of frames) {
if (!frame.trim()) continue;
let parsed;
try {
parsed = JSON.parse(frame);
} catch {
continue;
}
if (parsed && parsed.id === id) {
if (parsed.error) {
finish(reject, new Error(`JSONRPC error: ${method} -> ${parsed.error.message || JSON.stringify(parsed.error)}`));
} else {
finish(resolve, parsed.result);
}
return;
}
}
});
sock.on('error', (e) => finish(reject, new Error(`JSONRPC connect failed: ${host}:${port} (${e.message})`)));
sock.on('close', () => finish(reject, new Error(`JSONRPC connection closed before response: ${method}`)));
});
}
/**
* 统一执行入口:按地址协议分发(jsonrpc:// → TCP;http(s):// → fetch)。
*/
async function call(addr, method, params = [], httpOptions = {}, timeout = DEFAULT_TIMEOUT) {
const parsed = parseAddress(addr);
if (parsed.protocol === 'jsonrpc') return jsonrpcCall(addr, method, params, httpOptions.context || [], timeout);
const qs = httpOptions.query ? '?' + new URLSearchParams(httpOptions.query).toString() : '';
const resp = await fetch(parsed.httpUrl + method + qs, {
headers: httpOptions.headers || {},
signal: AbortSignal.timeout(timeout),
});
if (!resp.ok) throw new Error(`HTTP ${resp.status} for ${method}`);
return resp.json();
}
/** 业务 RPC 返回体形态不一,统一摊平成数组。 */
function asList(data) {
if (Array.isArray(data)) return data;
if (!data || typeof data !== 'object') return [];
for (const key of ['list', 'data', 'items', 'rows', 'result']) {
if (data[key] !== undefined) {
const inner = asList(data[key]);
if (inner.length) return inner;
}
}
return [];
}
/** 业务 RPC 返回体摊平成对象(取 list/data/result 的第一层对象)。 */
function asObject(data) {
if (!data || typeof data !== 'object') return null;
if (Array.isArray(data)) return data[0] || null;
for (const key of ['data', 'result', 'item']) {
if (data[key] && typeof data[key] === 'object' && !Array.isArray(data[key])) return data[key];
}
return data;
}
module.exports = { parseAddress, jsonrpcCall, call, asList, asObject };
+180
View File
@@ -0,0 +1,180 @@
'use strict';
// bridge-tools HTTP server:只做三件事
// GET /healthz 健康检查
// GET /tools/manifest 给 LibreDesk 注册工具用的清单
// POST /tools/<name> 执行工具(X-Tools-Key 鉴权 + X-Libredesk-* 身份头)
const http = require('http');
const { loadConfig } = require('./config');
const { createAuther } = require('./auth');
const { createTools, resolveSiteProfile } = require('./tools');
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 sendJson(res, status, body) {
const payload = JSON.stringify(body);
res.writeHead(status, {
'content-type': 'application/json; charset=utf-8',
'content-length': Buffer.byteLength(payload),
});
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 byName = new Map(tools.map((t) => [t.name, t]));
const startedAt = Date.now();
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: 'bridge-tools',
uptime_sec: Math.round((Date.now() - startedAt) / 1000),
tools: [...byName.keys()],
verification_bypass: cfg.devBypassVerification,
sites: Object.keys((cfg.sites && cfg.sites.profiles) || {}),
});
}
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: tools.map((t) => ({
name: t.name,
description: t.description,
method: 'POST',
url: `${cfg.toolsPublicBaseUrl}/tools/${t.name}`,
parameters: t.parameters,
requires_verification: t.requiresVerification,
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,
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(`[bridge-tools] listening on :${cfg.port} — manifest: ${cfg.toolsPublicBaseUrl}/tools/manifest`);
if (!cfg.toolsSharedKey) console.warn('[bridge-tools] TOOLS_SHARED_KEY is not set — all /tools/* calls will return 503');
if (cfg.devBypassVerification) console.warn('[bridge-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 };
+542
View File
@@ -0,0 +1,542 @@
'use strict';
// bridge-tools:只保留 LibreDesk AI 需要的业务工具(商品 / 订单 / 库存 / 客户资料)。
// 其余能力(AI 编排、RAG、OTP、转人工、身份签发、质量飞轮)已由 LibreDesk v2.9.1 内置实现。
// 来源:从 crm-new/bridge 裁剪,仅保留 RPC 业务工具链路。
const fs = require('fs');
const path = require('path');
const { ToolError } = require('./errors');
const { asList, asObject } = require('./rpc');
const { translateProductQuery } = require('./product-query');
// ---------------------------------------------------------------- 站点配置
const siteMapCache = new Map();
function loadSiteMapFile(file) {
const name = file || 'site-map.json';
if (siteMapCache.has(name)) return siteMapCache.get(name);
let entries = [];
try {
const raw = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'config', name), 'utf8'));
entries = Array.isArray(raw.entries) ? raw.entries : [];
} catch {
entries = [];
}
siteMapCache.set(name, entries);
return entries;
}
// 语言 code -> RPC language_id(业务侧枚举)
const DEFAULT_LANGUAGE_IDS = { en: 1, nl: 2, de: 3, es: 4, fr: 5, it: 6, tr: 7, cn: 10 };
// inbox 数字 ID -> 站点 profile(tenant / 语言 / 站点地图 / 搜索地址)
function resolveSiteProfile(cfg, ctx) {
const sites = cfg.sites || {};
const inboxKey = String((ctx && ctx.inboxId) || '');
const key = (sites.inboxes && sites.inboxes[inboxKey]) || sites.defaultSite || 'www';
const profile = (sites.profiles && sites.profiles[key]) || {};
const languageIds = (sites.languageIds && Object.keys(sites.languageIds).length ? sites.languageIds : DEFAULT_LANGUAGE_IDS);
const languageId = profile.languageId != null
? profile.languageId
: (profile.language && languageIds[profile.language] != null ? languageIds[profile.language] : (cfg.defaultLanguageId != null ? cfg.defaultLanguageId : 1));
return {
key,
site: profile.site || key,
tenantId: profile.tenantId != null ? profile.tenantId : (cfg.defaultTenantId != null ? cfg.defaultTenantId : 1),
languageId,
currencyCode: profile.currencyCode || '',
searchUrl: profile.searchUrl || cfg.siteSearchUrl,
homeUrl: profile.homeUrl || (() => { try { return new URL(profile.searchUrl || cfg.siteSearchUrl).origin; } catch { return ''; } })(),
siteMap: profile.siteMap || 'site-map.json',
};
}
// Hyperf JSON-RPC 的 DTO 反序列化要求嵌套对象带 _class(URL 编码的 PHP 类名)。
const REQ_CONTEXT_CLASS = encodeURIComponent('Yw\\Common\\Dto\\ReqContext');
const DEVICE_INFO_CLASS = encodeURIComponent('Yw\\Member\\Client\\Dto\\DeviceInfoDto');
function rpcContextFor(profile) {
return {
_class: REQ_CONTEXT_CLASS,
tenantId: profile.tenantId,
languageId: profile.languageId,
currencyCode: profile.currencyCode || '',
defaultCurrencyCode: profile.currencyCode || '',
siteType: 1,
isoCode: '',
};
}
function withRpcContext(params, profile) {
if (!Array.isArray(params) || params.length === 0) return params;
const first = params[0];
if (!first || typeof first !== 'object' || Array.isArray(first)) return params;
return [{
...first,
reqContext: rpcContextFor(profile),
deviceInfo: {
_class: DEVICE_INFO_CLASS,
ip: '',
userAgent: '',
deviceToken: '',
deviceType: 'h5',
appVersion: '',
},
}, ...params.slice(1)];
}
// ---------------------------------------------------------------- 站点地图兜底匹配
function normalizeText(value) {
return String(value || '')
.toLowerCase()
.replace(/[^\p{L}\p{N}\s-]+/gu, ' ')
.replace(/\s+/g, ' ')
.trim();
}
const CJK_RE = /[\u3400-\u9fff]/;
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// 中文用子串匹配;英文用词边界,避免 "earrings" 误命中 "rings"。
function fieldMatches(field, token) {
if (!field || !token) return false;
if (CJK_RE.test(field) || CJK_RE.test(token)) return field.includes(token) || token.includes(field);
if (token.length < 2) return false;
return new RegExp(`(^|[^a-z0-9])${escapeRegExp(token)}([^a-z0-9]|$)`).test(field);
}
function matchSiteMap(entries, query, limit) {
const q = normalizeText(query);
if (!q) return [];
const tokens = q.split(' ').filter(Boolean);
const scored = [];
for (const entry of entries) {
const fields = [entry.title, String(entry.category || '').split('/').pop(), ...(entry.aliases || [])]
.map(normalizeText)
.filter(Boolean);
let score = 0;
for (const field of fields) {
if (field === q) score = Math.max(score, 100);
else if (fieldMatches(field, q) || (q.length >= 2 && fieldMatches(q, field))) score = Math.max(score, 80);
else if (tokens.every((t) => fieldMatches(field, t))) score = Math.max(score, 60);
}
if (!score && tokens.length) {
const hits = tokens.filter((t) => fields.some((f) => fieldMatches(f, t))).length;
if (hits === tokens.length) score = 50;
}
if (score > 0) scored.push({ entry, score });
}
scored.sort((a, b) =>
b.score - a.score ||
(a.entry.level || 9) - (b.entry.level || 9) ||
String(a.entry.url || '').length - String(b.entry.url || '').length
);
return scored.slice(0, limit).map(({ entry }) => ({
name: entry.title || null,
url: entry.url || null,
sku: null,
price: null,
}));
}
// ---------------------------------------------------------------- 工具定义
/**
* 工具定义(同步给 LibreDesk Admin → AI → Tools)。
* description / parameters 直接进入模型上下文,用英文写。
* requiresVerification: LibreDesk 侧同名开关(未验证联系人会被平台拦截);服务端仍二次校验(fail-closed)。
*/
const TOOL_DEFS = [
{
name: 'search_products',
description:
'Search the Yehwang product catalog by keyword. Returns product names, prices, images, stock, product ids and page URLs (falls back to category page links when the catalog index has no exact match). Call this for ANY product-finding request instead of answering from memory; always include returned URLs and prices in the reply.',
parameters: {
type: 'object',
properties: {
query: { type: 'string', description: 'product keywords, e.g. "gold hoop earrings" or "stainless steel necklace"' },
limit: { type: 'integer', description: 'max results, default 5, max 5' },
},
required: ['query'],
},
requiresVerification: false,
},
{
name: 'search_orders',
description:
'Look up order status and details for the CURRENT verified customer only. Requires a verified contact. If the customer gives an order number, pass it as orderNumber to get full detail (items, totals, shipping address, tracking). Omit orderNumber to list the latest 5 orders. Never ask for or accept another person\'s email; the email is taken from platform identity headers. Always include the shipping method in the reply.',
parameters: {
type: 'object',
properties: {
orderNumber: { type: 'string', description: 'order number or order id; omit to list the latest 5 orders' },
},
required: [],
},
requiresVerification: true,
},
{
name: 'search_inventory',
description:
'Check real-time stock and pre-order ETA for a specific product. Pass product_id returned by search_products when available; otherwise a SKU/product id whose digits identify the product.',
parameters: {
type: 'object',
properties: {
product_id: { type: 'integer', description: 'product id from search_products (preferred)' },
sku: { type: 'string', description: 'SKU or product id' },
productName: { type: 'string', description: 'product name if SKU is unknown' },
},
required: [],
},
requiresVerification: false,
},
{
name: 'get_customer_info',
description:
'Look up the CURRENT verified customer\'s own account profile: display name, company, country, member grade, discount group, points, store credit, registration date and phone. Requires a verified contact; the identity always comes from platform headers, never from the customer message. Use it to personalise the reply or confirm account context. Never look up or reveal another person\'s profile.',
parameters: {
type: 'object',
properties: {},
required: [],
},
requiresVerification: true,
},
];
function digitsOf(value) {
const m = String(value || '').match(/\d+/);
return m ? parseInt(m[0], 10) : null;
}
function summarizeOrder(o) {
if (!o || typeof o !== 'object') return null;
return {
order_no: o.orderNo || o.order_no || o.order_id || o.orderId || null,
order_id: o.orderId || o.order_id || null,
status: o.statusText || o.orderStatus || o.status || null,
total: o.total || o.totalPrice || o.total_price || null,
date: o.dateAdded || o.date_added || null,
shipping_method: o.shippingMethod || o.shipping_method || null,
tracking_number: o.trackingNumber || o.tracking_number || null,
};
}
// ---------------------------------------------------------------- 工具实现
function createTools(cfg, deps = {}) {
const rpc = deps.rpc || require('./rpc');
const siteMapOverride = deps.siteMap || null;
const siteEntries = (profile) => siteMapOverride || loadSiteMapFile(profile.siteMap);
async function callRpc(addr, method, params, ctx, timeoutMs) {
if (!addr) throw new ToolError(503, 'not_configured', `RPC endpoint for ${method} is not configured`);
const profile = resolveSiteProfile(cfg, ctx || {});
return rpc.call(addr, method, withRpcContext(params, profile), {}, timeoutMs || cfg.rpcTimeoutMs);
}
async function searchProducts(args, ctx) {
const query = String(args.query || '').trim();
if (!query) throw new ToolError(400, 'invalid_arguments', 'query is required');
const limit = Math.min(Number(args.limit) || 5, 5);
const profile = resolveSiteProfile(cfg, ctx);
// 中文/别名商品词先映射到英文类目,例如 简约不锈钢戒指 -> Rings
const translation = translateProductQuery(query, siteEntries(profile));
const rpcQuery = translation.query || query;
const absoluteUrl = (href) => {
if (!href) return null;
if (/^https?:\/\//i.test(href)) return href;
return profile.homeUrl ? profile.homeUrl + (href.startsWith('/') ? '' : '/') + href : href;
};
let items = [];
let total = null;
let catalogError = null;
try {
const cmd = {
keyword: rpcQuery,
page: 1,
// 多取候选,下面按库存排序后再截断到 limit,避免缺货商品占满前几条
pageSize: limit * 4,
languageId: profile.languageId,
tenantId: profile.tenantId,
currencyCode: profile.currencyCode || '',
status: 20,
siteType: 1,
supplierCode: '',
tagIds: [],
tenantBaseCurrency: profile.currencyCode || '',
filterSpuList: [],
productSource: 1,
};
const raw = await callRpc(cfg.recommendUrl, '/product/list', [cmd], ctx, cfg.productTimeoutMs);
const result = (raw && raw.result) || raw || {};
const paging = result.data || result;
const list = Array.isArray(paging) ? paging : (paging.data || paging.list || []);
total = result.total != null ? result.total : (paging.total != null ? paging.total : null);
items = (Array.isArray(list) ? list : [])
.map((p) => {
const name = p.spuName || p.name || p.title || null;
const sku = p.sku || p.model || (Array.isArray(p.skus) && p.skus[0] ? p.skus[0].productId : null);
const stock = Array.isArray(p.skus)
? p.skus.reduce((sum, s) => sum + (Number(s.quantity) || 0), 0)
: null;
const href = p.href || p.url || p.product_url || null;
return {
name,
price: p.minPriceWithCurrency || p.priceWithCurrency || (p.price != null ? p.price : null),
url: absoluteUrl(href) || (name && profile.searchUrl ? profile.searchUrl + encodeURIComponent(name) : null),
sku,
product_id: p.spuId != null ? p.spuId : null,
image: p.masterImage || null,
stock,
mark_price: p.markPriceWithCurrency || null,
};
})
.filter((p) => p.name)
// 有库存优先:stock>0 → 0,未知 → 1,缺货 → 2;sort 稳定,保留 RPC 原顺序
.sort((a, b) => {
const rank = (p) => (p.stock > 0 ? 0 : p.stock === null ? 1 : 2);
return rank(a) - rank(b);
})
.slice(0, limit);
} catch (err) {
catalogError = err.message;
}
// 兜底 1:商品列表不可用时退回 autocomplete(只有名字)
if (!items.length) {
try {
const raw = await callRpc(cfg.recommendUrl, '/product_new/autocomplete', [
{ keyword: rpcQuery, page: 1, pageSize: limit },
], ctx, cfg.productTimeoutMs);
items = asList(raw)
.slice(0, limit)
.map((p) => {
const name = p.name || p.title || null;
const url = p.url || p.product_url || p.href ||
(name && profile.searchUrl ? profile.searchUrl + encodeURIComponent(name) : null);
return { name, price: null, url, sku: null };
})
.filter((p) => p.name);
if (items.length) catalogError = null;
} catch (err) {
catalogError = catalogError || err.message;
}
}
let source = 'catalog';
const categoryLinks = matchSiteMap(siteEntries(profile), query, 3);
if (!items.length && categoryLinks.length) {
items = categoryLinks;
source = 'site-map';
}
return {
products: items,
count: items.length,
...(total != null ? { total } : {}),
source,
site: profile.site,
...(translation.translated ? { query_used: rpcQuery, translated_from: query, category: translation.category } : {}),
...(categoryLinks.length && source === 'catalog' ? { category_links: categoryLinks } : {}),
search_url: profile.searchUrl + encodeURIComponent(query),
...(source === 'site-map'
? { note: 'These are Yehwang category pages matched from the site map; include the links in the reply.' }
: {}),
...(source === 'catalog' && categoryLinks.length
? { note: 'Product names come from the live catalog; category_links are real category pages you can also share. Prices/SKUs are currently unavailable.' }
: {}),
...(catalogError ? { catalog_error: catalogError } : {}),
};
}
async function searchOrders(args, ctx) {
if (!ctx.verified) {
throw new ToolError(403, 'verification_required', 'Order lookup requires a verified contact. Ask the customer to verify their email with the one-time code first.');
}
const email = String(ctx.email || '').trim().toLowerCase();
if (!email) throw new ToolError(400, 'missing_email', 'The verified contact has no email on file.');
// 隐私门控:邮箱永远来自平台身份头,忽略模型/参数里传入的任何邮箱。
if (args.orderNumber) {
const wanted = String(args.orderNumber).trim().toLowerCase();
const own = asList(await callRpc(cfg.orderApiUrl, '/erp_order/getList', [
{ email, page: 1, pageSize: 50, transfer: 10000 },
], ctx));
const match = own.find((o) =>
[o.orderId, o.order_id, o.orderNo, o.order_no]
.filter((v) => v != null && v !== '')
.some((v) => String(v).toLowerCase() === wanted)
);
if (!match) {
// 0 结果也返回 2xx:让模型告知客户核对单号,而不是当成系统故障。
return { orders: [], count: 0, message: `No order "${args.orderNumber}" found for the verified customer.` };
}
return { orders: [await fetchOrderDetail(match, ctx)], count: 1 };
}
const orders = asList(await callRpc(cfg.orderApiUrl, '/erp_order/getList', [
{ email, page: 1, pageSize: 5, transfer: 10000 },
], ctx))
.slice(0, 5)
.map(summarizeOrder)
.filter(Boolean);
return {
orders,
count: orders.length,
...(orders.length ? {} : { message: 'No orders found for the verified customer.' }),
};
}
async function fetchOrderDetail(listItem, ctx) {
const orderId = parseInt(listItem.orderId || listItem.order_id, 10) || 0;
const [detailRes, trackRes] = await Promise.allSettled([
callRpc(cfg.orderApiUrl, '/erp_order/getDetail', [{ orderId }], ctx),
callRpc(cfg.orderApiUrl, '/erp_order/getTrackPreSaleOrder', [{ orderId, page: 1, pageSize: 5 }], ctx),
]);
const detail = detailRes.status === 'fulfilled' ? asObject(detailRes.value) : null;
const trackingRaw = trackRes.status === 'fulfilled' ? trackRes.value : null;
const d = detail || listItem;
const parcels = asList(trackingRaw)
.slice(0, 5)
.map((p) => ({
shipping_method: p.shippingMethod || p.shipping_method || p.shippingCode || p.shipping_code || p.carrier || null,
tracking_number: p.trackingNumber || p.tracking_number || p.tracking_code || p.trackingCode || null,
shipped_at: p.shippingAt || p.shipping_at || p.shipped_at || null,
}))
.filter((p) => p.shipping_method || p.tracking_number);
const address = [
[d.shippingFirstname, d.shippingLastname].filter(Boolean).join(' '),
d.shippingCompany,
[d.shippingAddress_1, d.shippingAddress_2].filter(Boolean).join(' '),
[d.shippingPostcode, d.shippingCity].filter(Boolean).join(' '),
d.shippingZone,
d.shippingCountry,
].filter(Boolean).join(', ');
const items = asList(d.products).slice(0, 10).map((p) => ({
name: p.name || null,
model: p.model || null,
quantity: p.quantity != null ? p.quantity : null,
price: p.price || null,
total: p.total || null,
can_return: p.canReturn != null ? Boolean(Number(p.canReturn)) : null,
})).filter((p) => p.name);
const totals = asList(d.totals).slice(0, 8).map((t) => ({
title: t.title || null,
value: t.value || null,
code: t.code || null,
})).filter((t) => t.title);
return {
...summarizeOrder(d),
order_id: d.orderId || orderId,
status: d.statusText || d.status || null,
payment_method: d.paymentMethod || d.paymentCode || null,
customer: {
name: [d.firstname, d.lastname].filter(Boolean).join(' ') || null,
email: d.email || null,
telephone: d.telephone || null,
},
shipping_address: address || null,
shipping_method: d.shippingMethod || d.shipping_method || d.tracking || null,
shipping_code: d.shippingCode || null,
tracking_number: d.trackingNumber || d.tracking_number || null,
shipping_cost: d.shippingCost != null ? String(d.shippingCost) : null,
items,
totals,
...(parcels.length ? { parcels } : {}),
email: d.email || null,
};
}
async function searchInventory(args, ctx) {
const productId = Number(args.product_id) || digitsOf(args.sku) || digitsOf(args.productName);
if (!productId) {
return { products: [], message: `Could not extract a product id from "${args.sku || args.productName || ''}". Ask the customer for the SKU.` };
}
const [stockRes, etaRes] = await Promise.allSettled([
callRpc(cfg.inventoryApiUrl, '/eta/queryStockSummary', [{ productId, warehouseId: 2, includeDetails: false }], ctx),
callRpc(cfg.inventoryApiUrl, '/eta/queryProductEta', [{ productId, warehouseId: 0, useCache: true, customerCode: '' }], ctx),
]);
const stock = stockRes.status === 'fulfilled' ? asObject(stockRes.value) : null;
// eta 服务对未知 productId 会返回默认值,只有 stockSummary 才是权威数据。
if (!stock || typeof stock.instantStock !== 'number') {
return { products: [], product_id: productId, message: `No inventory data found for product id ${productId}. Verify the SKU first.` };
}
const instant = stock.instantStock;
const reserved = stock.reservedQuantity != null ? stock.reservedQuantity : 0;
const eta = etaRes.status === 'fulfilled' ? asObject(etaRes.value) : null;
return {
product_id: productId,
instant_stock: instant,
available: Math.max(0, instant - reserved),
in_transit: stock.inTransitQuantity || 0,
reserved,
...(eta && (eta.etaDate || eta.status)
? { eta_date: eta.etaDate || null, eta_available: eta.availableQuantity != null ? eta.availableQuantity : null, eta_status: eta.status || null }
: {}),
};
}
async function getCustomerInfo(args, ctx) {
if (!ctx.verified) {
throw new ToolError(403, 'verification_required', 'Customer profile lookup requires a verified contact. Ask the customer to verify their email with the one-time code first.');
}
const email = String(ctx.email || '').trim().toLowerCase();
if (!email) throw new ToolError(400, 'missing_email', 'The verified contact has no email on file.');
if (!cfg.memberRpcUrl) throw new ToolError(503, 'not_configured', 'MEMBER_RPC_URL is not configured');
// 邮箱 → customerId(member-service 的 checkEmailExist:customerId=0 表示没有账号)
const check = asObject(await callRpc(cfg.memberRpcUrl, '/customer/checkEmailExist', [{ email }], ctx, cfg.rpcTimeoutMs)) || {};
const customerId = parseInt(check.customerId, 10) || 0;
if (!customerId) {
return { found: false, email, message: 'No customer account is registered with this email.' };
}
const p = asObject(await callRpc(cfg.memberRpcUrl, '/customer/getByCustomerId', [{ customerId }], ctx, cfg.rpcTimeoutMs)) || {};
const name = [p.firstname, p.lastname].map((v) => String(v || '').trim()).filter(Boolean).join(' ').trim();
const dial = String(p.dial || '').trim();
const phone = String(p.telephone || '').trim();
return {
found: true,
customer_id: customerId,
email: String(p.email || email).trim().toLowerCase(),
name: name || String(p.nickname || '').trim() || null,
company: String(p.company || '').trim() || null,
country: String(p.countryName || '').trim() || null,
phone: phone ? `${dial ? '+' + dial : ''}${phone}` : null,
member_grade: String(p.gradeName || '').trim() || null,
discount_group: String(p.groupName || '').trim() || null,
registered_at: p.dateAdded || null,
points: p.point != null ? p.point : null,
points_formatted: p.pointFormat || null,
store_credit: p.storeCredit != null ? p.storeCredit : null,
store_credit_formatted: p.storeCreditFormat || null,
mileage: p.mileage != null ? p.mileage : null,
account_status: p.status === 1 ? 'active' : (p.status === 0 ? 'inactive' : (p.status != null ? p.status : null)),
note: 'Profile data comes from the live customer account. Do not read out the email/phone unless the customer asks for it.',
};
}
const handlers = {
search_products: searchProducts,
search_orders: searchOrders,
search_inventory: searchInventory,
get_customer_info: getCustomerInfo,
};
return TOOL_DEFS.map((def) => ({
...def,
handler: (args, ctx) => handlers[def.name](args || {}, ctx),
}));
}
module.exports = { createTools, TOOL_DEFS, resolveSiteProfile };