'use strict'; const fs = require('fs'); const path = require('path'); const { ToolError } = require('./errors'); const { createTwentyClient } = require('./twenty'); const { asList, asObject } = require('./rpc'); const { createLeadStore } = require('./leads'); const { translateProductQuery } = require('./product-query'); const { createSiteMap } = require('./sitemap'); const { createOpportunityService } = require('./opportunity'); const { createMailHistory } = require('./mail-history'); // 站点类目映射(旧项目 bridge/config/site-map*.json,含中文别名)。 // 商品 RPC 需要 tenant 上下文,目前从 bridge 直连拿不到;RPC 空结果时用它兜底。 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 类名)。 // 不传这个,product-service 会报 "Cannot assign array to property ... ?ReqContext"。 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_kb', description: 'Search the official Yehwang knowledge base (policies, shipping, returns, payment, VAT, membership, dropshipping, product care). Call this FIRST for ANY policy/FAQ question and answer only from the returned content. Full URLs in source_urls may be cited.', parameters: { type: 'object', properties: { query: { type: 'string', description: 'the customer question or policy topic' }, limit: { type: 'integer', description: 'max results, default 5' }, }, required: ['query'], }, requiresVerification: false, }, { 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_email_history', description: 'Search the CURRENT verified customer\'s own past email history (inquiries, quotes, previous conversations). Requires a verified contact. Never search another person\'s email; the email scope always comes from the verified platform identity, not from the customer message.', parameters: { type: 'object', properties: { query: { type: 'string', description: 'topic to search in the customer\'s email history' }, }, required: ['query'], }, 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: 'collect_info', description: 'Record customer lead details incrementally across turns (company, contact name, phone, product need, quantity, budget, close date). Call this whenever the customer provides any of these; it persists them for the conversation so the final create_opportunity does not lose earlier facts. Does not create a CRM record.', parameters: { type: 'object', properties: { company: { type: 'string', description: 'customer company name' }, contactName: { type: 'string', description: 'contact person full name' }, phone: { type: 'string', description: 'phone number if stated' }, productNeed: { type: 'string', description: 'what the customer asked for, in their own words' }, quantity: { type: 'string', description: 'quantity if stated' }, budget: { type: 'string', description: 'budget if stated, e.g. "€500"' }, closeDate: { type: 'string', description: 'expected close date YYYY-MM-DD, only if stated' }, }, required: [], }, requiresVerification: false, }, { name: 'send_verification_code', description: 'Send a 6-digit email verification code to the customer. Only call after the customer provides their email address and private data (orders/opportunities) requires verification. Never invent an email address.', parameters: { type: 'object', properties: { email: { type: 'string', description: 'customer email address they provided' }, }, required: ['email'], }, requiresVerification: false, }, { name: 'verify_email_code', description: 'Verify the 6-digit code the customer received by email. Call after the customer replies with the code. On success the conversation becomes verified for private order/opportunity tools.', parameters: { type: 'object', properties: { code: { type: 'string', description: 'the 6-digit code from the customer' }, }, required: ['code'], }, requiresVerification: false, }, { name: 'handoff_to_human', description: 'Hand the conversation to a human agent. Call when the customer explicitly asks for a human, is angry/complaining, or the request is outside your tools and cannot be resolved. It writes a private handoff note and assigns the conversation to the configured human user.', parameters: { type: 'object', properties: { reason: { type: 'string', description: 'why the conversation is being handed off' }, }, required: ['reason'], }, requiresVerification: false, }, { name: 'create_opportunity', description: 'Create a CRM opportunity in Twenty after you have collected the 3 required elements: company, contact name, and product/need. Call it exactly once per customer; if it returns created=false an opportunity already exists. Requires verified contact.', parameters: { type: 'object', properties: { company: { type: 'string', description: 'customer company name' }, contactName: { type: 'string', description: 'contact person full name' }, productNeed: { type: 'string', description: 'what the customer asked for, in their own words' }, quantity: { type: 'string', description: 'quantity if stated' }, budget: { type: 'string', description: 'budget if stated, e.g. "€500"' }, closeDate: { type: 'string', description: 'expected close date YYYY-MM-DD, only if the customer stated one' }, phone: { type: 'string', description: 'phone if stated' }, }, required: ['company', 'contactName', 'productNeed'], }, 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 twenty = deps.twenty || createTwentyClient(cfg.twenty); const siteMapOverride = deps.siteMap || null; const siteEntries = (profile) => siteMapOverride || loadSiteMapFile(profile.siteMap); // 会话级 lead 槽位:配了 REDIS_URL 就存 Redis(bridge 重启/多副本共享),否则退回内存 const leadStore = deps.leadStore || createLeadStore(cfg); const retriever = deps.retriever || null; const sitemap = deps.sitemap || createSiteMap(cfg); const opportunityService = deps.opportunityService || createOpportunityService({ cfg, twenty, leadStore, client: deps.client }); const mailHistory = deps.mailHistory || createMailHistory(cfg); const reranker = deps.reranker || null; function missingLeadFields(slots) { const missing = []; if (!slots.company) missing.push('company'); if (!slots.contactName) missing.push('contactName'); if (!slots.productNeed) missing.push('productNeed'); return missing; } async function collectInfo(args, ctx) { const conversationUuid = String((ctx && ctx.conversationUuid) || ''); if (!conversationUuid) throw new ToolError(400, 'missing_conversation', 'conversation context is required'); const existing = await leadStore.get(conversationUuid); const slots = { ...(existing ? existing.slots : {}) }; for (const key of ['company', 'contactName', 'phone', 'productNeed', 'quantity', 'budget', 'closeDate']) { const value = args && args[key] != null ? String(args[key]).trim() : ''; if (value) slots[key] = value; } if (slots.closeDate && !/^\d{4}-\d{2}-\d{2}$/.test(slots.closeDate)) delete slots.closeDate; const savedState = await leadStore.save(conversationUuid, slots); if (savedState && savedState.opportunityId && opportunityService.advanceStage) { try { await opportunityService.advanceStage({ ctx, state: savedState, slots, email: ctx.email }); } catch (err) { console.error(JSON.stringify({ ts: new Date().toISOString(), warn: `collect_info stage advance failed: ${err.message}` })); } } const missing = missingLeadFields(slots); return { saved: true, slots, missing, ready: missing.length === 0, note: missing.length ? `Still missing: ${missing.join(', ')}. Ask the customer naturally before calling create_opportunity.` : 'All required lead fields are present. You may call create_opportunity now.', }; } 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 searchKb(args) { if (!retriever) throw new ToolError(503, 'not_configured', 'Knowledge base is not configured'); 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, 8); // 有 reranker 时多取候选再重排;无 reranker 时尊重调用方 limit。 const fetchLimit = reranker ? Math.max(limit, 8) : limit; let results = await retriever.searchKB(query, fetchLimit); // parent/child 可能命中同一篇文章:按 question+answer 去重 const seen = new Set(); results = results.filter((r) => { const key = `${r.question || ''}\u0000${r.answer || ''}`; if (seen.has(key)) return false; seen.add(key); return true; }); if (reranker) results = await reranker.rerank(query, results, limit); else results = results.slice(0, limit); return { results, count: results.length, ...(results.length ? {} : { note: 'No knowledge base match; do not invent a policy answer.' }), }; } 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, pageSize: limit, 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 : []) .slice(0, limit) .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); } 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) { // 全量商品页站点地图:更具体的商品名/长尾词匹配真实商品页 const fullHits = await sitemap.search(rpcQuery || query, profile.site, 3); if (fullHits.length) { items = fullHits.map((h) => ({ name: h.name, url: h.url, price: null, sku: null, product_id: null })); source = 'sitemap'; } else if (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 === 'sitemap' ? { note: 'These are real Yehwang product/category pages matched from the full sitemap; include the links in the reply. Prices may be unavailable.' } : {}), ...(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, parcels, items: (Array.isArray(d.products) ? d.products : Array.isArray(d.items) ? d.items : []) .slice(0, 5) .map((it) => ({ name: it.name || it.productName || null, sku: it.sku || it.model || null, quantity: it.quantity || 1, price: it.price || it.buyPrice || 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 handoffToHuman(args, ctx) { if (!deps.client) throw new ToolError(503, 'not_configured', 'LibreDesk client is not configured'); if (!ctx.conversationUuid) throw new ToolError(400, 'missing_conversation', 'conversation context is required'); const reason = String(args.reason || 'customer requested human agent').trim(); await deps.client.sendPrivateNote(ctx.conversationUuid, `AI handoff: ${reason}`); try { await deps.client.assignUser(ctx.conversationUuid, cfg.handoffUserId); } catch (err) { return { handoff: true, assigned: false, reason, warning: `conversation assigned failed: ${err.message}` }; } return { handoff: true, assigned: true, assigned_user_id: cfg.handoffUserId, reason }; } async function createOpportunity(args, ctx) { if (!ctx.verified) { throw new ToolError(403, 'verification_required', 'Creating an opportunity requires a verified contact. Ask the customer to verify their email 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.twenty.apiKey) throw new ToolError(503, 'not_configured', 'TWENTY_API_KEY is not configured'); const conversationUuid = String(ctx.conversationUuid || ''); if (!conversationUuid) throw new ToolError(400, 'missing_conversation', 'conversation context is required'); const argSlots = Object.fromEntries( Object.entries(args || {}) .filter(([, v]) => v != null && String(v).trim() !== '') .map(([k, v]) => [k, String(v).trim()]) ); const profile = resolveSiteProfile(cfg, ctx); return opportunityService.createOrReuse({ ctx, argSlots, email, profile }); } async function searchEmailHistory(args, ctx) { if (!ctx.verified || !ctx.email) { throw new ToolError(403, 'verification_required', 'Email history is private; the customer must verify their identity first.'); } if (!mailHistory.enabled) throw new ToolError(503, 'not_configured', 'Email history is not configured'); const query = String(args.query || '').trim(); if (!query) throw new ToolError(400, 'invalid_arguments', 'query is required'); const matches = await mailHistory.search(query, ctx.email, 3); return { matches, count: matches.length, email_scope: ctx.email, ...(matches.length ? {} : { note: 'No email history found for this customer and query.' }), }; } async function sendVerificationCode(args, ctx) { if (!deps.verification) throw new ToolError(503, 'not_configured', 'Verification is not configured'); if (!ctx.conversationUuid) throw new ToolError(400, 'missing_conversation', 'conversation context is required'); const out = await deps.verification.sendCode(ctx.conversationUuid, args.email, { language: ctx.language }); return { ...out, note: 'Ask the customer to check their inbox and reply with the 6-digit code.' }; } async function verifyEmailCode(args, ctx) { if (!deps.verification) throw new ToolError(503, 'not_configured', 'Verification is not configured'); if (!ctx.conversationUuid) throw new ToolError(400, 'missing_conversation', 'conversation context is required'); return deps.verification.verifyCode(ctx.conversationUuid, args.code); } const handlers = { search_kb: searchKb, search_products: searchProducts, search_orders: searchOrders, search_inventory: searchInventory, search_email_history: searchEmailHistory, collect_info: collectInfo, handoff_to_human: handoffToHuman, send_verification_code: sendVerificationCode, verify_email_code: verifyEmailCode, create_opportunity: createOpportunity, }; const tools = TOOL_DEFS.map((def) => ({ ...def, handler: (args, ctx) => handlers[def.name](args || {}, ctx), })); tools.leadStore = leadStore; return tools; } module.exports = { createTools, TOOL_DEFS, resolveSiteProfile };