14 changed files with 382 additions and 27 deletions
+3
View File
@@ -5,6 +5,7 @@
"www": { "www": {
"site": "www", "site": "www",
"tenantId": 2, "tenantId": 2,
"currencyCode": "EUR",
"searchUrl": "https://www.yehwang.com/search.html?q=", "searchUrl": "https://www.yehwang.com/search.html?q=",
"siteMap": "site-map.json", "siteMap": "site-map.json",
"language": "en" "language": "en"
@@ -12,6 +13,7 @@
"us": { "us": {
"site": "us", "site": "us",
"tenantId": 2, "tenantId": 2,
"currencyCode": "USD",
"searchUrl": "https://us.yehwang.com/search.html?q=", "searchUrl": "https://us.yehwang.com/search.html?q=",
"siteMap": "site-map-us.json", "siteMap": "site-map-us.json",
"language": "en" "language": "en"
@@ -19,6 +21,7 @@
"eu": { "eu": {
"site": "www", "site": "www",
"tenantId": 2, "tenantId": 2,
"currencyCode": "EUR",
"searchUrl": "https://www.yehwang.com/search.html?q=", "searchUrl": "https://www.yehwang.com/search.html?q=",
"siteMap": "site-map.json", "siteMap": "site-map.json",
"language": "en" "language": "en"
+46 -2
View File
@@ -15,7 +15,7 @@ const { quickClassify, detectEmotion, INTENTS } = require('./intent');
const { createContextManager } = require('./context'); const { createContextManager } = require('./context');
const { createReranker } = require('./reranker'); const { createReranker } = require('./reranker');
const { detectInjection, markToolResult, INJECTION_CAUTION_PROMPT } = require('./security'); const { detectInjection, markToolResult, INJECTION_CAUTION_PROMPT } = require('./security');
const { hasBuyingIntent, missingLeadContacts, detectLanguage, appendLeadFollowup, alreadyAsksForLead } = require('./lead-intent'); const { hasBuyingIntent, looksLikeLead, missingLeadContacts, detectLanguage, appendLeadFollowup, alreadyAsksForLead } = require('./lead-intent');
const { createLlmCache } = require('./llm-cache'); const { createLlmCache } = require('./llm-cache');
const { checkCitation } = require('./citation-checker'); const { checkCitation } = require('./citation-checker');
const { createFlywheelWriter } = require('./flywheel'); const { createFlywheelWriter } = require('./flywheel');
@@ -142,10 +142,14 @@ function createAgent(cfg, deps = {}) {
} }
const preSlots = (leadState && leadState.slots) || {}; const preSlots = (leadState && leadState.slots) || {};
const buyingIntent = hasBuyingIntent(currentText); const buyingIntent = hasBuyingIntent(currentText);
const leadSignal = looksLikeLead(currentText);
const preMissing = missingLeadContacts(preSlots, ctx.email); const preMissing = missingLeadContacts(preSlots, ctx.email);
if (buyingIntent && preMissing.length) { if (buyingIntent && preMissing.length) {
systemContent += `\n\nLEAD COMPLETION (hard rule): the customer shows buying intent but we still miss ${preMissing.join(', ')}. At the END of your reply, ask naturally for the missing piece(s) in the customer's language. Do not end without asking.`; systemContent += `\n\nLEAD COMPLETION (hard rule): the customer shows buying intent but we still miss ${preMissing.join(', ')}. At the END of your reply, ask naturally for the missing piece(s) in the customer's language. Do not end without asking.`;
} }
if (leadSignal) {
systemContent += '\n\nLEAD CAPTURE (hard rule): the customer message already contains lead details (company and/or contact and/or product need). You MUST call collect_info with those exact values BEFORE writing the reply; do not ask again for details the customer already gave. After collect_info returns ready=true, call create_opportunity with the same values.';
}
messages.unshift({ role: 'system', content: systemContent }); messages.unshift({ role: 'system', content: systemContent });
if (injection.level === 'high') { if (injection.level === 'high') {
console.log(JSON.stringify({ ts: new Date().toISOString(), agent: 'injection_detected', conversation: conv.uuid, matches: injection.matches.length })); console.log(JSON.stringify({ ts: new Date().toISOString(), agent: 'injection_detected', conversation: conv.uuid, matches: injection.matches.length }));
@@ -310,7 +314,7 @@ function createAgent(cfg, deps = {}) {
console.error(JSON.stringify({ ts: new Date().toISOString(), warn: `link backfill failed: ${err.message}` })); console.error(JSON.stringify({ ts: new Date().toISOString(), warn: `link backfill failed: ${err.message}` }));
} }
// collect_info 兜底:消息含明显 lead 信息但 LLM 没调 collect_info 时,补抽槽位 // collect_info 兜底:消息含明显 lead 信息但 LLM 没调 collect_info 时,补抽槽位
if (buyingIntent && leadStore && !(stats.toolCalls || []).some((t) => t.name === 'collect_info')) { if ((buyingIntent || leadSignal) && leadStore && !(stats.toolCalls || []).some((t) => t.name === 'collect_info')) {
try { try {
const latest = await leadStore.get(conv.uuid); const latest = await leadStore.get(conv.uuid);
if (!latest || !latest.opportunityId) { if (!latest || !latest.opportunityId) {
@@ -328,6 +332,46 @@ function createAgent(cfg, deps = {}) {
} }
} }
// 商机兜底(旧 bridge leadPipeline.maybeCreateLead 同款):槽位齐全但 LLM 没调 create_opportunity 时,
// 由代码直接建商机,避免“回复说已记录/会创建、CRM 里却没有”的漏建。
// 不要求邮箱验证(政策 A):公司 + 联系人(姓名/邮箱/电话)+ 需求即可。
try {
const createTool = byName.get('create_opportunity');
const calledThisTurn = (stats.toolCalls || []).some((t) => t.name === 'create_opportunity');
if (leadStore && createTool && !calledThisTurn) {
const latest = await leadStore.get(conv.uuid);
const slots = (latest && latest.slots) || {};
const hasContact = Boolean(slots.contactName || slots.email || slots.phone);
const ready = Boolean(slots.company && slots.productNeed && hasContact);
if (ready && !(latest && latest.opportunityId)) {
const started = Date.now();
const created = await createTool.handler({
company: slots.company,
contactName: slots.contactName,
email: slots.email,
productNeed: slots.productNeed,
quantity: slots.quantity,
budget: slots.budget,
closeDate: slots.closeDate,
phone: slots.phone,
}, ctx);
stats.toolCalls.push({ name: 'create_opportunity', ms: Date.now() - started, ok: true, auto: true });
stats.toolResults.push(created);
console.log(JSON.stringify({
ts: new Date().toISOString(),
agent: 'lead_auto_create',
conversation: conv.uuid,
created: Boolean(created && created.created),
reason: created && created.reason,
opportunity: created && created.opportunity_id,
verified: ctx.verified,
}));
}
}
} catch (err) {
console.error(JSON.stringify({ ts: new Date().toISOString(), warn: `lead auto create failed: ${err.message}` }));
}
// post-LLM 兜底:LLM 没问公司/联系方式时,代码强制追加多语言追问 // post-LLM 兜底:LLM 没问公司/联系方式时,代码强制追加多语言追问
let leadFollowup = false; let leadFollowup = false;
try { try {
+1 -1
View File
@@ -8,7 +8,7 @@ function loadSites(env) {
const fallback = { const fallback = {
defaultSite: 'www', defaultSite: 'www',
profiles: { profiles: {
www: { site: 'www', tenantId: null, languageId: null, currencyCode: 'USD', searchUrl: 'https://www.yehwang.com/search.html?q=', siteMap: 'site-map.json' }, www: { site: 'www', tenantId: null, languageId: null, currencyCode: 'EUR', searchUrl: 'https://www.yehwang.com/search.html?q=', siteMap: 'site-map.json' },
}, },
inboxes: {}, inboxes: {},
}; };
+9 -2
View File
@@ -17,17 +17,24 @@ function createEscalation({ client, handoffUserId = 1 }) {
const text = String(reply || '').replace(/\s+$/, ''); const text = String(reply || '').replace(/\s+$/, '');
const hints = { const hints = {
zh: '您想转人工客服吗?', zh: '您想转人工客服吗?',
en: 'Would you like to speak to a human agent?',
de: 'Möchten Sie mit einem Mitarbeiter sprechen?', de: 'Möchten Sie mit einem Mitarbeiter sprechen?',
fr: 'Souhaitez-vous parler à un conseiller ?', fr: 'Souhaitez-vous parler à un conseiller ?',
it: 'Vuole parlare con un operatore?', it: 'Vuole parlare con un operatore?',
nl: 'Wilt u met een medewerker spreken?', nl: 'Wilt u met een medewerker spreken?',
es: '¿Desea hablar con un agente humano?',
tr: 'Bir insan temsilcisiyle görüşmek ister misiniz?',
}; };
// 只输出一种语言:按用户输入判断语言,识别不到时用英文;不再拼英文兜底。
let lang = null; let lang = null;
if (/[\u4e00-\u9fff]/.test(query)) lang = 'zh'; if (/[\u4e00-\u9fff]/.test(query)) lang = 'zh';
else if (/[ğışç]/i.test(query) || /\b(merhaba|istiyorum|konuşmak|temsilci|fiyat|ürün|sipariş)\b/i.test(query)) lang = 'tr';
else if (/[äöüß]/i.test(query)) lang = 'de'; else if (/[äöüß]/i.test(query)) lang = 'de';
else if (/[éèêëàâùûîïôç]/i.test(query)) lang = 'fr'; else if (/[ñ¿¡]/i.test(query) || /\b(hola|quiero|hablar|agente|puedo|gracias|precio|pedido)\b/i.test(query)) lang = 'es';
else if (/\b(vorrei|parlare|operatore|desidera|posso|prezzo|ordine)\b/i.test(query)) lang = 'it';
else if (/[éèêëàâùûîïôç]/i.test(query) || /\b(bonjour|merci|conseiller|commande|livraison|prix|voudrais)\b/i.test(query)) lang = 'fr';
else if (/\b(hoe|voor|wilt|bent|geen|verzending|levering|bestelling)\b/i.test(query)) lang = 'nl'; else if (/\b(hoe|voor|wilt|bent|geen|verzending|levering|bestelling)\b/i.test(query)) lang = 'nl';
const option = lang ? `\n\n${hints[lang]} / Would you like to speak to a human agent?` : '\n\nWould you like to speak to a human agent?'; const option = `\n\n${hints[lang] || hints.en}`;
return text + option; return text + option;
} }
+11 -1
View File
@@ -14,11 +14,21 @@ const ASK = {
en: '\n\nBy the way, could you share your company name and a contact email? That way we can set up your wholesale account and follow up on your request.', en: '\n\nBy the way, could you share your company name and a contact email? That way we can set up your wholesale account and follow up on your request.',
}; };
// 公司/联系人信号:客户主动报公司名(含法律后缀)、邮箱、电话、联系人等。
// 命中它但数量单位不规整(如 "500 stainless steel rings")时,也要触发 lead 抽取兜底,
// 否则 LLM 漏调 collect_info 时槽位会为空,商机就建不出来。
const LEAD_SIGNAL_RE = /(@|\+?\d[\d\s().-]{6,}\d|gmbh|b\.?\s?v\.?\b|ltd\b|llc\b|inc\b|sarl|srl\b|limited\b|company|corporation|we are|we're|our company|contact\s*(?:name|person)?\s*[::]|\u516c\u53f8|\u8054\u7cfb\u4eba|\u91c7\u8d2d|wholesale|bulk)/i;
function hasBuyingIntent(text) { function hasBuyingIntent(text) {
const value = String(text || ''); const value = String(text || '');
return PRODUCT_RE.test(value) && QUANTITY_RE.test(value); return PRODUCT_RE.test(value) && QUANTITY_RE.test(value);
} }
function looksLikeLead(text) {
const value = String(text || '');
return PRODUCT_RE.test(value) && LEAD_SIGNAL_RE.test(value);
}
function missingLeadContacts(slots = {}, knownEmail = '') { function missingLeadContacts(slots = {}, knownEmail = '') {
const missing = []; const missing = [];
if (!slots.company) missing.push('company'); if (!slots.company) missing.push('company');
@@ -51,4 +61,4 @@ function appendLeadFollowup(reply, lang) {
return text + buildLeadFollowup(lang); return text + buildLeadFollowup(lang);
} }
module.exports = { hasBuyingIntent, missingLeadContacts, detectLanguage, buildLeadFollowup, alreadyAsksForLead, appendLeadFollowup }; module.exports = { hasBuyingIntent, looksLikeLead, LEAD_SIGNAL_RE, missingLeadContacts, detectLanguage, buildLeadFollowup, alreadyAsksForLead, appendLeadFollowup };
+1 -1
View File
@@ -57,7 +57,7 @@ function createOpportunityService({ cfg, twenty, leadStore, client }) {
function summaryFor({ ctx, slots, email, profile }) { function summaryFor({ ctx, slots, email, profile }) {
return [ return [
`Company: ${slots.company}`, `Company: ${slots.company}`,
`Contact: ${slots.contactName || '(not provided)'} <${email}>`, `Contact: ${slots.contactName || '(not provided)'}${email ? ` <${email}>` : ''}`,
`Need: ${slots.productNeed}`, `Need: ${slots.productNeed}`,
slots.quantity ? `Quantity: ${slots.quantity}` : null, slots.quantity ? `Quantity: ${slots.quantity}` : null,
slots.budget ? `Budget: ${slots.budget}` : null, slots.budget ? `Budget: ${slots.budget}` : null,
+11 -2
View File
@@ -17,7 +17,7 @@ const ACTION_RULES = [
{ type: 'pdf_invoice', re: /(?:PDF|预发票|proforma|invoice).{0,15}(?:已生成|已发送|attached|generated)|(?:attached|generated).{0,15}(?:pdf|invoice|proforma)/i }, { type: 'pdf_invoice', re: /(?:PDF|预发票|proforma|invoice).{0,15}(?:已生成|已发送|attached|generated)|(?:attached|generated).{0,15}(?:pdf|invoice|proforma)/i },
{ type: 'team_notify', re: /(?:团队|销售|同事).{0,12}(?:已通知|已联系|会联系|已收到|已转)|team\s+(?:has\s+been|was)\s+notified|notified\s+(?:the\s+)?team|sales\s+team\s+has\s+been\s+notified/i }, { type: 'team_notify', re: /(?:团队|销售|同事).{0,12}(?:已通知|已联系|会联系|已收到|已转)|team\s+(?:has\s+been|was)\s+notified|notified\s+(?:the\s+)?team|sales\s+team\s+has\s+been\s+notified/i },
{ type: 'opportunity_create', re: /opportunity\s+(?:created|has been created)|商机(?:已|已经)?(?:创建|建立)|opportunity\s+id/i, requiresTool: 'create_opportunity' }, { type: 'opportunity_create', re: /opportunity\s+(?:created|has been created)|商机(?:已|已经)?(?:创建|建立)|opportunity\s+id/i, requiresTool: 'create_opportunity' },
{ type: 'internal_crm', re: /\bCRM\b|商机|opportunity\s+(?:id|created|recorded|has been)|created\s+an?\s+opportunity/i }, { type: 'internal_crm', re: /\bCRM\b|商机|opportunity\s+(?:id|created|recorded|has been|reference|name|number|no\.?|#)|created\s+an?\s+opportunity|\bopportunity\b\s*[::]/i },
]; ];
const STOCK_RE = /(?:有货|库存|现货|缺货|in stock|out of stock|available|backorder|restock|pre-?order)/i; const STOCK_RE = /(?:有货|库存|现货|缺货|in stock|out of stock|available|backorder|restock|pre-?order)/i;
@@ -98,6 +98,13 @@ function actionClarification(lang, hasOpportunity) {
return `${hasOpportunity ? 'Your request has been noted; a relevant colleague will follow up.\n\n' : ''}Note: I can't place orders, create order numbers, or send emails/PDFs from this chat; please complete the order in your account cart: https://www.yehwang.com/cart.`; return `${hasOpportunity ? 'Your request has been noted; a relevant colleague will follow up.\n\n' : ''}Note: I can't place orders, create order numbers, or send emails/PDFs from this chat; please complete the order in your account cart: https://www.yehwang.com/cart.`;
} }
// CRM/商机类措辞被拦时:只把真实发生了的事(需求已记录、同事跟进)告诉客户,
// 不要拼接“无法下单/购物车付款”这类误导性的订单话术。
function crmClarification(lang, hasOpportunity) {
if (lang === 'zh') return hasOpportunity ? '您的需求已记录,相关同事会与您联系。' : '相关同事会与您联系。';
return hasOpportunity ? 'Your request has been noted; a relevant colleague will follow up.' : 'A relevant colleague will follow up.';
}
function replaceUnsupported(content, amounts, stockNumbers, lang) { function replaceUnsupported(content, amounts, stockNumbers, lang) {
let out = String(content || ''); let out = String(content || '');
const priceText = lang === 'zh' ? '价格以商品页为准' : 'price shown on the product page'; const priceText = lang === 'zh' ? '价格以商品页为准' : 'price shown on the product page';
@@ -155,6 +162,7 @@ function guardReply({ content, toolResults, toolCalls, lang = 'en' }) {
blocked = true; blocked = true;
rewritten = true; rewritten = true;
reasons.push(...violations.map((v) => v.type)); reasons.push(...violations.map((v) => v.type));
const crmOnly = violations.every((v) => v.type === 'internal_crm' || v.type === 'opportunity_create');
// HTML 回复不做句子切割,避免破坏标签,直接安全话术 // HTML 回复不做句子切割,避免破坏标签,直接安全话术
if (/<[a-z][^>]*>/i.test(raw)) { if (/<[a-z][^>]*>/i.test(raw)) {
out = safeReply(lang, { hasOpportunity }); out = safeReply(lang, { hasOpportunity });
@@ -163,7 +171,8 @@ function guardReply({ content, toolResults, toolCalls, lang = 'en' }) {
.filter((sentence) => !detectActionClaims(sentence).some((h) => !h.requiresTool || !hasTool(toolCalls, h.requiresTool))) .filter((sentence) => !detectActionClaims(sentence).some((h) => !h.requiresTool || !hasTool(toolCalls, h.requiresTool)))
.join('') .join('')
.trim(); .trim();
out = isMeaningful(kept) ? `${kept}\n\n${actionClarification(lang, hasOpportunity)}` : safeReply(lang, { hasOpportunity }); const clarification = crmOnly ? crmClarification(lang, hasOpportunity) : actionClarification(lang, hasOpportunity);
out = isMeaningful(kept) ? `${kept}\n\n${clarification}` : (crmOnly ? crmClarification(lang, hasOpportunity) : safeReply(lang, { hasOpportunity }));
} }
} }
+4 -4
View File
@@ -17,13 +17,13 @@ TOOL DISCIPLINE:
- search_products: any product-finding request (recommend/show/find/buy products). A bare product/category word or short phrase is a product request too (e.g. 戒指, 项链, rings, necklace, earrings): call search_products before answering. Use exact product names from results; include prices, stock and full URLs when available. If a category link is returned, include it as a full clickable URL. If results are empty, say so honestly. Never reuse product names, prices or links from earlier turns unless they are in THIS turn's tool results. - search_products: any product-finding request (recommend/show/find/buy products). A bare product/category word or short phrase is a product request too (e.g. 戒指, 项链, rings, necklace, earrings): call search_products before answering. Use exact product names from results; include prices, stock and full URLs when available. If a category link is returned, include it as a full clickable URL. If results are empty, say so honestly. Never reuse product names, prices or links from earlier turns unless they are in THIS turn's tool results.
- search_orders: only for the verified customer's own orders. If the customer gives an order number, pass it to get full detail (status, items, totals, address, shipping). Never accept another person's email; the identity comes from the platform. - search_orders: only for the verified customer's own orders. If the customer gives an order number, pass it to get full detail (status, items, totals, address, shipping). Never accept another person's email; the identity comes from the platform.
- search_inventory: real-time stock/ETA for a specific product; prefer product_id returned by search_products. - search_inventory: real-time stock/ETA for a specific product; prefer product_id returned by search_products.
- collect_info: whenever you learn any lead details (company, contact name, phone, product need, quantity, budget, close date), call it immediately so nothing is lost. Record productNeed exactly as the customer stated, never invent or substitute. - collect_info: whenever you learn any lead details (company, contact name, email, phone, product need, quantity, budget, close date), call it immediately so nothing is lost. Record productNeed exactly as the customer stated, never invent or substitute.
- search_email_history: only for a verified customer asking about their own past emails (quotes, previous inquiries, "I emailed you before"). Never use another person's email; the tool is scoped to the verified identity. - search_email_history: only for a verified customer asking about their own past emails (quotes, previous inquiries, "I emailed you before"). Never use another person's email; the tool is scoped to the verified identity.
- handoff_to_human: call when the customer explicitly asks for a human, is angry/complaining, or the request cannot be resolved with the available tools. Use it instead of only saying "I'll transfer you". - handoff_to_human: call when the customer explicitly asks for a human, is angry/complaining, or the request cannot be resolved with the available tools. Use it instead of only saying "I'll transfer you".
- create_opportunity: only after company + contact name + product need are all known; call once per customer. If it returns created=false, do not create another. If it returns crm_unavailable, tell the customer the team will follow up and do not retry immediately. NEVER tell the customer about CRM, opportunity IDs, internal records, assigned users, or tool results. If it was created, only say their request has been noted and a relevant colleague will contact them. - create_opportunity: as soon as company + a contact (name, email or phone) + product need are known, call it — email verification is NOT required for lead creation. Call it exactly once per customer. If it returns created=false, do not create another. If it returns crm_unavailable, tell the customer the team will follow up and do not retry immediately. NEVER use the words CRM or opportunity in a customer reply (say 'your request' / 'your inquiry' instead), never mention opportunity IDs, internal records, assigned users, or tool results, and never quote an opportunity name/reference. Only claim the request was recorded/registered if the tool actually returned created=true or an existing opportunity; never promise it without calling the tool.
VERIFICATION (important): VERIFICATION (important):
- Order lookup and opportunity creation require a verified customer. - Order lookup requires a verified customer. Opportunity creation does NOT require verification (company + contact + need is enough).
- If the customer is not verified and asks for private order data: ask for their email, call send_verification_code({email}), tell them a code was sent, then wait for the code and call verify_email_code({code}). - If the customer is not verified and asks for private order data: ask for their email, call send_verification_code({email}), tell them a code was sent, then wait for the code and call verify_email_code({code}).
- Do not call send_verification_code until the customer provides an email. Never invent an email address. - Do not call send_verification_code until the customer provides an email. Never invent an email address.
- The code expires in 10 minutes and allows 5 attempts. If verification fails, ask them to check the code or request a new one. - The code expires in 10 minutes and allows 5 attempts. If verification fails, ask them to check the code or request a new one.
@@ -31,7 +31,7 @@ VERIFICATION (important):
SALES FLOW: SALES FLOW:
- For clear buying intent (product + quantity/budget) but missing company or contact, ask for the missing pieces naturally in the same reply. - For clear buying intent (product + quantity/budget) but missing company or contact, ask for the missing pieces naturally in the same reply.
- Do not create an opportunity until the required three fields exist. - Create the opportunity only after company + contact + product need exist; do not create it for a vague inquiry.
- Do not proactively push discounts; if asked, volume/membership tiers are available (mention only when asked). - Do not proactively push discounts; if asked, volume/membership tiers are available (mention only when asked).
ANTI-HALLUCINATION: ANTI-HALLUCINATION:
+10 -10
View File
@@ -221,12 +221,13 @@ const TOOL_DEFS = [
{ {
name: 'collect_info', name: 'collect_info',
description: 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.', 'Record customer lead details incrementally across turns (company, contact name, email, 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: { parameters: {
type: 'object', type: 'object',
properties: { properties: {
company: { type: 'string', description: 'customer company name' }, company: { type: 'string', description: 'customer company name' },
contactName: { type: 'string', description: 'contact person full name' }, contactName: { type: 'string', description: 'contact person full name' },
email: { type: 'string', description: 'contact email if the customer provided one' },
phone: { type: 'string', description: 'phone number if stated' }, phone: { type: 'string', description: 'phone number if stated' },
productNeed: { type: 'string', description: 'what the customer asked for, in their own words' }, productNeed: { type: 'string', description: 'what the customer asked for, in their own words' },
quantity: { type: 'string', description: 'quantity if stated' }, quantity: { type: 'string', description: 'quantity if stated' },
@@ -240,7 +241,7 @@ const TOOL_DEFS = [
{ {
name: 'send_verification_code', name: 'send_verification_code',
description: 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.', 'Send a 6-digit email verification code to the customer. Only call after the customer provides their email address and private data (orders) requires verification. Never invent an email address.',
parameters: { parameters: {
type: 'object', type: 'object',
properties: { properties: {
@@ -253,7 +254,7 @@ const TOOL_DEFS = [
{ {
name: 'verify_email_code', name: 'verify_email_code',
description: 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.', '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 tools.',
parameters: { parameters: {
type: 'object', type: 'object',
properties: { properties: {
@@ -279,12 +280,13 @@ const TOOL_DEFS = [
{ {
name: 'create_opportunity', name: 'create_opportunity',
description: 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.', 'Create a CRM opportunity in Twenty once you know the company, a contact (name, email or phone) and the product/need. Call it exactly once per customer; if it returns created=false an opportunity already exists. Email verification is NOT required for lead creation.',
parameters: { parameters: {
type: 'object', type: 'object',
properties: { properties: {
company: { type: 'string', description: 'customer company name' }, company: { type: 'string', description: 'customer company name' },
contactName: { type: 'string', description: 'contact person full name' }, contactName: { type: 'string', description: 'contact person full name' },
email: { type: 'string', description: 'contact email if the customer provided one' },
productNeed: { type: 'string', description: 'what the customer asked for, in their own words' }, productNeed: { type: 'string', description: 'what the customer asked for, in their own words' },
quantity: { type: 'string', description: 'quantity if stated' }, quantity: { type: 'string', description: 'quantity if stated' },
budget: { type: 'string', description: 'budget if stated, e.g. "€500"' }, budget: { type: 'string', description: 'budget if stated, e.g. "€500"' },
@@ -293,7 +295,7 @@ const TOOL_DEFS = [
}, },
required: ['company', 'contactName', 'productNeed'], required: ['company', 'contactName', 'productNeed'],
}, },
requiresVerification: true, requiresVerification: false,
}, },
]; ];
@@ -341,7 +343,7 @@ function createTools(cfg, deps = {}) {
if (!conversationUuid) throw new ToolError(400, 'missing_conversation', 'conversation context is required'); if (!conversationUuid) throw new ToolError(400, 'missing_conversation', 'conversation context is required');
const existing = await leadStore.get(conversationUuid); const existing = await leadStore.get(conversationUuid);
const slots = { ...(existing ? existing.slots : {}) }; const slots = { ...(existing ? existing.slots : {}) };
for (const key of ['company', 'contactName', 'phone', 'productNeed', 'quantity', 'budget', 'closeDate']) { for (const key of ['company', 'contactName', 'email', 'phone', 'productNeed', 'quantity', 'budget', 'closeDate']) {
const value = args && args[key] != null ? String(args[key]).trim() : ''; const value = args && args[key] != null ? String(args[key]).trim() : '';
if (value) slots[key] = value; if (value) slots[key] = value;
} }
@@ -673,11 +675,9 @@ function createTools(cfg, deps = {}) {
} }
async function createOpportunity(args, ctx) { async function createOpportunity(args, ctx) {
if (!ctx.verified) { // 线索建商机不要求邮箱验证(对齐旧 bridge leadPipeline):公司 + 联系人 + 需求即可。
throw new ToolError(403, 'verification_required', 'Creating an opportunity requires a verified contact. Ask the customer to verify their email first.'); // 订单/邮箱历史仍要求 verified,邮箱只从身份头取;商机允许用客户在对话里给的 email 做去重/联系人。
}
const email = String(ctx.email || '').trim().toLowerCase(); 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'); if (!cfg.twenty.apiKey) throw new ToolError(503, 'not_configured', 'TWENTY_API_KEY is not configured');
const conversationUuid = String(ctx.conversationUuid || ''); const conversationUuid = String(ctx.conversationUuid || '');
if (!conversationUuid) throw new ToolError(400, 'missing_conversation', 'conversation context is required'); if (!conversationUuid) throw new ToolError(400, 'missing_conversation', 'conversation context is required');
+177
View File
@@ -3,6 +3,7 @@
const test = require('node:test'); const test = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const { createAgent } = require('../src/agent'); const { createAgent } = require('../src/agent');
const { createTools } = require('../src/tools');
function fakeTools() { function fakeTools() {
const tools = [{ const tools = [{
@@ -180,3 +181,179 @@ test('agent: forces search_products when the LLM answers without tools', async (
assert.doesNotMatch(sent, /fake-ring/); assert.doesNotMatch(sent, /fake-ring/);
assert.match(sent, /https:\/\/www\.yehwang\.com\/triple-rings-ring-gold-color-17590\.html/); assert.match(sent, /https:\/\/www\.yehwang\.com\/triple-rings-ring-gold-color-17590\.html/);
}); });
// ---- 商机兜底:LLM 只调 collect_info 不调 create_opportunity 时,代码自动建商机 ----
function leadTools(twenty) {
const rpc = { async call() { return []; } };
return createTools(
{
recommendUrl: 'jsonrpc://127.0.0.1:1', orderApiUrl: '', inventoryApiUrl: '', rpcTimeoutMs: 50,
siteSearchUrl: 'https://www.yehwang.com/search.html?q=',
twenty: { baseURL: 'http://localhost:1', graphqlPath: '/graphql', workspaceId: 'w', apiKey: 'k' },
},
{ rpc, twenty, client: { sendPrivateNote: async () => {}, assignUser: async () => {} } }
);
}
function leadTwenty() {
const calls = [];
return {
calls,
async findPersonByEmail(email) { calls.push(['findPersonByEmail', email]); return null; },
async findOpportunitiesByPersonId() { return []; },
async createPerson(p) { calls.push(['createPerson', p]); return { id: 'person-1' }; },
async findCompanyByName() { return null; },
async createCompany(n) { calls.push(['createCompany', n]); return { id: 'company-1' }; },
async createOpportunity(o) { calls.push(['createOpportunity', o]); return { id: 'opp-auto-1', name: o.name }; },
async updateOpportunity(id, patch) { calls.push(['updateOpportunity', id, patch]); return { id }; },
async updateOpportunityStage(id, stage) { calls.push(['updateOpportunityStage', id, stage]); return { id, stage }; },
async createNote(c) { calls.push(['createNote', c]); return 'note-1'; },
async linkNoteToOpportunity(n, o) { calls.push(['linkNoteToOpportunity', n, o]); return { id: 'nt' }; },
async createTask(t, b, d, o) { calls.push(['createTask', t, o]); return 'task-1'; },
};
}
function leadAgentDeps(twenty, tools, contact) {
let llmCalls = 0;
let sent = '';
return {
get sent() { return sent; },
deps: {
llm: {
enabled: true,
model: 'fake',
chat: async () => {
llmCalls += 1;
if (llmCalls === 1) {
return {
content: '',
toolCalls: [{
id: 'tc-collect-1',
function: {
name: 'collect_info',
arguments: JSON.stringify({ company: 'Acme GmbH', contactName: 'John Doe', productNeed: '500 pieces for our shops', quantity: '500' }),
},
}],
usage: null,
};
}
return { content: 'Your request has been noted; a colleague will follow up.', toolCalls: [], usage: null };
},
},
client: {
getMessages: async () => [{ id: 1, text_content: 'We are Acme GmbH, contact John Doe, we want 500 pieces for our shops.', sender_type: 'contact', private: false }],
sendMessage: async (_uuid, content) => { sent = content; },
sendPrivateNote: async () => {},
},
tools,
verification: { getVerifiedEmail: async () => (contact.type === 'contact' ? contact.email : null), sendCode: async () => ({}), verifyCode: async () => ({}) },
metrics: { record: async () => {}, enabled: false },
contextManager: { compressHistory: async (_c, h) => h },
},
conv: { uuid: 'conv-auto-opp-1', inbox_id: '1', channel: 'livechat', contact },
};
}
test('agent: auto-creates opportunity for a verified single-message lead', async () => {
const twenty = leadTwenty();
const tools = leadTools(twenty);
const ctx = leadAgentDeps(twenty, tools, { type: 'contact', email: 'buyer@test-customer.nl' });
ctx.conv.uuid = 'conv-auto-opp-verified';
const agent = createAgent({ sites: [], defaultSite: 'www' }, ctx.deps);
await agent.run(ctx.conv, { uuid: 'msg-auto-1', text_content: 'We are Acme GmbH, contact John Doe, we want 500 pieces for our shops.', content: '...' });
assert.equal(twenty.calls.filter((c) => c[0] === 'createOpportunity').length, 1);
assert.match(twenty.calls.find((c) => c[0] === 'createOpportunity')[1].name, /Acme GmbH/);
assert.match(ctx.sent, /noted/i);
});
test('agent: auto-creates opportunity for an unverified single-message lead (policy A)', async () => {
const twenty = leadTwenty();
const tools = leadTools(twenty);
const ctx = leadAgentDeps(twenty, tools, { type: 'visitor' });
ctx.conv.uuid = 'conv-auto-opp-anon';
const agent = createAgent({ sites: [], defaultSite: 'www' }, ctx.deps);
await agent.run(ctx.conv, { uuid: 'msg-auto-2', text_content: 'We are Acme GmbH, contact John Doe, we want 500 pieces for our shops.', content: '...' });
assert.equal(twenty.calls.filter((c) => c[0] === 'createOpportunity').length, 1);
assert.ok(twenty.calls.some((c) => c[0] === 'createCompany' && c[1] === 'Acme GmbH'));
});
test('agent: does not auto-create when the LLM already created the opportunity', async () => {
const twenty = leadTwenty();
const tools = leadTools(twenty);
let sent = '';
let llmCalls = 0;
const deps = {
llm: {
enabled: true,
model: 'fake',
chat: async () => {
llmCalls += 1;
if (llmCalls === 1) {
return {
content: '',
toolCalls: [{ id: 'tc-1', function: { name: 'collect_info', arguments: JSON.stringify({ company: 'Acme GmbH', contactName: 'John Doe', productNeed: '500 pieces' }) } }],
usage: null,
};
}
if (llmCalls === 2) {
return {
content: '',
toolCalls: [{ id: 'tc-2', function: { name: 'create_opportunity', arguments: JSON.stringify({ company: 'Acme GmbH', contactName: 'John Doe', productNeed: '500 pieces' }) } }],
usage: null,
};
}
return { content: 'Your request has been noted; a colleague will follow up.', toolCalls: [], usage: null };
},
},
client: {
getMessages: async () => [{ id: 1, text_content: 'We are Acme GmbH, contact John Doe, we want 500 pieces.', sender_type: 'contact', private: false }],
sendMessage: async (_uuid, content) => { sent = content; },
sendPrivateNote: async () => {},
},
tools,
verification: { getVerifiedEmail: async () => 'buyer@test-customer.nl', sendCode: async () => ({}), verifyCode: async () => ({}) },
metrics: { record: async () => {}, enabled: false },
contextManager: { compressHistory: async (_c, h) => h },
};
const conv = { uuid: 'conv-opp-once', inbox_id: '1', channel: 'livechat', contact: { type: 'contact', email: 'buyer@test-customer.nl' } };
const agent = createAgent({ sites: [], defaultSite: 'www' }, deps);
await agent.run(conv, { uuid: 'msg-once', text_content: 'We are Acme GmbH, contact John Doe, we want 500 pieces.', content: '...' });
assert.equal(twenty.calls.filter((c) => c[0] === 'createOpportunity').length, 1);
});
test('agent: extracts lead fields when the LLM skips collect_info (company mentioned, unit-less quantity)', async () => {
const twenty = leadTwenty();
const tools = leadTools(twenty);
let sent = '';
let llmCalls = 0;
const deps = {
llm: {
enabled: true,
model: 'fake',
chat: async () => {
llmCalls += 1;
if (llmCalls === 1) {
return { content: 'Could you share your contact details so we can follow up?', toolCalls: [], usage: null };
}
// extractLeadFields 的调用:返回 JSON
return { content: JSON.stringify({ company: 'Leadfix GmbH', contactName: 'Anna Klein', productNeed: '500 stainless steel rings', quantity: '500' }), toolCalls: [], usage: null };
},
},
client: {
getMessages: async () => [{ id: 1, text_content: 'We are Leadfix GmbH, contact Anna Klein. We need 500 stainless steel rings.', sender_type: 'contact', private: false }],
sendMessage: async (_uuid, content) => { sent = content; },
sendPrivateNote: async () => {},
},
tools,
verification: { getVerifiedEmail: async () => null, sendCode: async () => ({}), verifyCode: async () => ({}) },
metrics: { record: async () => {}, enabled: false },
contextManager: { compressHistory: async (_c, h) => h },
};
const conv = { uuid: 'conv-lead-extract', inbox_id: '1', channel: 'livechat', contact: { type: 'visitor' } };
const agent = createAgent({ sites: [], defaultSite: 'www' }, deps);
await agent.run(conv, { uuid: 'msg-lead-extract', text_content: 'We are Leadfix GmbH, contact Anna Klein. We need 500 stainless steel rings.', content: '...' });
assert.equal(llmCalls >= 2, true);
assert.equal(twenty.calls.filter((c) => c[0] === 'createOpportunity').length, 1);
assert.ok(twenty.calls.some((c) => c[0] === 'createCompany' && c[1] === 'Leadfix GmbH'));
});
+9 -1
View File
@@ -2,7 +2,7 @@
const test = require('node:test'); const test = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const { hasBuyingIntent, missingLeadContacts, detectLanguage, appendLeadFollowup, alreadyAsksForLead } = require('../src/lead-intent'); const { hasBuyingIntent, looksLikeLead, missingLeadContacts, detectLanguage, appendLeadFollowup, alreadyAsksForLead } = require('../src/lead-intent');
test('lead-intent: product + quantity is buying intent', () => { test('lead-intent: product + quantity is buying intent', () => {
assert.equal(hasBuyingIntent('I want 500 pieces of stainless steel rings'), true); assert.equal(hasBuyingIntent('I want 500 pieces of stainless steel rings'), true);
@@ -26,3 +26,11 @@ test('lead-intent: detects existing ask in reply', () => {
assert.equal(alreadyAsksForLead('Could you share your company name and email?'), true); assert.equal(alreadyAsksForLead('Could you share your company name and email?'), true);
assert.equal(alreadyAsksForLead('Here are some rings.'), false); assert.equal(alreadyAsksForLead('Here are some rings.'), false);
}); });
test('lead-intent: company/contact signal counts as a lead even without unit-quantity', () => {
// 单位不规整(500 stainless steel rings)时 hasBuyingIntent 为 false,但已是明确线索
assert.equal(hasBuyingIntent('We are Acme GmbH, contact Anna Klein. We need 500 stainless steel rings.'), false);
assert.equal(looksLikeLead('We are Acme GmbH, contact Anna Klein. We need 500 stainless steel rings.'), true);
assert.equal(looksLikeLead('Our company is Test Buyer BV, contact name Jan Jansen.'), false); // 没有商品词
assert.equal(looksLikeLead('Do you have rings?'), false); // 没有公司/联系人信号
});
+28
View File
@@ -180,3 +180,31 @@ test('guard: keeps meaningful anchor text when the URL is removed', () => {
assert.doesNotMatch(r.content, /fake-guide/); assert.doesNotMatch(r.content, /fake-guide/);
assert.match(r.content, /the product care guide/); assert.match(r.content, /the product care guide/);
}); });
test('guard: CRM wording is replaced with a colleague follow-up, not a cart note', () => {
const r = guardReply({
content: 'Great — 500 stainless steel rings for your shops.\nI have created an opportunity for your request.',
toolResults: [{ created: true, opportunity_id: 'opp-1' }],
toolCalls: [{ name: 'create_opportunity', ok: true }],
lang: 'en',
});
assert.equal(r.blocked, true);
assert.ok(r.reasons.includes('internal_crm'));
assert.match(r.content, /stainless steel rings/i);
assert.match(r.content, /request has been noted/i);
assert.doesNotMatch(r.content, /account cart/i);
});
test('guard: strips leaked opportunity reference labels', () => {
const r = guardReply({
content: '🎯\n**Opportunity reference**: *Acme GmbH — 500 rings*\nOur sales team will contact you shortly.',
toolResults: [{ created: true, opportunity_id: 'opp-1' }],
toolCalls: [{ name: 'create_opportunity', ok: true }],
lang: 'en',
});
assert.equal(r.blocked, true);
assert.ok(r.reasons.includes('internal_crm'));
assert.doesNotMatch(r.content, /opportunity/i);
assert.match(r.content, /sales team will contact you/i);
assert.doesNotMatch(r.content, /account cart/i);
});
+69 -3
View File
@@ -487,15 +487,54 @@ test('search_email_history: scopes to verified email, ignores args.email', async
assert.equal(out.count, 0); assert.equal(out.count, 0);
}); });
test('create_opportunity: requires verified contact', async () => { test('create_opportunity: unverified lead is created from contact name (policy A)', async () => {
const twenty = fakeTwenty();
const tools = createTools(baseCfg, { rpc: fakeRpc(() => []), twenty });
const tool = tools.find((t) => t.name === 'create_opportunity');
const out = await tool.handler(
{ company: 'Anon Co', contactName: 'Anna Klein', productNeed: '500 rings' },
{ verified: false, conversationUuid: `conv-anon-${Date.now()}` }
);
assert.equal(out.created, true);
assert.equal(twenty.calls.filter((c) => c[0] === 'createOpportunity').length, 1);
const note = twenty.calls.find((c) => c[0] === 'createNote');
assert.match(note[1], /Contact: Anna Klein/);
assert.doesNotMatch(note[1], /<>/);
});
test('create_opportunity: unverified lead keeps the email provided in chat', async () => {
const twenty = fakeTwenty();
const tools = createTools(baseCfg, { rpc: fakeRpc(() => []), twenty });
const tool = tools.find((t) => t.name === 'create_opportunity');
const out = await tool.handler(
{ company: 'Anon Co', contactName: 'Anna Klein', email: 'anna@anon.test', productNeed: '500 rings' },
{ verified: false, conversationUuid: `conv-anon-mail-${Date.now()}` }
);
assert.equal(out.created, true);
const person = twenty.calls.find((c) => c[0] === 'createPerson');
assert.equal(person[1].email, 'anna@anon.test');
});
test('create_opportunity: still requires company + contact + need', async () => {
const tools = createTools(baseCfg, { rpc: fakeRpc(() => []), twenty: fakeTwenty() }); const tools = createTools(baseCfg, { rpc: fakeRpc(() => []), twenty: fakeTwenty() });
const tool = tools.find((t) => t.name === 'create_opportunity'); const tool = tools.find((t) => t.name === 'create_opportunity');
await assert.rejects( await assert.rejects(
() => tool.handler({ company: 'A', contactName: 'B', productNeed: 'C' }, { verified: false }), () => tool.handler({ company: 'A', productNeed: 'C' }, { verified: false, conversationUuid: 'conv-missing' }),
(err) => err instanceof ToolError && err.status === 403 (err) => err instanceof ToolError && err.status === 400
); );
}); });
test('collect_info: stores the email slot', async () => {
const tools = createTools(baseCfg, { rpc: fakeRpc(() => []), twenty: fakeTwenty() });
const tool = tools.find((t) => t.name === 'collect_info');
const out = await tool.handler(
{ company: 'Acme BV', contactName: 'John Doe', email: 'john@acme.test', productNeed: 'gold hoops' },
{ conversationUuid: 'conv-collect-email', verified: false }
);
assert.equal(out.slots.email, 'john@acme.test');
assert.deepEqual(out.missing, []);
});
test('create_opportunity: dedupes by existing opportunity', async () => { test('create_opportunity: dedupes by existing opportunity', async () => {
const twenty = fakeTwenty({ personId: 'person-1', existingOpps: [{ id: 'opp-old', name: 'Old', stage: 'NEW' }] }); const twenty = fakeTwenty({ personId: 'person-1', existingOpps: [{ id: 'opp-old', name: 'Old', stage: 'NEW' }] });
const tools = createTools(baseCfg, { rpc: fakeRpc(() => []), twenty }); const tools = createTools(baseCfg, { rpc: fakeRpc(() => []), twenty });
@@ -530,3 +569,30 @@ test('create_opportunity: creates opportunity + note + follow-up task', async ()
const personIdx = twenty.calls.findIndex((c) => c[0] === 'createPerson'); const personIdx = twenty.calls.findIndex((c) => c[0] === 'createPerson');
assert.ok(companyIdx >= 0 && personIdx > companyIdx, 'company must be created before person'); assert.ok(companyIdx >= 0 && personIdx > companyIdx, 'company must be created before person');
}); });
test('sites.json: www/eu -> EUR, us -> USD', () => {
const sites = require('../config/sites.json');
assert.equal(sites.profiles.www.currencyCode, 'EUR');
assert.equal(sites.profiles.eu.currencyCode, 'EUR');
assert.equal(sites.profiles.us.currencyCode, 'USD');
});
test('search_products: passes the site currency to the product RPC', async () => {
const rpc = fakeRpc(() => []);
const cfg = {
...baseCfg,
sites: {
defaultSite: 'www',
profiles: { www: { site: 'www', tenantId: 2, languageId: 1, currencyCode: 'EUR', searchUrl: 'https://www.yehwang.com/search.html?q=' } },
inboxes: { '1': 'www' },
},
};
const tools = createTools(cfg, { rpc, twenty: fakeTwenty() });
const tool = tools.find((t) => t.name === 'search_products');
await tool.handler({ query: 'rings' }, { inboxId: '1' });
const call = rpc.calls[0];
assert.equal(call.method, '/product/list');
assert.equal(call.params[0].currencyCode, 'EUR');
assert.equal(call.params[0].tenantBaseCurrency, 'EUR');
assert.equal(call.params[0].reqContext.currencyCode, 'EUR');
});
+3
View File
@@ -199,3 +199,6 @@ ss -ltnp | grep -E ':(9000|3100|4100|5440|6390)\s'
| WhatsApp webhook 收不到 | 没有公网 HTTPS / Meta 回调地址不对 | 本地联调跑 `bash scripts/public-https.sh`;测试/生产使用正式域名,不要用 Quick Tunnel | | WhatsApp webhook 收不到 | 没有公网 HTTPS / Meta 回调地址不对 | 本地联调跑 `bash scripts/public-https.sh`;测试/生产使用正式域名,不要用 Quick Tunnel |
| 公网访问到管理后台 | 网关白名单配置 | `services/public-gateway` 只放行 `/webhooks/whatsapp/*`、`/hc/*`、`/widget*`、`/static/public/*`,其余 404 | | 公网访问到管理后台 | 网关白名单配置 | `services/public-gateway` 只放行 `/webhooks/whatsapp/*`、`/hc/*`、`/widget*`、`/static/public/*`,其余 404 |
| AI 回复"系统暂时无法查询订单" | RPC 不可达,bridge 8s 超时 | 本地开发跑 `bash scripts/rpc-tunnel.sh --apply`;测试/生产检查内网 RPC 地址/网络策略,不要用开发机隧道 | | AI 回复"系统暂时无法查询订单" | RPC 不可达,bridge 8s 超时 | 本地开发跑 `bash scripts/rpc-tunnel.sh --apply`;测试/生产检查内网 RPC 地址/网络策略,不要用开发机隧道 |
| WhatsApp 真实消息收不到,但 Meta "Send to My Server" 测试事件能到 | WABA 级 `override_callback_uri` 优先于 App 级 callback,且 Meta 对 override 的验证常返回 403(与目标 URL 无关) | `GET /<WABA_ID>/subscribed_apps` 确认 override;`DELETE /<WABA_ID>/subscribed_apps` 后重新 `POST`(不带 override)清掉,确认 App 级 callback 指向目标域名,再发真实消息验证 |
| 多个栈共用同一个 WhatsApp 号,消息被旧环境接走(旧环境 AI 回复、新环境无日志) | crm-new 与旧 crm-ai-demo 配置了同一 `WA_PHONE_NUMBER_ID` / WABA,旧栈 tunnel 仍在接收 | 停旧栈 WhatsApp 接收(停旧 tunnel / 禁用旧 inbox),或给新环境申请独立测试号;不要让两套同时接同一个号 |
| 新建 WhatsApp inbox 后 webhook 验证 403 `verify token mismatch` | 创建接口传入的 `webhook_verify_token` 可能未真正生效,inbox 仍是默认值;`GET /api/v1/inboxes/<id>` 返回的是掩码值,不能用来核对 | 用 `PUT /api/v1/inboxes/<id>` 带完整 config 重新保存一次,再请求 `GET /webhooks/whatsapp/<id>?hub.mode=subscribe&hub.verify_token=...&hub.challenge=...` 确认返回 200 |