chore: CRM-NEW baseline

This commit is contained in:
wangliang
2026-10-23 10:35:28 +08:00
commit aa499f6dbf
111 changed files with 15126 additions and 0 deletions
+213
View File
@@ -0,0 +1,213 @@
#!/usr/bin/env node
'use strict';
/**
* 配置 Email 渠道(163):
* 1. 创建/更新 email inbox(IMAP 走 imap-id-proxy,绕开 163 的 "Unsafe Login")
* 2. 创建/更新默认外发邮件模板(email_outgoing)
* 3. 创建/更新 automation:email inbox 新会话 -> 分配 AI Assistant
*
* 依赖:docker compose 里的 imap-proxy 服务(services/imap-id-proxy)。
* 用法:node scripts/add-email-inbox.js
*/
const fs = require('fs');
const path = require('path');
const ROOT = path.resolve(__dirname, '..');
const OLD_MAIL_CFG = '/home/dev/code/crm/crm-ai-demo/bridge/mail.config.json';
const INBOX_NAME = 'Email 163';
const RULE_NAME = 'Assign email conversations to AI Assistant';
const ASSISTANT_NAME = 'Yehwang Assistant';
const TEMPLATE_NAME = 'Default outgoing email';
const TEMPLATE_BODY = `<!DOCTYPE html>
<html><body style="font-family: Arial, sans-serif; color: #1f2937; line-height: 1.6;">
<p>Hello,</p>
{{ template "content" . }}
<p style="margin-top: 24px;">Best regards,<br>Yehwang Support</p>
</body></html>`;
function loadEnv(file) {
const out = {};
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
const t = line.trim();
if (!t || t.startsWith('#')) continue;
const eq = t.indexOf('=');
if (eq < 0) continue;
let v = t.slice(eq + 1).trim();
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
out[t.slice(0, eq).trim()] = v;
}
return out;
}
function createClient(baseURL) {
const cookies = new Map();
let csrf = '';
const cookieHeader = () => [...cookies.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
function store(res) {
const list = typeof res.headers.getSetCookie === 'function' ? res.headers.getSetCookie() : [res.headers.get('set-cookie')].filter(Boolean);
for (const c of list) {
const [pair] = c.split(';');
const eq = pair.indexOf('=');
if (eq > 0) {
const k = pair.slice(0, eq).trim();
const v = pair.slice(eq + 1).trim();
cookies.set(k, v);
if (k === 'csrf_token') csrf = v;
}
}
}
async function req(method, p, body) {
const headers = { Accept: 'application/json' };
if (cookies.size) headers.Cookie = cookieHeader();
if (body !== undefined) headers['Content-Type'] = 'application/json';
if (csrf && method !== 'GET') headers['X-CSRFTOKEN'] = csrf;
const res = await fetch(baseURL + p, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
store(res);
const text = await res.text();
let json = null;
try { json = text ? JSON.parse(text) : null; } catch { /* ignore */ }
if (!res.ok) throw new Error(`${method} ${p} -> HTTP ${res.status}: ${(json && json.message) || text.slice(0, 300)}`);
return json;
}
return { post: (p, b) => req('POST', p, b), get: (p) => req('GET', p), put: (p, b) => req('PUT', p, b) };
}
const data = (j) => (j && j.data !== undefined ? j.data : j);
async function main() {
const env = loadEnv(path.join(ROOT, '.env'));
const old = JSON.parse(fs.readFileSync(OLD_MAIL_CFG, 'utf8'));
const base = (env.LIBREDESK_BASE_URL || 'http://192.168.31.61:9000').replace(/\/+$/, '');
const c = createClient(base);
await c.post('/api/v1/auth/login', { email: env.LIBREDESK_SYSTEM_EMAIL || 'System', password: env.LIBREDESK_SYSTEM_USER_PASSWORD });
console.log('登录成功');
// 1. Email inbox
const inboxes = data(await c.get('/api/v1/inboxes')) || [];
let inbox = inboxes.find((i) => i.channel === 'email' && i.name === INBOX_NAME);
const payload = {
name: INBOX_NAME,
channel: 'email',
enabled: true,
csat_enabled: false,
prompt_tags_on_reply: false,
reopen_window_hours: 0,
from: old.smtp.auth.user,
aliases: [],
config: {
auth_type: 'password',
imap: [{
host: 'imap-proxy',
port: 1993,
username: old.imap.auth.user,
password: old.imap.auth.pass,
mailbox: 'INBOX',
read_interval: '1m',
scan_inbox_since: '48h',
tls_type: 'none',
tls_skip_verify: false,
}],
smtp: [{
host: old.smtp.host,
port: old.smtp.port,
username: old.smtp.auth.user,
password: old.smtp.auth.pass,
tls_type: 'tls',
auth_protocol: 'plain',
max_conns: 5,
idle_timeout: '30s',
pool_wait_timeout: '10s',
max_msg_retries: 3,
hello_hostname: '',
}],
reply_to: '',
enable_plus_addressing: false,
},
};
if (!inbox) {
inbox = data(await c.post('/api/v1/inboxes', payload));
console.log(`创建 Email Inbox #${inbox.id}`);
} else {
inbox = data(await c.put(`/api/v1/inboxes/${inbox.id}`, payload));
console.log(`更新 Email Inbox #${inbox.id}`);
}
// 2. 默认外发邮件模板
const tpls = data(await c.get('/api/v1/templates?type=email_outgoing')) || [];
let tpl = tpls.find((t) => t.is_default);
if (!tpl) {
tpl = data(await c.post('/api/v1/templates', {
name: TEMPLATE_NAME,
body: TEMPLATE_BODY,
subject: '',
is_default: true,
type: 'email_outgoing',
}));
console.log(`创建默认外发模板 #${tpl.id}`);
} else if (tpl.body !== TEMPLATE_BODY) {
tpl = data(await c.put(`/api/v1/templates/${tpl.id}`, {
name: tpl.name,
body: TEMPLATE_BODY,
subject: tpl.subject || '',
is_default: true,
type: 'email_outgoing',
}));
console.log(`更新默认外发模板 #${tpl.id}`);
} else {
console.log(`复用默认外发模板 #${tpl.id}`);
}
// 3. Automation:email inbox -> assistant
const assistants = data(await c.get('/api/v1/ai/assistants')) || [];
const assistant = assistants.find((a) => a.name === ASSISTANT_NAME);
if (!assistant) throw new Error(`未找到 Assistant "${ASSISTANT_NAME}",先运行 scripts/setup-defaults.js`);
const rulesRaw = data(await c.get('/api/v1/automations/rules?type=new_conversation')) || [];
const rules = Array.isArray(rulesRaw) ? rulesRaw : (rulesRaw.rules || []);
const rulePayload = {
name: RULE_NAME,
description: 'Route new email conversations to the AI assistant',
type: 'new_conversation',
events: [],
enabled: true,
weight: 0,
rules: [{
type: 'new_conversation',
execution_mode: 'all',
event: [],
group_operator: 'AND',
groups: [{
logical_op: 'AND',
rules: [{
field: 'inbox',
field_type: 'conversation',
operator: 'equals',
value: String(inbox.id),
case_sensitive_match: false,
}],
}],
actions: [{
type: 'assign_user',
value: [String(assistant.user_id)],
display_value: [assistant.name],
}],
}],
};
const existing = rules.find((r) => r.name === RULE_NAME);
if (!existing) {
const rule = data(await c.post('/api/v1/automations/rules', rulePayload));
console.log(`创建 Automation #${rule.id}(inbox ${inbox.id} → user ${assistant.user_id})`);
} else {
const rule = data(await c.put(`/api/v1/automations/rules/${existing.id}`, rulePayload));
console.log(`更新 Automation #${rule.id}(inbox ${inbox.id} → user ${assistant.user_id})`);
}
console.log('\nEmail 渠道配置完成。IMAP 走 imap-proxy(自动注入 ID),SMTP 直连 smtp.163.com:465。');
}
main().catch((err) => {
console.error('[add-email-inbox]', err.message);
process.exit(1);
});
+153
View File
@@ -0,0 +1,153 @@
#!/usr/bin/env node
'use strict';
/**
* 创建/更新 WhatsApp inbox,并配置「新会话 → AI Assistant」automation。
*
* .env 需要:
* WA_PHONE_NUMBER_ID / WA_WABA_ID / WA_ACCESS_TOKEN / WA_APP_SECRET
* WA_VERIFY_TOKEN / WA_INBOX_NAME(可选)
* WA_API_VERSION(可选,默认 v26.0)
*
* 前提:LibreDesk app.root_url 已是公网 HTTPS(scripts/public-https.sh),
* 否则 Meta 无法回调,自动订阅也会失败。
*/
const fs = require('fs');
const path = require('path');
const ROOT = path.resolve(__dirname, '..');
const RULE_PREFIX = 'Assign WhatsApp conversations to AI Assistant';
function loadEnv(file) {
const out = {};
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
const t = line.trim();
if (!t || t.startsWith('#')) continue;
const eq = t.indexOf('=');
if (eq < 0) continue;
let v = t.slice(eq + 1).trim();
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
out[t.slice(0, eq).trim()] = v;
}
return out;
}
function createClient(baseURL) {
const cookies = new Map();
let csrf = '';
const cookieHeader = () => [...cookies.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
function store(res) {
const list = typeof res.headers.getSetCookie === 'function' ? res.headers.getSetCookie() : [res.headers.get('set-cookie')].filter(Boolean);
for (const c of list) {
const [pair] = c.split(';');
const eq = pair.indexOf('=');
if (eq > 0) {
const k = pair.slice(0, eq).trim();
const v = pair.slice(eq + 1).trim();
cookies.set(k, v);
if (k === 'csrf_token') csrf = v;
}
}
}
async function req(method, p, body) {
const headers = { Accept: 'application/json' };
if (cookies.size) headers.Cookie = cookieHeader();
if (body !== undefined) headers['Content-Type'] = 'application/json';
if (csrf && method !== 'GET') headers['X-CSRFTOKEN'] = csrf;
const res = await fetch(baseURL + p, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
store(res);
const text = await res.text();
let json = null;
try { json = text ? JSON.parse(text) : null; } catch { /* ignore */ }
if (!res.ok) throw new Error(`${method} ${p} -> HTTP ${res.status}: ${(json && (json.message || json.error)) || text.slice(0, 300)}`);
return json;
}
return { get: (p) => req('GET', p), post: (p, b) => req('POST', p, b), put: (p, b) => req('PUT', p, b) };
}
const data = (j) => (j && j.data !== undefined ? j.data : j);
async function main() {
const env = loadEnv(path.join(ROOT, '.env'));
const base = (env.LIBREDESK_BASE_URL || 'http://192.168.31.61:9000').replace(/\/+$/, '');
const required = ['WA_PHONE_NUMBER_ID', 'WA_WABA_ID', 'WA_ACCESS_TOKEN', 'WA_APP_SECRET', 'WA_VERIFY_TOKEN'];
const missing = required.filter((k) => !env[k]);
if (missing.length) throw new Error(`.env 缺少: ${missing.join(', ')}`);
const c = createClient(base);
await c.post('/api/v1/auth/login', { email: env.LIBREDESK_SYSTEM_EMAIL || 'System', password: env.LIBREDESK_SYSTEM_USER_PASSWORD });
console.log('登录成功');
const inboxName = env.WA_INBOX_NAME || 'WhatsApp US';
const payload = {
name: inboxName,
channel: 'whatsapp',
enabled: true,
config: {
phone_number_id: env.WA_PHONE_NUMBER_ID,
waba_id: env.WA_WABA_ID,
access_token: env.WA_ACCESS_TOKEN,
app_secret: env.WA_APP_SECRET,
webhook_verify_token: env.WA_VERIFY_TOKEN,
api_version: env.WA_API_VERSION || 'v26.0',
},
};
const inboxes = data(await c.get('/api/v1/inboxes')) || [];
let inbox = inboxes.find((i) => i.channel === 'whatsapp' && i.name === inboxName);
if (!inbox) {
inbox = data(await c.post('/api/v1/inboxes', payload));
console.log(`创建 WhatsApp Inbox #${inbox.id}`);
} else {
inbox = data(await c.put(`/api/v1/inboxes/${inbox.id}`, payload));
console.log(`更新 WhatsApp Inbox #${inbox.id}`);
}
// automation: WhatsApp inbox 新会话 -> AI Assistant
const assistants = data(await c.get('/api/v1/ai/assistants')) || [];
const assistant = assistants.find((a) => a.name === (env.AI_ASSISTANT_NAME || 'Yehwang Assistant'));
if (!assistant) throw new Error('未找到 AI Assistant,先运行 scripts/setup-defaults.js');
const ruleName = `${RULE_PREFIX} #${inbox.id}`;
const rulesRaw = data(await c.get('/api/v1/automations/rules?type=new_conversation')) || [];
const rules = Array.isArray(rulesRaw) ? rulesRaw : (rulesRaw.rules || []);
const rulePayload = {
name: ruleName,
description: 'Route new WhatsApp conversations to the AI assistant',
type: 'new_conversation',
events: [],
enabled: true,
weight: 0,
rules: [{
type: 'new_conversation',
execution_mode: 'all',
event: [],
group_operator: 'AND',
groups: [{
logical_op: 'AND',
rules: [{ field: 'inbox', field_type: 'conversation', operator: 'equals', value: String(inbox.id), case_sensitive_match: false }],
}],
actions: [{ type: 'assign_user', value: [String(assistant.user_id)], display_value: [assistant.name] }],
}],
};
const existing = rules.find((r) => r.name === ruleName);
if (!existing) {
const rule = data(await c.post('/api/v1/automations/rules', rulePayload));
console.log(`创建 Automation #${rule.id}(inbox ${inbox.id} → user ${assistant.user_id})`);
} else {
const rule = data(await c.put(`/api/v1/automations/rules/${existing.id}`, rulePayload));
console.log(`更新 Automation #${rule.id}(inbox ${inbox.id} → user ${assistant.user_id})`);
}
const root = env.PUBLIC_BASE_URL || '<app.root_url>';
console.log('\nWhatsApp Inbox 配置完成');
console.log(`Inbox ID: ${inbox.id}`);
console.log(`Callback URL: ${root}/webhooks/whatsapp/${inbox.id}`);
console.log(`Verify Token: ${env.WA_VERIFY_TOKEN}`);
console.log('Meta -> WhatsApp -> Configuration -> Webhook 里填上面两项,订阅 messages');
}
main().catch((err) => {
console.error('[add-whatsapp-inbox]', err.message);
process.exit(1);
});
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
# crm-new 一键启动:生成 .env、渲染 LibreDesk 配置、启动全栈、等待健康检查。
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
if [ ! -f .env ]; then
echo "==> 生成 .env(随机密钥)"
cp .env.example .env
node - "$ROOT" <<'NODE'
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const p = path.join(process.argv[2], '.env');
let s = fs.readFileSync(p, 'utf8');
const set = (k, v) => {
const re = new RegExp('^' + k + '=.*$', 'm');
s = re.test(s) ? s.replace(re, `${k}=${v}`) : `${s}\n${k}=${v}`;
};
const strongPassword = () => `Yehwang${crypto.randomInt(1000, 9999)}!${crypto.randomBytes(4).toString('hex')}`;
set('POSTGRES_PASSWORD', crypto.randomBytes(12).toString('hex'));
set('LIBREDESK_ENC_KEY', crypto.randomBytes(16).toString('hex'));
set('LIBREDESK_SYSTEM_USER_PASSWORD', strongPassword());
set('TOOLS_SHARED_KEY', crypto.randomBytes(16).toString('hex'));
set('TWENTY_APP_SECRET', crypto.randomBytes(32).toString('hex'));
set('TWENTY_ENCRYPTION_KEY', crypto.randomBytes(32).toString('base64'));
set('TWENTY_FALLBACK_ENCRYPTION_KEY', crypto.randomBytes(32).toString('base64'));
fs.writeFileSync(p, s, { mode: 0o600 });
NODE
echo " .env 已生成(含密钥,勿提交)"
fi
echo "==> 渲染 LibreDesk 配置"
node scripts/render-config.js
mkdir -p libredesk/uploads
touch libredesk/uploads/.gitkeep
echo "==> 构建并启动全栈(首次 Twenty 迁移约 1-3 分钟)"
docker compose up -d --build
wait_http() {
local name="$1" url="$2" tries="${3:-150}"
for i in $(seq 1 "$tries"); do
if curl -fsS --max-time 2 "$url" >/dev/null 2>&1; then
echo " ✓ ${name} (${i} 次探测)"
return 0
fi
sleep 2
done
echo "!! ${name} 等待超时:$url" >&2
return 1
}
echo "==> 等待服务就绪"
wait_http "LibreDesk" "http://localhost:9000/health" 150
wait_http "Twenty" "http://localhost:3100/healthz" 180
wait_http "bridge" "http://localhost:4100/healthz" 60
# shellcheck disable=SC1091
set -a; . ./.env; set +a
echo
echo "==================== crm-new 已启动 ===================="
echo " LibreDesk : http://localhost:9000 (System / ${LIBREDESK_SYSTEM_USER_PASSWORD})"
echo " Twenty : http://localhost:3100 (首次访问需注册 workspace)"
echo " bridge : http://localhost:4100 (/healthz, /tools/manifest)"
echo " Postgres : 127.0.0.1:5440 (libredesk / twenty / bridge)"
echo " Redis : 127.0.0.1:6390"
echo
echo " 下一步:"
echo " 1. 打开 LibreDesk 配 AI Provider(DashScope)"
echo " 2. 打开 Twenty 注册首个账号,创建 API key 填进 .env"
echo " 3. 生成 LibreDesk System API key 填进 .env,然后 node scripts/register-tools.js"
echo " 4. 建 Live chat / Email / WhatsApp inbox 与 AI Assistant"
echo "======================================================="
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env node
'use strict';
// 抓取官网 sitemap.xml(www/us),把全量 URL 落到 bridge 库 bridge_site_map_urls。
// 数据只在服务端匹配,不进 LLM/向量库;商品搜索无结果时返回真实商品页链接。
const fs = require('fs');
const path = require('path');
const { Client } = require('../bridge/node_modules/pg');
const ROOT = path.resolve(__dirname, '..');
const env = {};
for (const line of fs.readFileSync(path.join(ROOT, '.env'), 'utf8').split('\n')) {
if (!line.includes('=') || line.trim().startsWith('#')) continue;
const i = line.indexOf('=');
env[line.slice(0, i).trim()] = line.slice(i + 1).trim();
}
const password = encodeURIComponent(env.POSTGRES_PASSWORD || '');
const PG_URL = process.env.SITEMAP_PG_URL || `postgres://crmnew:${password}@127.0.0.1:5440/bridge`;
const SITES = {
www: { base: 'https://www.yehwang.com', sitemap: 'https://www.yehwang.com/sitemap.xml' },
us: { base: 'https://us.yehwang.com', sitemap: 'https://us.yehwang.com/sitemap.xml' },
};
function tokensFor(pathPart) {
return pathPart
.replace(/\.html$/, '')
.split('/')
.filter(Boolean)
.map((seg) => seg.replace(/-?\d+$/, '').split('-').filter((w) => w && w.length > 1).join(' '))
.filter(Boolean)
.join(' ')
.toLowerCase();
}
async function fetchSitemap(url) {
const res = await fetch(url, {
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; YehwangSiteMapBot/1.0)' },
signal: AbortSignal.timeout(90000),
});
if (!res.ok) throw new Error(`sitemap HTTP ${res.status}: ${url}`);
const xml = await res.text();
return [...xml.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1].trim()).filter(Boolean);
}
async function buildSite(client, name) {
const cfg = SITES[name];
const urls = await fetchSitemap(cfg.sitemap);
const rows = [];
const seenPaths = new Set();
for (const url of urls) {
if (!url.startsWith(cfg.base)) continue;
const pathPart = url.slice(cfg.base.length).split('?')[0];
if (!pathPart || pathPart === '/') continue;
if (seenPaths.has(pathPart)) continue;
seenPaths.add(pathPart);
const isProduct = /\.html$/.test(pathPart);
rows.push({
path: pathPart,
url: cfg.base + pathPart,
slug_tokens: tokensFor(pathPart),
is_product: isProduct,
level: pathPart.split('/').filter(Boolean).length,
});
}
await client.query('BEGIN');
await client.query('DELETE FROM bridge_site_map_urls WHERE site = $1', [name]);
for (let i = 0; i < rows.length; i += 1000) {
const batch = rows.slice(i, i + 1000);
const values = [];
const params = [];
batch.forEach((r, idx) => {
const b = idx * 6;
values.push(`($${b + 1},$${b + 2},$${b + 3},$${b + 4},$${b + 5},$${b + 6})`);
params.push(name, r.path, r.url, r.slug_tokens, r.is_product, r.level);
});
await client.query(
`INSERT INTO bridge_site_map_urls (site, path, url, slug_tokens, is_product, level) VALUES ${values.join(',')}`,
params
);
}
await client.query('COMMIT');
const productCount = rows.filter((r) => r.is_product).length;
console.log(`[site-map] ${name}: total=${rows.length}, products=${productCount}, nav=${rows.length - productCount}`);
}
async function main() {
const client = new Client({ connectionString: PG_URL });
await client.connect();
await client.query('CREATE EXTENSION IF NOT EXISTS pg_trgm');
await client.query(`
CREATE TABLE IF NOT EXISTS bridge_site_map_urls (
id BIGSERIAL PRIMARY KEY,
site TEXT NOT NULL,
path TEXT NOT NULL,
url TEXT NOT NULL,
slug_tokens TEXT NOT NULL,
is_product BOOLEAN NOT NULL DEFAULT FALSE,
level INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (site, path)
)
`);
await client.query('CREATE INDEX IF NOT EXISTS bridge_site_map_urls_site_idx ON bridge_site_map_urls (site)');
await client.query('CREATE INDEX IF NOT EXISTS bridge_site_map_urls_tokens_trgm_idx ON bridge_site_map_urls USING gin (slug_tokens gin_trgm_ops)');
const targets = process.argv.slice(2).filter((s) => SITES[s]);
for (const site of targets.length ? targets : ['www', 'us']) {
await buildSite(client, site);
}
await client.end();
}
main().catch((err) => {
console.error('[build-site-map]', err.message);
process.exit(1);
});
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env node
'use strict';
/**
* 用 System 会话配置 LibreDesk 的 AI Provider(Completion + Embedding)。
* 默认复用旧项目 crm-ai-demo/bridge/kb.config.json 里的 DashScope key(可用 DASHSCOPE_API_KEY 覆盖)。
*
* 用法:node scripts/configure-ai.js
*/
const fs = require('fs');
const path = require('path');
const ROOT = path.resolve(__dirname, '..');
const OLD_KB_CFG = '/home/dev/code/crm/crm-ai-demo/bridge/kb.config.json';
function loadEnv(file) {
const out = {};
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
const t = line.trim();
if (!t || t.startsWith('#')) continue;
const eq = t.indexOf('=');
if (eq < 0) continue;
let v = t.slice(eq + 1).trim();
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
out[t.slice(0, eq).trim()] = v;
}
return out;
}
function createClient(baseURL) {
const cookies = new Map();
let csrf = '';
const cookieHeader = () => [...cookies.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
function store(res) {
const list = typeof res.headers.getSetCookie === 'function' ? res.headers.getSetCookie() : [res.headers.get('set-cookie')].filter(Boolean);
for (const c of list) {
const [pair] = c.split(';');
const eq = pair.indexOf('=');
if (eq > 0) {
const k = pair.slice(0, eq).trim();
const v = pair.slice(eq + 1).trim();
cookies.set(k, v);
if (k === 'csrf_token') csrf = v;
}
}
}
async function req(method, p, body) {
const headers = { Accept: 'application/json' };
if (cookies.size) headers.Cookie = cookieHeader();
if (body !== undefined) headers['Content-Type'] = 'application/json';
if (csrf && method !== 'GET') headers['X-CSRFTOKEN'] = csrf;
const res = await fetch(baseURL + p, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
store(res);
const text = await res.text();
let json = null;
try { json = text ? JSON.parse(text) : null; } catch { /* ignore */ }
if (!res.ok) throw new Error(`${method} ${p} -> HTTP ${res.status}: ${(json && json.message) || text.slice(0, 300)}`);
return json;
}
return { get: (p) => req('GET', p), post: (p, b) => req('POST', p, b), put: (p, b) => req('PUT', p, b) };
}
const mask = (k) => (k ? k.slice(0, 6) + '…' + k.slice(-4) : '(empty)');
async function main() {
const env = loadEnv(path.join(ROOT, '.env'));
const base = (env.LIBREDESK_BASE_URL || 'http://192.168.31.61:9000').replace(/\/+$/, '');
const password = env.LIBREDESK_SYSTEM_USER_PASSWORD;
const email = env.LIBREDESK_SYSTEM_EMAIL || 'System';
let apiKey = process.env.DASHSCOPE_API_KEY || '';
let baseURL = 'https://dashscope.aliyuncs.com/compatible-mode/v1';
let completionModel = 'qwen-plus-latest';
let embeddingModel = 'text-embedding-v4';
let dimensions = 1024;
if (!apiKey && fs.existsSync(OLD_KB_CFG)) {
const kb = JSON.parse(fs.readFileSync(OLD_KB_CFG, 'utf8'));
apiKey = kb.apiKey || '';
baseURL = kb.baseURL || baseURL;
embeddingModel = kb.model || embeddingModel;
dimensions = kb.dim || dimensions;
}
if (!apiKey) throw new Error('没有可用的 DashScope key:设置 DASHSCOPE_API_KEY 或确认旧 kb.config.json 存在');
const c = createClient(base);
await c.post('/api/v1/auth/login', { email, password });
console.log(`登录成功:${email}`);
const completion = {
provider: 'openai',
base_url: baseURL,
api_key: apiKey,
model: completionModel,
temperature: 0.3,
max_tokens: 800,
vision: false,
};
const embedding = {
provider: 'openai',
base_url: baseURL,
api_key: apiKey,
model: embeddingModel,
dimensions,
embedding_max_tokens: 8192,
};
console.log(`Completion: ${completionModel} @ ${baseURL} key=${mask(apiKey)}`);
console.log(`Embedding : ${embeddingModel} dim=${dimensions} key=${mask(apiKey)}`);
console.log('测试 Completion…');
await c.post('/api/v1/ai/config/completion/test', completion);
console.log(' 通过');
console.log('测试 Embedding…');
await c.post('/api/v1/ai/config/embedding/test', embedding);
console.log(' 通过');
await c.put('/api/v1/ai/config/completion', completion);
console.log('已保存 Completion 配置');
await c.put('/api/v1/ai/config/embedding', embedding);
console.log('已保存 Embedding 配置(会自动开始重建知识库向量)');
}
main().catch((err) => {
console.error('[configure-ai]', err.message);
process.exit(1);
});
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env node
'use strict';
/**
* 配置 LibreDesk 通知邮件(SMTP)。默认使用旧项目的 163 授权码。
* 修改后 LibreDesk 需要重启才能生效(脚本最后会重启容器)。
*
* 用法:node scripts/configure-email.js
*/
const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');
const ROOT = path.resolve(__dirname, '..');
const OLD_MAIL_CFG = '/home/dev/code/crm/crm-ai-demo/bridge/mail.config.json';
function loadEnv(file) {
const out = {};
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
const t = line.trim();
if (!t || t.startsWith('#')) continue;
const eq = t.indexOf('=');
if (eq < 0) continue;
let v = t.slice(eq + 1).trim();
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
out[t.slice(0, eq).trim()] = v;
}
return out;
}
function createClient(baseURL) {
const cookies = new Map();
let csrf = '';
const cookieHeader = () => [...cookies.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
function store(res) {
const list = typeof res.headers.getSetCookie === 'function' ? res.headers.getSetCookie() : [res.headers.get('set-cookie')].filter(Boolean);
for (const c of list) {
const [pair] = c.split(';');
const eq = pair.indexOf('=');
if (eq > 0) {
const k = pair.slice(0, eq).trim();
const v = pair.slice(eq + 1).trim();
cookies.set(k, v);
if (k === 'csrf_token') csrf = v;
}
}
}
async function req(method, p, body) {
const headers = { Accept: 'application/json' };
if (cookies.size) headers.Cookie = cookieHeader();
if (body !== undefined) headers['Content-Type'] = 'application/json';
if (csrf && method !== 'GET') headers['X-CSRFTOKEN'] = csrf;
const res = await fetch(baseURL + p, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
store(res);
const text = await res.text();
let json = null;
try { json = text ? JSON.parse(text) : null; } catch { /* ignore */ }
if (!res.ok) throw new Error(`${method} ${p} -> HTTP ${res.status}: ${(json && json.message) || text.slice(0, 300)}`);
return json;
}
return { post: (p, b) => req('POST', p, b), get: (p) => req('GET', p), put: (p, b) => req('PUT', p, b) };
}
async function main() {
const env = loadEnv(path.join(ROOT, '.env'));
const base = (env.LIBREDESK_BASE_URL || 'http://192.168.31.61:9000').replace(/\/+$/, '');
// 163 凭据:优先 .env 覆盖,否则读旧项目 mail.config.json
let user = env.SMTP_USER;
let pass = env.SMTP_PASS;
let host = env.SMTP_HOST || 'smtp.163.com';
let port = Number(env.SMTP_PORT || 465);
let from = env.SMTP_FROM;
if ((!user || !pass) && fs.existsSync(OLD_MAIL_CFG)) {
const old = JSON.parse(fs.readFileSync(OLD_MAIL_CFG, 'utf8'));
user = user || old.smtp?.auth?.user;
pass = pass || old.smtp?.auth?.pass;
host = host || old.smtp?.host;
port = port || old.smtp?.port;
from = from || old.smtp?.from;
}
if (!user || !pass) throw new Error('缺少 SMTP 凭据:设置 SMTP_USER/SMTP_PASS 或确认旧 mail.config.json 存在');
const c = createClient(base);
await c.post('/api/v1/auth/login', { email: env.LIBREDESK_SYSTEM_EMAIL || 'System', password: env.LIBREDESK_SYSTEM_USER_PASSWORD });
console.log('登录成功');
const payload = {
'notification.email.username': user,
'notification.email.host': host,
'notification.email.port': port,
'notification.email.password': pass,
'notification.email.email_address': from,
'notification.email.enabled': true,
'notification.email.tls_type': 'tls',
'notification.email.auth_protocol': 'plain',
'notification.email.max_conns': 5,
'notification.email.idle_timeout': '30s',
'notification.email.wait_timeout': '10s',
'notification.email.max_msg_retries': 3,
'notification.email.tls_skip_verify': false,
'notification.email.hello_hostname': '',
};
await c.put('/api/v1/settings/notifications/email', payload);
console.log(`已保存 SMTP:${host}:${port} user=${user} from=${from}`);
console.log('重启 LibreDesk 使配置生效…');
execFileSync('docker', ['compose', 'restart', 'libredesk'], { cwd: ROOT, stdio: 'inherit' });
console.log('完成。');
}
main().catch((err) => {
console.error('[configure-email]', err.message);
process.exit(1);
});
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env node
'use strict';
// 匿名 Live chat:AI 问邮箱 -> 发 OTP -> 读 163 邮箱取码 -> 回码 -> 验证 -> 查订单
const { execFileSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const ROOT = path.resolve(__dirname, '..');
const env = {};
for (const line of fs.readFileSync(path.join(ROOT, '.env'), 'utf8').split('\n')) {
if (!line.includes('=') || line.trim().startsWith('#')) continue;
const i = line.indexOf('=');
env[line.slice(0, i).trim()] = line.slice(i + 1).trim();
}
const BASE = (env.LIBREDESK_BASE_URL || '').replace(/\/+$/, '');
const INBOX = env.LIBREDESK_INBOX_UUID;
const EMAIL = process.env.OTP_EMAIL || env.SMTP_USER;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function adminCookie() {
const r = await fetch(BASE + '/api/v1/auth/login', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'System', password: env.LIBREDESK_SYSTEM_USER_PASSWORD }),
});
return r.headers.getSetCookie().map((c) => c.split(';')[0]).join('; ');
}
async function adminMessages(cookie, uuid) {
const r = await fetch(`${BASE}/api/v1/conversations/${uuid}/messages`, { headers: { Cookie: cookie } });
if (!r.ok) return [];
const body = await r.json();
const msgs = (body.data && (body.data.results || body.data.messages)) || [];
return msgs.slice().sort((a, b) => Number(a.id) - Number(b.id));
}
async function waitReply(cookie, uuid, afterId, timeoutMs = 90000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
await sleep(3000);
const msgs = await adminMessages(cookie, uuid);
const replies = msgs.filter((m) => m.type === 'outgoing' && m.sender_type === 'agent' && !m.private && Number(m.id) > afterId);
if (replies.length) return replies[replies.length - 1];
}
return null;
}
async function sendWidget(uuid, token, message) {
const r = await fetch(`${BASE}/api/v1/widget/chat/conversations/${uuid}/message`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, 'X-Libredesk-Inbox-ID': INBOX },
body: JSON.stringify({ message }),
});
if (!r.ok) throw new Error(`widget send failed: ${r.status} ${(await r.text()).slice(0, 200)}`);
}
function readOtpaCode() {
const py = String.raw`
import json, imaplib, email, re
from email.header import decode_header
imaplib.Commands['ID']=('AUTH',)
cfg=json.load(open('/home/dev/code/crm/crm-ai-demo/bridge/mail.config.json'))
imap=cfg['imap']; auth=imap['auth']
M=imaplib.IMAP4_SSL(imap['host'], imap['port']); M.login(auth['user'],auth['pass'])
M._simple_command('ID','("name" "pycrm" "version" "1.0" "vendor" "python")'); M.select('INBOX')
typ,data=M.search(None,'ALL'); ids=data[0].split()[-10:]
for i in reversed(ids):
typ,md=M.fetch(i,'(RFC822)'); msg=email.message_from_bytes(md[0][1])
subj=str(email.header.make_header(decode_header(msg['Subject'] or '')))
if 'verification code' in subj.lower():
body=''
if msg.is_multipart():
for part in msg.walk():
if part.get_content_type()=='text/plain': body=part.get_payload(decode=True).decode('utf-8','ignore'); break
else: body=msg.get_payload(decode=True).decode('utf-8','ignore')
m=re.search(r'\b(\d{6})\b', body)
if m: print(m.group(1)); break
`;
return execFileSync('python3', ['-c', py], { encoding: 'utf8' }).trim();
}
async function main() {
const cookie = await adminCookie();
const init = await fetch(`${BASE}/api/v1/widget/chat/conversations/init`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Libredesk-Inbox-ID': INBOX },
body: JSON.stringify({ message: 'I want to check my order status' }),
});
const j = await init.json();
if (!init.ok) throw new Error(`init failed: ${JSON.stringify(j).slice(0, 200)}`);
const uuid = j.data.conversation.uuid;
const token = j.data.session_token;
console.log('1) conversation', uuid);
let msgs = await adminMessages(cookie, uuid);
let lastId = msgs.length ? Number(msgs[msgs.length - 1].id) : 0;
const ask = await waitReply(cookie, uuid, lastId);
console.log('2) AI:', ask && ask.text_content);
await sendWidget(uuid, token, EMAIL);
console.log('3) sent email', EMAIL);
msgs = await adminMessages(cookie, uuid);
lastId = Number(msgs[msgs.length - 1].id);
const sent = await waitReply(cookie, uuid, lastId);
console.log('4) AI:', sent && sent.text_content);
const code = readOtpaCode();
if (!code) throw new Error('OTP code not found in mailbox');
console.log('5) code from mailbox', code);
await sendWidget(uuid, token, code);
msgs = await adminMessages(cookie, uuid);
lastId = Number(msgs[msgs.length - 1].id);
const final = await waitReply(cookie, uuid, lastId, 120000);
console.log('6) AI:', final && final.text_content);
if (!final) throw new Error('no final reply');
console.log('\nCONVERSATION', uuid);
}
main().catch((err) => { console.error('[e2e-anon-otp]', err.message); process.exit(1); });
+147
View File
@@ -0,0 +1,147 @@
#!/usr/bin/env node
'use strict';
/**
* 端到端验证 widget JWT:
* 站点身份 → bridge /identity 签 LibreDesk JWT → auth exchange → 带身份开聊
* → AI 把该联系人视为已登录(可查订单/建商机)
*
* 用法:node scripts/e2e-widget-jwt.js "Can you check the status of my order SO-1?"
*/
const fs = require('fs');
const path = require('path');
const ROOT = path.resolve(__dirname, '..');
const message = process.argv.slice(2).join(' ') || 'Can you check the status of my order SO-1?';
const SITE_ORIGIN = 'http://192.168.31.61:9000';
function loadEnv(file) {
const out = {};
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
const t = line.trim();
if (!t || t.startsWith('#')) continue;
const eq = t.indexOf('=');
if (eq < 0) continue;
let v = t.slice(eq + 1).trim();
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
out[t.slice(0, eq).trim()] = v;
}
return out;
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function main() {
const env = loadEnv(path.join(ROOT, '.env'));
const lr = (env.LIBREDESK_BASE_URL || 'http://192.168.31.61:9000').replace(/\/+$/, '');
const bridge = (env.BRIDGE_BASE_URL || 'http://192.168.31.61:4100').replace(/\/+$/, '');
const inboxUUID = env.LIBREDESK_INBOX_UUID;
if (!inboxUUID) throw new Error('缺少 LIBREDESK_INBOX_UUID');
// 1) bridge /identity: 站点身份 -> widget JWT(dev payload;生产用 token)
const idRes = await fetch(`${bridge}/identity`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Origin: SITE_ORIGIN },
body: JSON.stringify({
email: process.env.IDENTITY_EMAIL || 'buyer@test-customer.nl',
externalUserId: process.env.IDENTITY_EXTERNAL_ID || 'cust-1001',
firstName: process.env.IDENTITY_FIRST || 'Jan',
lastName: process.env.IDENTITY_LAST || 'Jansen',
}),
});
const idJson = await idRes.json();
if (!idRes.ok) throw new Error(`identity failed: ${JSON.stringify(idJson).slice(0, 200)}`);
console.log(`1) userJWT 签发成功 (external_user_id=${idJson.user.external_user_id})`);
// 2) widget auth exchange
const exRes = await fetch(`${lr}/api/v1/widget/chat/auth/exchange`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Libredesk-Inbox-ID': inboxUUID },
body: JSON.stringify({ jwt: idJson.userJWT }),
});
const exJson = await exRes.json();
if (!exRes.ok) throw new Error(`auth exchange failed: ${JSON.stringify(exJson).slice(0, 300)}`);
const sessionToken = exJson.data.session_token;
console.log(`2) auth exchange 成功 (contact user_id=${exJson.data.user.user_id})`);
// 3) 带 session 开聊
const initRes = await fetch(`${lr}/api/v1/widget/chat/conversations/init`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${sessionToken}`, 'X-Libredesk-Inbox-ID': inboxUUID },
body: JSON.stringify({ message }),
});
const initJson = await initRes.json();
if (!initRes.ok) throw new Error(`chat init failed: ${JSON.stringify(initJson).slice(0, 300)}`);
const convUUID = initJson.data.conversation.uuid;
console.log(`3) 会话创建: ${convUUID}`);
console.log(` visitor message: ${message}`);
// 4) admin 轮询回复
const loginRes = await fetch(`${lr}/api/v1/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: env.LIBREDESK_SYSTEM_EMAIL || 'System', password: env.LIBREDESK_SYSTEM_USER_PASSWORD }),
});
const adminCookie = loginRes.headers.getSetCookie().map((c) => c.split(';')[0]).join('; ');
const deadline = Date.now() + 120000;
let reply = null;
let last = -1;
while (Date.now() < deadline) {
await sleep(3000);
const res = await fetch(`${lr}/api/v1/conversations/${convUUID}/messages`, { headers: { Cookie: adminCookie } });
if (!res.ok) continue;
const body = await res.json();
const msgs = ((body.data && (body.data.results || body.data.messages)) || []).slice().sort((a, b) => Number(a.id) - Number(b.id));
if (msgs.length !== last) {
last = msgs.length;
const convRes = await fetch(`${lr}/api/v1/conversations/${convUUID}`, { headers: { Cookie: adminCookie } });
const conv = (await convRes.json()).data || {};
console.log(` messages=${msgs.length} assigned_user_id=${conv.assigned_user_id} status=${conv.status}`);
}
const ai = msgs.filter((m) => m.type === 'outgoing' && m.sender_type === 'agent' && !m.private).slice(-1)[0];
if (ai) { reply = ai; break; }
}
if (!reply) throw new Error('120 秒内没有 AI 回复');
console.log('\n===== AI 回复 #1 =====');
console.log(reply.content);
const followups = String(process.env.FOLLOWUPS || '').split('||').map((s) => s.trim()).filter(Boolean);
let round = 1;
for (const text of followups) {
round += 1;
console.log(`\n>> 发送 #${round}: ${text}`);
const sendRes = await fetch(`${lr}/api/v1/widget/chat/conversations/${convUUID}/message`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${sessionToken}`, 'X-Libredesk-Inbox-ID': inboxUUID },
body: JSON.stringify({ message: text }),
});
if (!sendRes.ok) throw new Error(`send follow-up failed: HTTP ${sendRes.status} ${(await sendRes.text()).slice(0, 200)}`);
const before = last;
let next = null;
const deadline2 = Date.now() + 120000;
while (Date.now() < deadline2) {
await sleep(3000);
const res = await fetch(`${lr}/api/v1/conversations/${convUUID}/messages`, { headers: { Cookie: adminCookie } });
if (!res.ok) continue;
const body = await res.json();
const msgs = ((body.data && (body.data.results || body.data.messages)) || []).slice().sort((a, b) => Number(a.id) - Number(b.id));
const fresh = msgs.filter((m) => m.type === 'outgoing' && m.sender_type === 'agent' && !m.private);
const latest = fresh.slice(-1)[0];
if (msgs.length > before && latest && latest.id !== reply.id) { next = latest; break; }
}
if (!next) { console.log(' (本轮无新回复)'); continue; }
reply = next;
console.log(`===== AI 回复 #${round} =====`);
console.log(next.content);
}
console.log('\n提示:bridge 日志应出现 search_orders / create_opportunity 且 verified=true');
}
main().catch((err) => {
console.error('[e2e-widget-jwt]', err.message);
process.exit(1);
});
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env node
'use strict';
/**
* 端到端验证:模拟 widget 访客发起对话 → Automation 分配给 AI Assistant → 等待 AI 回复。
* 用法:node scripts/e2e-widget.js "What is the minimum order value?"
*/
const fs = require('fs');
const path = require('path');
const ROOT = path.resolve(__dirname, '..');
const question = process.argv.slice(2).join(' ') || 'What is the minimum order value?';
function loadEnv(file) {
const out = {};
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
const t = line.trim();
if (!t || t.startsWith('#')) continue;
const eq = t.indexOf('=');
if (eq < 0) continue;
let v = t.slice(eq + 1).trim();
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
out[t.slice(0, eq).trim()] = v;
}
return out;
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function main() {
const env = loadEnv(path.join(ROOT, '.env'));
const base = (env.LIBREDESK_BASE_URL || 'http://192.168.31.61:9000').replace(/\/+$/, '');
// 1. admin session: 找到 Website inbox 的 UUID
const loginRes = await fetch(`${base}/api/v1/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: env.LIBREDESK_SYSTEM_EMAIL || 'System', password: env.LIBREDESK_SYSTEM_USER_PASSWORD }),
});
const loginCookies = (loginRes.headers.getSetCookie ? loginRes.headers.getSetCookie() : [])
.map((c) => c.split(';')[0]);
const adminCookie = loginCookies.join('; ');
const csrf = (loginCookies.find((c) => c.startsWith('csrf_token=')) || '').split('=')[1];
if (!loginRes.ok) throw new Error(`admin login failed: HTTP ${loginRes.status}`);
const inboxesRes = await fetch(`${base}/api/v1/inboxes`, { headers: { Cookie: adminCookie } });
const inboxes = (await inboxesRes.json()).data || [];
const inbox = inboxes.find((i) => i.channel === 'livechat' && i.name === 'Website');
if (!inbox) throw new Error('未找到 Website livechat inbox,先跑 scripts/setup-defaults.js');
console.log(`inbox: #${inbox.id} ${inbox.uuid}`);
// 2. widget init(匿名访客,发第一条消息)
const initRes = await fetch(`${base}/api/v1/widget/chat/conversations/init`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Libredesk-Inbox-ID': inbox.uuid },
body: JSON.stringify({ message: question }),
});
const initText = await initRes.text();
if (!initRes.ok) throw new Error(`widget init failed: HTTP ${initRes.status} ${initText.slice(0, 300)}`);
const init = JSON.parse(initText).data;
const token = init.session_token;
const convUUID = init.conversation.uuid;
console.log(`conversation: ${convUUID}`);
console.log(`visitor message: ${question}`);
// 3. 轮询等待 AI 回复(用管理端 API 读消息,widget API 的返回结构不同)
const deadline = Date.now() + 120000;
let lastCount = 0;
let reply = null;
while (Date.now() < deadline) {
await sleep(3000);
const res = await fetch(`${base}/api/v1/conversations/${convUUID}/messages`, {
headers: { Cookie: adminCookie },
});
if (!res.ok) continue;
const body = await res.json();
const messages = (body.data && (body.data.results || body.data.messages)) || [];
if (messages.length !== lastCount) {
lastCount = messages.length;
const convRes = await fetch(`${base}/api/v1/conversations/${convUUID}`, { headers: { Cookie: adminCookie } });
const conv = (await convRes.json()).data || {};
console.log(` [${new Date().toLocaleTimeString()}] messages=${messages.length} assigned_user_id=${conv.assigned_user_id} status=${conv.status}`);
}
const aiMsg = messages
.filter((m) => m.type === 'outgoing' && m.sender_type === 'agent' && !m.private)
.slice(-1)[0];
if (aiMsg) { reply = aiMsg; break; }
}
if (!reply) {
console.log('\n超时:120 秒内没有收到 AI 回复。检查 docker logs crmnew-libredesk | grep -i ai');
process.exit(2);
}
console.log('\n===== AI 回复 =====');
console.log(reply.content || reply.text_content || '(empty)');
console.log('\n===== 元信息 =====');
console.log(JSON.stringify({
message_id: reply.id,
sender_type: reply.sender_type,
type: reply.type,
private: reply.private,
meta: reply.meta,
created_at: reply.created_at,
}, null, 2));
}
main().catch((err) => {
console.error('[e2e-widget]', err.message);
process.exit(1);
});
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env node
'use strict';
// 离线“真实模型”评测:用 fake LibreDesk 客户端跑 createAgent,不写回真实会话。
// 依赖:LLM、旧 KB (127.0.0.1:5432/products)、商品 RPC 隧道。
const fs = require('fs');
const path = require('path');
const { loadConfig } = require('../bridge/src/config');
const { createAgent } = require('../bridge/src/agent');
const { createTools } = require('../bridge/src/tools');
const { createRetriever } = require('../bridge/src/rag');
const ROOT = path.resolve(__dirname, '..');
const env = {};
for (const line of fs.readFileSync(path.join(ROOT, '.env'), 'utf8').split('\n')) {
if (!line.includes('=') || line.trim().startsWith('#')) continue;
const i = line.indexOf('=');
env[line.slice(0, i).trim()] = line.slice(i + 1).trim();
}
const password = encodeURIComponent(env.POSTGRES_PASSWORD || '');
env.PG_URL = `postgres://crmnew:${password}@127.0.0.1:5440/bridge`;
env.RAG_PG_URL = 'postgres://postgres:postgres@127.0.0.1:5432/products';
const cfg = loadConfig({ ...process.env, ...env });
const CASES = [
{ id: 'policy-min-order', message: 'What is the minimum order value?', contact: { type: 'contact', email: 'eval@example.com' }, expectTool: 'search_kb', contains: ['30'] },
{ id: 'product-hoops', message: 'Do you have hoop earrings?', contact: { type: 'contact', email: 'eval@example.com' }, expectTool: 'search_products', contains: ['yehwang.com'] },
{ id: 'product-zh-rings', message: '推荐一些简约不锈钢戒指', contact: { type: 'contact', email: 'eval@example.com' }, expectTool: 'search_products', contains: ['戒指'] },
{ id: 'anon-order-gate', message: 'Where is my order?', contact: { type: 'visitor' }, containsAny: ['email', 'verify', 'verification', '邮箱', '验证'] },
];
function fakeLeadStore() {
const memory = new Map();
return {
get: async (uuid) => memory.get(uuid) || null,
save: async (uuid, slots) => { memory.set(uuid, { slots }); return { slots }; },
remove: async (uuid) => { memory.delete(uuid); },
};
}
async function runCase(c) {
let reply = '';
let metric = null;
const tools = createTools(cfg, { retriever: createRetriever(cfg) });
tools.leadStore = fakeLeadStore();
const agent = createAgent(cfg, {
tools,
client: {
getMessages: async () => [{ id: 1, text_content: c.message, sender_type: 'contact', private: false }],
sendMessage: async (_uuid, content) => { reply = content; },
getConversation: async () => ({}),
},
verification: { getVerifiedEmail: async () => null, sendCode: async () => ({}), verifyCode: async () => ({}) },
metrics: { enabled: false, record: async (m) => { metric = m; } },
contextManager: { compressHistory: async (_c, h) => h },
llmCache: { enabled: false, get: async () => null, store: async () => false },
});
const conv = { uuid: `eval-${c.id}`, inbox_id: '1', channel: 'livechat', contact: c.contact };
await agent.run(conv, { uuid: `msg-${c.id}`, text_content: c.message, content: c.message });
return { reply, metric };
}
(async () => {
let failed = 0;
for (const c of CASES) {
try {
const { reply, metric } = await runCase(c);
const tools = (metric && metric.toolCalls || []).map((t) => t.name);
const okTool = !c.expectTool || tools.includes(c.expectTool);
const okContains = !c.contains || c.contains.every((s) => reply.includes(s));
const okAny = !c.containsAny || c.containsAny.some((s) => reply.toLowerCase().includes(s.toLowerCase()));
const ok = okTool && okContains && okAny;
if (!ok) failed += 1;
console.log(`${ok ? 'PASS' : 'FAIL'} ${c.id} tools=[${tools.join(',')}] reply=${reply.slice(0, 140).replace(/\n/g, ' ')}`);
} catch (err) {
failed += 1;
console.log(`FAIL ${c.id} error=${err.message}`);
}
}
console.log(`\n${CASES.length - failed}/${CASES.length} passed`);
process.exit(failed ? 1 : 0);
})().catch((err) => { console.error('[eval-live]', err.message); process.exit(1); });
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env node
'use strict';
// 离线回归评测:不调用 LLM,覆盖意图/注入/商品词/lead/格式/引用审计等确定性逻辑。
const path = require('path');
const fs = require('fs');
const { quickClassify } = require('../bridge/src/intent');
const { detectInjection } = require('../bridge/src/security');
const { translateProductQuery } = require('../bridge/src/product-query');
const { hasBuyingIntent, missingLeadContacts } = require('../bridge/src/lead-intent');
const { markdownToHtml } = require('../bridge/src/markdown');
const { auditAnswer } = require('../bridge/src/audit');
const ENTRIES = [
{ title: 'Rings', category: 'jewelry/rings', url: 'https://www.yehwang.com/jewelry/rings', aliases: ['戒指', 'ringen'] },
{ title: 'Earrings', category: 'jewelry/earrings', url: 'https://www.yehwang.com/jewelry/earrings', aliases: ['耳环'] },
{ title: 'Necklaces', category: 'jewelry/necklaces', url: 'https://www.yehwang.com/jewelry/necklaces', aliases: ['项链'] },
];
const cases = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../bridge/eval/golden.json'), 'utf8'));
function run(c) {
if (c.type === 'intent') return quickClassify(c.message);
if (c.type === 'injection') return detectInjection(c.message).level;
if (c.type === 'product_query') return translateProductQuery(c.message, ENTRIES).query;
if (c.type === 'lead') {
const missing = missingLeadContacts(c.slots || {});
return missing.join(',');
}
if (c.type === 'markdown') return markdownToHtml(c.message);
if (c.type === 'audit') {
const audit = auditAnswer({
finalContent: c.message,
toolResults: [{ results: [{ source_urls: c.tool_urls || [] }] }],
toolCalls: [],
});
return String(audit.unverified_urls.length);
}
throw new Error(`unknown case type: ${c.type}`);
}
function check(c, actual) {
if (c.type === 'lead') return actual === (c.expect_missing || []).join(',');
if (c.type === 'markdown') {
return (c.expect_contains || []).every((s) => actual.includes(s))
&& (c.expect_not_contains || []).every((s) => !actual.includes(s));
}
if (c.type === 'audit') return actual === String(c.expect_unverified);
return actual === c.expect;
}
let failed = 0;
for (const c of cases) {
const actual = run(c);
const ok = check(c, actual);
if (!ok) failed += 1;
console.log(`${ok ? 'PASS' : 'FAIL'} ${c.id}${ok ? '' : ` expected=${JSON.stringify(c.expect || c.expect_missing || c.expect_contains)} actual=${JSON.stringify(actual)}`}`);
}
console.log(`\n${cases.length - failed}/${cases.length} passed`);
process.exit(failed ? 1 : 0);
+331
View File
@@ -0,0 +1,331 @@
#!/usr/bin/env node
'use strict';
/**
* 把旧 customer-service-kb/*.md 导入 LibreDesk 帮助中心(文章 + ai_enabled)。
*
* 为什么用文章而不是 snippets:
* - 只有帮助中心文章能被 AI 引用([[cite:id]])
* - 会同时进入帮助中心站内搜索
*
* 鉴权:用 System 账号登录拿 session + CSRF(不依赖 API key)。
* 用法:
* node scripts/import-kb-helpcenter.js --dry-run
* node scripts/import-kb-helpcenter.js
*/
const fs = require('fs');
const path = require('path');
const ROOT = path.resolve(__dirname, '..');
const KB_DIR = '/home/dev/code/crm/crm-ai-demo/customer-service-kb';
const DRY = process.argv.includes('--dry-run');
// 文件 -> collection(跳过 site-map:导航类内容会污染 FAQ 向量检索)
const COLLECTIONS = [
{ file: '01-registration-account.md', slug: 'registration', name: 'Registration & Account' },
{ file: '02-ordering.md', slug: 'ordering', name: 'Ordering' },
{ file: '03-pre-order.md', slug: 'pre-order', name: 'Pre-order' },
{ file: '04-payment-vat-invoice.md', slug: 'payment', name: 'Payment, VAT & Invoices' },
{ file: '05-shipping-delivery.md', slug: 'shipping', name: 'Shipping & Delivery' },
{ file: '06-returns-refunds.md', slug: 'returns', name: 'Returns & Refunds' },
{ file: '07-factory-direct.md', slug: 'factory-direct', name: 'Factory-Direct' },
{ file: '09-membership-rewards.md', slug: 'membership', name: 'Membership & Rewards' },
{ file: '10-products-and-jewelry-care.md', slug: 'products', name: 'Products & Jewelry Care' },
];
const HELP_CENTER = {
name: 'Yehwang Help Center',
slug: 'yehwang-help',
page_title: 'Yehwang Help Center',
meta_description: 'FAQ for Yehwang wholesale customers: ordering, payment, shipping, returns and more.',
default_locale: 'en',
allowed_locales: ['en'],
template: 'classic',
};
function loadEnv() {
const out = {};
for (const line of fs.readFileSync(path.join(ROOT, '.env'), 'utf8').split('\n')) {
const t = line.trim();
if (!t || t.startsWith('#')) continue;
const eq = t.indexOf('=');
if (eq < 0) continue;
let v = t.slice(eq + 1).trim();
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
out[t.slice(0, eq).trim()] = v;
}
return out;
}
// ---------------------------------------------------------------- markdown -> html
function escapeHtml(s) {
return s
.replace(/&(?!(amp|lt|gt|quot|#\d+);)/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function inline(s) {
let t = escapeHtml(s);
t = t.replace(/`([^`]+)`/g, '<code>$1</code>');
t = t.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
t = t.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2">$1</a>');
return t;
}
function answerToHtml(md) {
const lines = md.replace(/\r\n/g, '\n').split('\n');
const blocks = [];
let para = [];
let list = null; // { ordered, items }
const flushPara = () => {
if (para.length) {
blocks.push(`<p>${inline(para.join(' '))}</p>`);
para = [];
}
};
const flushList = () => {
if (list) {
const tag = list.ordered ? 'ol' : 'ul';
blocks.push(`<${tag}>` + list.items.map((i) => `<li>${inline(i)}</li>`).join('') + `</${tag}>`);
list = null;
}
};
for (const raw of lines) {
const line = raw.trim();
if (!line) {
flushPara();
flushList();
continue;
}
const ol = line.match(/^(\d+)[.)]\s+(.*)$/);
const ul = line.match(/^[-*+]\s+(.*)$/);
if (ol) {
flushPara();
if (!list || !list.ordered) { flushList(); list = { ordered: true, items: [] }; }
list.items.push(ol[2]);
} else if (ul) {
flushPara();
if (!list || list.ordered) { flushList(); list = { ordered: false, items: [] }; }
list.items.push(ul[1]);
} else {
flushList();
para.push(line);
}
}
flushPara();
flushList();
return blocks.join('\n');
}
function parseFrontmatter(text) {
const m = text.match(/^---\n([\s\S]*?)\n---\n?/);
if (!m) return { meta: {}, body: text };
const meta = {};
for (const line of m[1].split('\n')) {
const kv = line.match(/^([a-z_]+):\s*(.*)$/);
if (kv) meta[kv[1]] = kv[2];
}
return { meta, body: text.slice(m[0].length) };
}
function parseSections(body) {
const parts = body.split(/^### Q:\s*/m).slice(1);
return parts.map((p) => {
const lines = p.split('\n');
const question = lines.shift().trim();
const answerMd = lines.join('\n').trim();
return { question, answerMd };
}).filter((s) => s.question && s.answerMd);
}
function normalizeQ(q) {
return q.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
}
function plainText(html) {
return html.replace(/<[^>]+>/g, ' ').replace(/&amp;/g, '&').replace(/\s+/g, ' ').trim();
}
// ---------------------------------------------------------------- HTTP client
function createClient(baseURL) {
const cookies = new Map();
let csrf = '';
function cookieHeader() {
return [...cookies.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
}
function storeCookies(res) {
const list = typeof res.headers.getSetCookie === 'function'
? res.headers.getSetCookie()
: [res.headers.get('set-cookie')].filter(Boolean);
for (const c of list) {
const [pair] = c.split(';');
const eq = pair.indexOf('=');
if (eq > 0) {
const k = pair.slice(0, eq).trim();
const v = pair.slice(eq + 1).trim();
cookies.set(k, v);
if (k === 'csrf_token') csrf = v;
}
}
}
async function req(method, urlPath, body) {
const headers = { Accept: 'application/json' };
if (cookies.size) headers.Cookie = cookieHeader();
if (body !== undefined) headers['Content-Type'] = 'application/json';
if (csrf && method !== 'GET' && method !== 'HEAD') headers['X-CSRFTOKEN'] = csrf;
const res = await fetch(baseURL + urlPath, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
storeCookies(res);
const text = await res.text();
let json = null;
try { json = text ? JSON.parse(text) : null; } catch { /* ignore */ }
if (!res.ok) {
const msg = json && json.message ? json.message : text.slice(0, 300);
throw new Error(`${method} ${urlPath} -> HTTP ${res.status}: ${msg}`);
}
return json;
}
return { req, get: (p) => req('GET', p), post: (p, b) => req('POST', p, b), put: (p, b) => req('PUT', p, b) };
}
function asData(json) {
if (!json) return null;
return json.data !== undefined ? json.data : json;
}
// ---------------------------------------------------------------- main
async function main() {
const env = loadEnv();
const base = (env.LIBREDESK_BASE_URL || 'http://192.168.31.61:9000').replace(/\/+$/, '');
const email = env.LIBREDESK_SYSTEM_EMAIL || 'System';
const password = env.LIBREDESK_SYSTEM_USER_PASSWORD;
if (!password) throw new Error('缺少 LIBREDESK_SYSTEM_USER_PASSWORD');
// 1. 解析 KB
const plans = [];
const seen = new Set();
let totalQuestions = 0;
for (const col of COLLECTIONS) {
const file = path.join(KB_DIR, col.file);
if (!fs.existsSync(file)) { console.warn(`跳过缺失文件 ${col.file}`); continue; }
const { body } = parseFrontmatter(fs.readFileSync(file, 'utf8'));
const sections = parseSections(body);
const articles = [];
for (const s of sections) {
const key = normalizeQ(s.question);
if (!key || seen.has(key)) continue;
seen.add(key);
const html = answerToHtml(s.answerMd);
const text = plainText(html);
articles.push({
title: s.question.replace(/\s+/g, ' ').trim(),
content: `<h2>${escapeHtml(s.question)}</h2>\n${html}`,
excerpt: text.slice(0, 200),
});
}
totalQuestions += articles.length;
plans.push({ ...col, articles });
}
console.log(`解析完成:${plans.length} 个 collection,${totalQuestions} 篇文章(已全局去重)`);
for (const p of plans) console.log(` - ${p.name}: ${p.articles.length}`);
if (DRY) return;
// 2. 登录
const c = createClient(base);
await c.post('/api/v1/auth/login', { email, password });
console.log(`登录成功:${email}`);
// 3. 帮助中心
const existingHC = asData(await c.get('/api/v1/help-centers')) || [];
let hc = existingHC.find((x) => x.slug === HELP_CENTER.slug);
if (!hc) {
hc = asData(await c.post('/api/v1/help-centers', {
...HELP_CENTER,
allowed_locales: HELP_CENTER.allowed_locales,
theme: {},
}));
console.log(`创建帮助中心 #${hc.id} ${hc.slug}`);
} else {
console.log(`复用帮助中心 #${hc.id} ${hc.slug}`);
}
// 4. collections + articles
const existingCols = asData(await c.get(`/api/v1/help-centers/${hc.id}/collections`)) || [];
// 注意:服务端会用 name 生成 slug(重名时追加 -2),因此按 name 匹配而不是 slug。
const normName = (s) => String(s || '').trim().toLowerCase();
const colByName = new Map(existingCols.map((x) => [normName(x.name), x]));
// 现有文章标题(按 collection 分组,避免重复导入)
const treeResp = asData(await c.get(`/api/v1/help-centers/${hc.id}/tree`)) || {};
const titlesByColId = new Map();
const walk = (nodes) => {
for (const n of nodes || []) {
const set = titlesByColId.get(n.id) || new Set();
for (const a of n.articles || []) set.add(normalizeQ(a.title));
titlesByColId.set(n.id, set);
walk(n.children);
}
};
walk(treeResp.tree);
let createdCols = 0, createdArticles = 0, skipped = 0;
let order = 0;
for (const plan of plans) {
order += 10;
let col = colByName.get(normName(plan.name));
if (!col) {
col = asData(await c.post(`/api/v1/help-centers/${hc.id}/collections`, {
slug: plan.slug,
locale: 'en',
name: plan.name,
description: '',
icon: '',
sort_order: order,
is_published: true,
}));
createdCols++;
colByName.set(normName(plan.name), col);
}
const existingTitles = titlesByColId.get(col.id) || new Set();
let added = 0;
let i = 0;
for (const art of plan.articles) {
i += 1;
if (existingTitles.has(normalizeQ(art.title))) { skipped++; continue; }
await c.post(`/api/v1/collections/${col.id}/articles`, {
locale: 'en',
title: art.title,
content: art.content,
excerpt: art.excerpt,
sort_order: i * 10,
status: 'published',
ai_enabled: true,
});
added++;
createdArticles++;
}
console.log(` ${plan.name}: +${added}`);
}
console.log(`\n完成:新建 collection ${createdCols},文章 ${createdArticles},跳过已存在 ${skipped}`);
console.log('注意:向量索引依赖 AI Provider 的 embedding 配置;配置后会自动 reindex。');
}
main().catch((err) => {
console.error('[import-kb]', err.message);
process.exit(1);
});
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# 启动/重启公网 HTTPS(Cloudflare Quick Tunnel),并把 LibreDesk root_url 指过去。
# 注意:quick tunnel 每次重启 URL 会变,需要重新跑本脚本(Meta 里的 webhook 也要同步改)。
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/.."
docker compose up -d public-gateway cloudflared
URL=""
for _ in $(seq 1 30); do
URL=$(docker logs crmnew-cloudflared 2>&1 | grep -oE 'https://[a-zA-Z0-9.-]+\.trycloudflare\.com' | tail -1 || true)
[[ -n "$URL" ]] && break
sleep 1
done
if [[ -z "$URL" ]]; then
echo "未拿到 cloudflared 公网地址,请检查: docker logs crmnew-cloudflared" >&2
exit 1
fi
node scripts/set-public-url.js "$URL"
# 如果已经配置过 WhatsApp,同步更新 Meta 的 app 级 webhook(隧道 URL 变化时必须)
if grep -q '^WA_APP_ID=' .env 2>/dev/null; then
node scripts/set-wa-webhook.js "$URL"
fi
cat <<EOF
公网 HTTPS: $URL
WhatsApp webhook 地址: $URL/webhooks/whatsapp/<inbox_id>
仅暴露:
/webhooks/whatsapp/* (Meta 回调)
/hc/* (帮助中心,AI 引用链接)
/widget* /static/public/*
管理后台 / API 仍只在内网 http://192.168.31.61:9000
EOF
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env node
'use strict';
// 质量告警:读 bridge_ai_metrics,阈值超限时发邮件。建议 cron 每 15-30 分钟跑一次。
// 用法: node scripts/quality-alert.js [hours] [--dry-run]
const fs = require('fs');
const path = require('path');
const { loadConfig } = require('../bridge/src/config');
const { createMetrics } = require('../bridge/src/metrics');
const { evaluateQuality } = require('../bridge/src/quality');
const { sendMail } = require('../bridge/src/smtp');
const ROOT = path.resolve(__dirname, '..');
const env = {};
for (const line of fs.readFileSync(path.join(ROOT, '.env'), 'utf8').split('\n')) {
if (!line.includes('=') || line.trim().startsWith('#')) continue;
const i = line.indexOf('=');
env[line.slice(0, i).trim()] = line.slice(i + 1).trim();
}
const password = encodeURIComponent(env.POSTGRES_PASSWORD || '');
env.PG_URL = process.env.QUALITY_PG_URL || `postgres://crmnew:${password}@127.0.0.1:5440/bridge`;
const cfg = loadConfig({ ...process.env, ...env });
const hours = Number(process.argv[2]) || 1;
const dryRun = process.argv.includes('--dry-run');
(async () => {
const metrics = createMetrics(cfg);
const report = await metrics.report(hours);
const result = evaluateQuality(report.summary);
if (result.skipped) {
console.log(JSON.stringify({ skipped: true, reason: result.reason, summary: report.summary }));
await metrics.pool.end();
return;
}
if (!result.alerts.length) {
console.log(JSON.stringify({ ok: true, summary: report.summary }));
await metrics.pool.end();
return;
}
const text = [
`Yehwang bridge AI 质量告警(最近 ${hours} 小时)`,
'',
...result.alerts.map((a) => `- ${a.message}`),
'',
'summary: ' + JSON.stringify(report.summary),
'tools: ' + JSON.stringify(report.tools),
].join('\n');
console.log(text);
if (dryRun) {
console.log('[dry-run] not sending email');
} else if (cfg.smtpHost && cfg.smtpUser && cfg.smtpPass) {
const to = env.ALERT_EMAIL_TO || cfg.smtpUser;
await sendMail(
{ host: cfg.smtpHost, port: cfg.smtpPort, user: cfg.smtpUser, pass: cfg.smtpPass, from: cfg.smtpFrom },
{ to, subject: 'Yehwang bridge AI 质量告警', text }
);
console.log(`[alert] email sent to ${to}`);
} else {
console.error('[alert] SMTP not configured; alert only logged');
}
await metrics.pool.end();
})().catch((err) => { console.error('[quality-alert]', err.message); process.exit(1); });
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env node
'use strict';
// 质量日报:读取 bridge 库的 bridge_ai_metrics,输出最近 N 小时指标。
const path = require('path');
const { loadConfig } = require('../bridge/src/config');
const { createMetrics } = require('../bridge/src/metrics');
const fs = require('fs');
const envFile = path.resolve(__dirname, '..', '.env');
const env = {};
for (const line of fs.readFileSync(envFile, 'utf8').split('\n')) {
if (!line.includes('=') || line.trim().startsWith('#')) continue;
const i = line.indexOf('=');
env[line.slice(0, i).trim()] = line.slice(i + 1).trim();
}
const password = encodeURIComponent(env.POSTGRES_PASSWORD || '');
env.PG_URL = process.env.QUALITY_PG_URL || `postgres://crmnew:${password}@127.0.0.1:5440/bridge`;
const cfg = loadConfig({ ...process.env, ...env });
const hours = Number(process.argv[2]) || 24;
(async () => {
const metrics = createMetrics(cfg);
if (!metrics.enabled) throw new Error('PG_URL is not configured');
const report = await metrics.report(hours);
console.log(JSON.stringify(report, null, 2));
await metrics.pool.end();
})().catch((err) => {
console.error('[quality-report]', err.message);
process.exit(1);
});
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# 重建旧 KB 的 parent-child 索引(调用旧 bridge 容器里的 indexer),再回填 text_tsv。
# 旧表会由 indexer 归档为 kb_docs_vN;额外先做一份 kb_docs_backup_<date> 备份。
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
DATE="$(date +%Y%m%d_%H%M%S)"
docker exec crm-postgres psql -U postgres -d products -c \
"CREATE TABLE IF NOT EXISTS kb_docs_backup_${DATE} AS SELECT * FROM kb_docs;" >/dev/null
docker exec crm-postgres psql -U postgres -d products <<'SQL' >/dev/null
CREATE OR REPLACE FUNCTION kb_docs_tsv_update() RETURNS trigger AS $$
BEGIN
NEW.text_tsv := to_tsvector('english',
coalesce(NEW.question,'') || ' ' || coalesce(NEW.answer,'') || ' ' ||
coalesce(array_to_string(NEW.keywords,' '),'') || ' ' ||
coalesce(NEW.title,'') || ' ' || coalesce(NEW.section,''));
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
SQL
docker exec crm-bridge node -e "
const {loadConfig}=require('./config/loader');
const {createEmbeddingClient}=require('./utils/embedding');
const {createKbIndexer}=require('./faq/kbIndexer');
(async()=>{
const config=loadConfig();
const emb=createEmbeddingClient(config.emb, config.PG_URL);
const idx=createKbIndexer({pool:emb.pool, embeddingClient:emb, keepVersions:2});
const res=await idx.indexNewVersion({skipIfUnchanged:false, note:'parent-child rebuild', aiGenerated:false});
console.log(JSON.stringify(res));
await emb.pool.end();
})().catch(e=>{console.error(e.message);process.exit(1);});
"
cd "$ROOT" && node scripts/upgrade-kb-schema.js
echo "[rebuild-kb-index] done; backup=kb_docs_backup_${DATE}"
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env node
'use strict';
/**
* 把 bridge 的工具 manifest 注册到 LibreDesk(Admin → AI → Tools)。
*
* 鉴权二选一:
* 1. .env 配了 LIBREDESK_API_KEY/SECRET → 用 API key
* 2. 否则用 System 账号登录(LIBREDESK_SYSTEM_USER_PASSWORD)拿 session + CSRF
*
* 用法:node scripts/register-tools.js
*/
const fs = require('fs');
const path = require('path');
const ROOT = path.resolve(__dirname, '..');
function loadEnv(file) {
const out = {};
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
const t = line.trim();
if (!t || t.startsWith('#')) continue;
const eq = t.indexOf('=');
if (eq < 0) continue;
let v = t.slice(eq + 1).trim();
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
out[t.slice(0, eq).trim()] = v;
}
return out;
}
function createClient(baseURL) {
const cookies = new Map();
let csrf = '';
let authHeader = '';
const cookieHeader = () => [...cookies.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
function store(res) {
const list = typeof res.headers.getSetCookie === 'function' ? res.headers.getSetCookie() : [res.headers.get('set-cookie')].filter(Boolean);
for (const c of list) {
const [pair] = c.split(';');
const eq = pair.indexOf('=');
if (eq > 0) {
const k = pair.slice(0, eq).trim();
const v = pair.slice(eq + 1).trim();
cookies.set(k, v);
if (k === 'csrf_token') csrf = v;
}
}
}
async function req(method, p, body) {
const headers = { Accept: 'application/json' };
if (authHeader) headers.Authorization = authHeader;
else if (cookies.size) headers.Cookie = cookieHeader();
if (body !== undefined) headers['Content-Type'] = 'application/json';
if (!authHeader && csrf && method !== 'GET') headers['X-CSRFTOKEN'] = csrf;
const res = await fetch(baseURL + p, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
store(res);
const text = await res.text();
let json = null;
try { json = text ? JSON.parse(text) : null; } catch { /* ignore */ }
if (!res.ok) throw new Error(`${method} ${p} -> HTTP ${res.status}: ${(json && json.message) || text.slice(0, 300)}`);
return json;
}
return {
setAuth: (h) => { authHeader = h; },
login: (email, password) => req('POST', '/api/v1/auth/login', { email, password }),
get: (p) => req('GET', p),
post: (p, b) => req('POST', p, b),
put: (p, b) => req('PUT', p, b),
};
}
async function main() {
const env = loadEnv(path.join(ROOT, '.env'));
const base = (env.LIBREDESK_BASE_URL || 'http://192.168.31.61:9000').replace(/\/+$/, '');
const toolsKey = env.TOOLS_SHARED_KEY;
if (!toolsKey) throw new Error('TOOLS_SHARED_KEY 未配置');
const c = createClient(base);
if (env.LIBREDESK_API_KEY && env.LIBREDESK_API_SECRET) {
c.setAuth(`token ${env.LIBREDESK_API_KEY}:${env.LIBREDESK_API_SECRET}`);
console.log('使用 API key 鉴权');
} else {
await c.login(env.LIBREDESK_SYSTEM_EMAIL || 'System', env.LIBREDESK_SYSTEM_USER_PASSWORD);
console.log('使用 System 会话鉴权');
}
const manifestRes = await fetch('http://127.0.0.1:4100/tools/manifest', { headers: { 'X-Tools-Key': toolsKey } });
if (!manifestRes.ok) throw new Error(`bridge manifest 不可用(HTTP ${manifestRes.status}),先启动全栈`);
const manifest = await manifestRes.json();
const listJson = await c.get('/api/v1/ai/tools');
const existing = Array.isArray(listJson) ? listJson : (listJson.data || []);
const byName = new Map(existing.map((t) => [t.name, t]));
let failed = 0;
for (const tool of manifest.tools) {
const body = {
name: tool.name,
description: tool.description,
url: tool.url, // http://bridge:4100/tools/<name>(容器网络内可达)
method: 'POST',
auth: { headers: [{ key: 'X-Tools-Key', value: toolsKey }] },
parameters: tool.parameters,
enabled: true,
copilot_enabled: true,
generate_reply_enabled: true,
requires_agent_approval: false,
requires_verification: Boolean(tool.requires_verification),
};
const current = byName.get(tool.name);
try {
if (current) {
await c.put(`/api/v1/ai/tools/${current.id}`, body);
console.log(` ✓ ${tool.name} (updated)`);
} else {
await c.post('/api/v1/ai/tools', body);
console.log(` ✓ ${tool.name} (created)`);
}
} catch (err) {
failed += 1;
console.error(` ✗ ${tool.name}: ${err.message}`);
}
}
if (failed) {
console.error('\n部分工具注册失败,回退到 Admin → AI → Tools 手工创建。');
process.exitCode = 1;
}
}
main().catch((err) => {
console.error('[register-tools]', err.message);
process.exit(1);
});
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env node
'use strict';
/**
* 渲染 libredesk/config/config.toml:
* - 读取顶层 .env(crm-new/.env)
* - 替换模板中的 DB 用户/密码/加密密钥占位符
* - 输出 600 权限的 config.toml(不入库)
*/
const fs = require('fs');
const path = require('path');
const root = path.resolve(__dirname, '..');
const envPath = path.join(root, '.env');
const tplPath = path.join(root, 'libredesk', 'config', 'config.toml.example');
const outPath = path.join(root, 'libredesk', 'config', 'config.toml');
function parseEnv(text) {
const out = {};
for (const line of text.split('\n')) {
const t = line.trim();
if (!t || t.startsWith('#')) continue;
const eq = t.indexOf('=');
if (eq < 0) continue;
const key = t.slice(0, eq).trim();
let value = t.slice(eq + 1).trim();
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
out[key] = value;
}
return out;
}
if (!fs.existsSync(envPath)) {
console.error('[render-config] .env 不存在,请先运行 scripts/bootstrap.sh');
process.exit(1);
}
const env = parseEnv(fs.readFileSync(envPath, 'utf8'));
for (const key of ['POSTGRES_USER', 'POSTGRES_PASSWORD', 'LIBREDESK_ENC_KEY']) {
if (!env[key]) {
console.error(`[render-config] 缺少 ${key}`);
process.exit(1);
}
}
if (env.LIBREDESK_ENC_KEY.length !== 32) {
console.error('[render-config] LIBREDESK_ENC_KEY 必须是 32 字符(openssl rand -hex 16)');
process.exit(1);
}
const rendered = fs
.readFileSync(tplPath, 'utf8')
.replaceAll('__DB_USER__', env.POSTGRES_USER)
.replaceAll('__DB_PASSWORD__', env.POSTGRES_PASSWORD)
.replaceAll('__ENCRYPTION_KEY__', env.LIBREDESK_ENC_KEY);
fs.writeFileSync(outPath, rendered, { mode: 0o600 });
console.log(`[render-config] wrote ${path.relative(root, outPath)} (mode 600)`);
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env bash
# 测试服 JSON-RPC 端口外网被封:在本机建 SSH 隧道,供 crm-new 的 bridge 访问。
#
# 默认只启动隧道并打印 .env 需要的 4 行配置;
# 加 --apply 会自动更新 .env 里的 RPC URL 并重建 bridge。
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/.."
APPLY=0
[[ "${1:-}" == "--apply" ]] && APPLY=1
NET=$(docker network ls --format '{{.Name}}' | grep -E '^crm-new.*_default$' | head -1 || true)
if [[ -z "$NET" ]]; then
echo "找不到 crm-new 的 compose 网络,请先启动 stack(docker compose up -d)" >&2
exit 1
fi
GW=$(docker network inspect "$NET" -f '{{range .IPAM.Config}}{{.Gateway}}{{end}}')
PORTS=(24503 24505 24506 24509)
ARGS=()
for p in "${PORTS[@]}"; do
if ss -ltn "sport = :$p" 2>/dev/null | grep -q "$GW"; then
echo "端口 $p 已在 $GW 监听,跳过"
else
ARGS+=("-L" "$GW:$p:127.0.0.1:$p")
fi
done
if [[ ${#ARGS[@]} -gt 0 ]]; then
ssh -F ~/.ssh/config -fN \
-o ExitOnForwardFailure=yes \
-o ServerAliveInterval=30 \
-o ServerAliveCountMax=3 \
"${ARGS[@]}" yw-dev
echo "隧道已建立:$GW -> yw-dev 127.0.0.1"
fi
cat <<EOF
bridge 需要以下配置(当前网关 $GW):
RECOMMEND_URL=jsonrpc://$GW:24503
ORDER_API_URL=jsonrpc://$GW:24505
MEMBER_RPC_URL=jsonrpc://$GW:24506
INVENTORY_API_URL=jsonrpc://$GW:24509
EOF
if [[ $APPLY -eq 1 ]]; then
python3 - "$GW" <<'PY'
import pathlib, sys
gw = sys.argv[1]
p = pathlib.Path('.env')
lines = p.read_text().split('\n')
mapping = {
'RECOMMEND_URL': f'jsonrpc://{gw}:24503',
'ORDER_API_URL': f'jsonrpc://{gw}:24505',
'MEMBER_RPC_URL': f'jsonrpc://{gw}:24506',
'INVENTORY_API_URL': f'jsonrpc://{gw}:24509',
}
for k, v in mapping.items():
for i, line in enumerate(lines):
if line.startswith(k + '='):
lines[i] = f'{k}={v}'
break
else:
lines.append(f'{k}={v}')
p.write_text('\n'.join(lines))
print('已更新 .env')
PY
docker compose up -d --force-recreate bridge
echo "已重建 bridge"
fi
echo
echo "注意:网络重建/机器重启后网关 IP 可能变化,重跑本脚本即可。"
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
# 本地静态测试页(widget JWT 演示)
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/../web"
echo "http://192.168.31.61:${WEB_PORT:-8088}/demo.html"
exec python3 -m http.server "${WEB_PORT:-8088}" --bind 0.0.0.0
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env node
'use strict';
/**
* 把 LibreDesk 的 app.root_url 设置为公网 HTTPS 地址(WhatsApp webhook 必须)。
* 用法:node scripts/set-public-url.js https://xxx.trycloudflare.com
*/
const fs = require('fs');
const path = require('path');
const ROOT = path.resolve(__dirname, '..');
const publicURL = String(process.argv[2] || '').trim().replace(/\/+$/, '');
if (!/^https:\/\//.test(publicURL)) {
console.error('用法: node scripts/set-public-url.js https://<public-host>');
process.exit(1);
}
function loadEnv(file) {
const out = {};
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
const t = line.trim();
if (!t || t.startsWith('#')) continue;
const eq = t.indexOf('=');
if (eq < 0) continue;
let v = t.slice(eq + 1).trim();
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
out[t.slice(0, eq).trim()] = v;
}
return out;
}
async function main() {
const env = loadEnv(path.join(ROOT, '.env'));
const base = (env.LIBREDESK_BASE_URL || 'http://192.168.31.61:9000').replace(/\/+$/, '');
const login = await fetch(base + '/api/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: env.LIBREDESK_SYSTEM_EMAIL || 'System', password: env.LIBREDESK_SYSTEM_USER_PASSWORD }),
});
if (!login.ok) throw new Error(`login failed: HTTP ${login.status}`);
const cookies = login.headers.getSetCookie().map((c) => c.split(';')[0]);
const csrfCookie = cookies.find((c) => c.startsWith('csrf_token='));
const csrf = csrfCookie.slice(csrfCookie.indexOf('=') + 1);
const keys = [
'app.site_name', 'app.lang', 'app.max_file_upload_size', 'app.favicon_url', 'app.logo_url',
'app.root_url', 'app.allowed_file_upload_extensions', 'app.timezone', 'app.business_hours_id',
'app.show_conversation_subject',
];
const currentRes = await fetch(base + '/api/v1/settings/general', { headers: { Cookie: cookies.join('; ') } });
const currentText = await currentRes.text();
let current = {};
try { current = JSON.parse(currentText).data || JSON.parse(currentText); } catch { /* ignore */ }
const body = {};
for (const k of keys) if (k in current) body[k] = current[k];
body['app.root_url'] = publicURL;
const res = await fetch(base + '/api/v1/settings/general', {
method: 'PUT',
headers: { Cookie: cookies.join('; '), 'Content-Type': 'application/json', 'X-CSRFTOKEN': csrf },
body: JSON.stringify(body),
});
const text = await res.text();
if (!res.ok) throw new Error(`update failed: HTTP ${res.status} ${text.slice(0, 200)}`);
console.log(`root_url 已设置为: ${publicURL}`);
}
main().catch((err) => {
console.error('[set-public-url]', err.message);
process.exit(1);
});
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env node
'use strict';
/**
* 配置 Meta App 级 WhatsApp webhook(cloudflared URL 变化后需要重跑)。
* 用法:node scripts/set-wa-webhook.js [https://public-host]
* 依赖 .env: WA_APP_ID / WA_APP_SECRET / WA_VERIFY_TOKEN / WA_INBOX_ID
*/
const fs = require('fs');
const path = require('path');
const ROOT = path.resolve(__dirname, '..');
function loadEnv(file) {
const out = {};
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
const t = line.trim();
if (!t || t.startsWith('#')) continue;
const eq = t.indexOf('=');
if (eq < 0) continue;
let v = t.slice(eq + 1).trim();
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
out[t.slice(0, eq).trim()] = v;
}
return out;
}
async function main() {
const env = loadEnv(path.join(ROOT, '.env'));
const publicURL = String(process.argv[2] || env.PUBLIC_BASE_URL || '').trim().replace(/\/+$/, '');
const appId = env.WA_APP_ID;
const appSecret = env.WA_APP_SECRET;
const verify = env.WA_VERIFY_TOKEN;
const inboxId = env.WA_INBOX_ID;
const missing = ['WA_APP_ID', 'WA_APP_SECRET', 'WA_VERIFY_TOKEN', 'WA_INBOX_ID'].filter((k) => !env[k]);
if (missing.length) throw new Error(`.env 缺少: ${missing.join(', ')}`);
if (!/^https:\/\//.test(publicURL)) throw new Error('需要公网 HTTPS 地址');
const callback = `${publicURL}/webhooks/whatsapp/${inboxId}`;
const appToken = `${appId}|${appSecret}`;
const body = new URLSearchParams({
object: 'whatsapp_business_account',
callback_url: callback,
verify_token: verify,
fields: 'messages',
access_token: appToken,
});
const res = await fetch(`https://graph.facebook.com/v26.0/${appId}/subscriptions`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
const text = await res.text();
if (!res.ok) throw new Error(`Meta webhook 配置失败: HTTP ${res.status} ${text.slice(0, 300)}`);
console.log(`Meta WhatsApp webhook 已更新: ${callback}`);
}
main().catch((err) => {
console.error('[set-wa-webhook]', err.message);
process.exit(1);
});
+240
View File
@@ -0,0 +1,240 @@
#!/usr/bin/env node
'use strict';
/**
* 建默认配置:Support 团队(可选)、AI Assistant、Live chat Inbox、Automation 规则。
* 幂等:按名称复用已有对象。
*/
const fs = require('fs');
const path = require('path');
const ROOT = path.resolve(__dirname, '..');
const NAME = {
inbox: 'Website',
assistant: 'Yehwang Assistant',
rule: 'Assign new conversations to AI Assistant',
};
function loadEnv(file) {
const out = {};
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
const t = line.trim();
if (!t || t.startsWith('#')) continue;
const eq = t.indexOf('=');
if (eq < 0) continue;
let v = t.slice(eq + 1).trim();
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
out[t.slice(0, eq).trim()] = v;
}
return out;
}
function createClient(baseURL) {
const cookies = new Map();
let csrf = '';
const cookieHeader = () => [...cookies.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
function store(res) {
const list = typeof res.headers.getSetCookie === 'function' ? res.headers.getSetCookie() : [res.headers.get('set-cookie')].filter(Boolean);
for (const c of list) {
const [pair] = c.split(';');
const eq = pair.indexOf('=');
if (eq > 0) {
const k = pair.slice(0, eq).trim();
const v = pair.slice(eq + 1).trim();
cookies.set(k, v);
if (k === 'csrf_token') csrf = v;
}
}
}
async function req(method, p, body) {
const headers = { Accept: 'application/json' };
if (cookies.size) headers.Cookie = cookieHeader();
if (body !== undefined && !(body instanceof FormData)) headers['Content-Type'] = 'application/json';
if (csrf && method !== 'GET') headers['X-CSRFTOKEN'] = csrf;
const res = await fetch(baseURL + p, {
method,
headers,
body: body === undefined ? undefined : (body instanceof FormData ? body : JSON.stringify(body)),
});
store(res);
const text = await res.text();
let json = null;
try { json = text ? JSON.parse(text) : null; } catch { /* ignore */ }
if (!res.ok) throw new Error(`${method} ${p} -> HTTP ${res.status}: ${(json && json.message) || text.slice(0, 400)}`);
return json;
}
return {
get: (p) => req('GET', p),
post: (p, b) => req('POST', p, b),
put: (p, b) => req('PUT', p, b),
};
}
const data = (j) => (j && j.data !== undefined ? j.data : j);
const INSTRUCTIONS = `You are the Yehwang B2B customer assistant for stainless-steel jewelry wholesale.
- Always reply in the customer's language.
- For policy / FAQ questions, search the knowledge base first and answer from it.
- For product questions, call search_products. Never invent catalog items, prices or stock.
- For order questions, call search_orders. If it returns verification_required, ask the customer
to verify their email with the one-time code, then retry the tool.
- For stock / pre-order questions, call search_inventory with the SKU digits.
- When you know company + contact name + product/need, call create_opportunity once.
- If you cannot help, or the customer asks for a human, hand off to a human agent.
- Never reveal another customer's data.`;
const GUARDRAILS = `Never invent prices, discounts, stock or delivery dates.
Never share another customer's order or personal data.
Never create an opportunity before the contact is verified.`;
async function main() {
const env = loadEnv(path.join(ROOT, '.env'));
const base = (env.LIBREDESK_BASE_URL || 'http://192.168.31.61:9000').replace(/\/+$/, '');
const c = createClient(base);
await c.post('/api/v1/auth/login', { email: env.LIBREDESK_SYSTEM_EMAIL || 'System', password: env.LIBREDESK_SYSTEM_USER_PASSWORD });
console.log('登录成功');
// 1. Assistant
const assistants = data(await c.get('/api/v1/ai/assistants')) || [];
let assistant = assistants.find((a) => a.name === NAME.assistant);
const tools = data(await c.get('/api/v1/ai/tools')) || [];
const toolIds = tools.map((t) => t.id);
if (!assistant) {
const payload = {
name: NAME.assistant,
description: 'Yehwang sales & service assistant',
instructions: INSTRUCTIONS,
guardrails: GUARDRAILS,
expectation: 'You are chatting with our AI assistant. I can help with products, orders and policies — and connect you to a human when needed.',
tone: 'friendly',
response_length: 'balanced',
max_turns: 8,
fallback_team_id: null,
citations_enabled: true,
handoff_enabled: true,
languages: [],
enabled: true,
tool_ids: toolIds,
};
const form = new FormData();
form.append('data', JSON.stringify(payload));
assistant = data(await c.post('/api/v1/ai/assistants', form));
console.log(`创建 Assistant #${assistant.id} (user_id=${assistant.user_id}, tools=${toolIds.join(',')})`);
} else {
console.log(`复用 Assistant #${assistant.id} (user_id=${assistant.user_id})`);
}
// 2. Live chat inbox
const inboxPayload = {
name: NAME.inbox,
channel: 'livechat',
enabled: true,
csat_enabled: true,
prompt_tags_on_reply: false,
reopen_window_hours: 0,
// 保留 widget JWT 签名用的 secret(更新时服务端会用请求值覆盖)
...(env.LIBREDESK_INBOX_SECRET ? { secret: env.LIBREDESK_INBOX_SECRET } : {}),
config: {
brand_name: 'Yehwang',
website_url: 'https://www.yehwang.com',
language: 'en-US',
fallback_language: 'en-US',
theme: 'light',
show_powered_by: false,
direct_to_conversation: true,
campaign_cooldown: '24h',
session_duration: '4320h',
launcher: { position: 'right', icon_scale: 100, spacing: { side: 24, bottom: 24 } },
greeting_message: 'Hi! How can we help you today?',
chat_introduction: 'Ask about products, orders, shipping or returns.',
trusted_domains: ['192.168.31.61:8088', 'localhost:8088', '127.0.0.1:8088', '192.168.31.61', 'localhost', '127.0.0.1', 'www.yehwang.com', 'yehwang.com'],
help: { help_center_id: 0 },
users: { allow_start_conversation: true, prevent_multiple_conversations: false },
visitors: { allow_start_conversation: true, prevent_multiple_conversations: false },
branding: {
light: {
colors: { primary: '#1f6feb' },
logo_url: '',
launcher: { logo_url: '', color: '#1f6feb' },
home_screen: { header_text_color: 'black', background: { type: 'solid', color: '#ffffff' }, fade_background: false },
},
dark: {
colors: { primary: '#1f6feb' },
logo_url: '',
launcher: { logo_url: '', color: '#1f6feb' },
home_screen: { header_text_color: 'white', background: { type: 'solid', color: '#111827' }, fade_background: false },
},
},
features: { emoji: true, file_upload: true, transcript: true },
},
};
const inboxes = data(await c.get('/api/v1/inboxes')) || [];
let inbox = inboxes.find((i) => i.name === NAME.inbox && i.channel === 'livechat');
if (!inbox) {
inbox = data(await c.post('/api/v1/inboxes', inboxPayload));
console.log(`创建 Live chat Inbox #${inbox.id} uuid=${inbox.uuid}`);
} else {
inbox = data(await c.put(`/api/v1/inboxes/${inbox.id}`, inboxPayload));
console.log(`更新 Live chat Inbox #${inbox.id} uuid=${inbox.uuid}`);
}
// 3. Automation: new conversation in this inbox -> assign assistant user
const rulesRaw = data(await c.get('/api/v1/automations/rules?type=new_conversation')) || [];
const rules = Array.isArray(rulesRaw) ? rulesRaw : (rulesRaw.rules || []);
const rulePayload = {
name: NAME.rule,
description: 'Route new live chat conversations to the AI assistant',
type: 'new_conversation',
events: [],
enabled: true,
weight: 0,
// 注意:rules 必须是数组(引擎按 []Rule 反序列化)
rules: [{
type: 'new_conversation',
execution_mode: 'all',
event: [],
group_operator: 'AND',
groups: [{
logical_op: 'AND',
rules: [{
field: 'inbox',
field_type: 'conversation',
operator: 'equals',
value: String(inbox.id),
case_sensitive_match: false,
}],
}],
actions: [{
type: 'assign_user',
value: [String(assistant.user_id)],
display_value: [assistant.name],
}],
}],
};
let rule = rules.find((r) => r.name === NAME.rule);
if (!rule) {
rule = data(await c.post('/api/v1/automations/rules', rulePayload));
console.log(`创建 Automation #${rule.id} -> assign user ${assistant.user_id}`);
} else {
rule = data(await c.put(`/api/v1/automations/rules/${rule.id}`, rulePayload));
console.log(`更新 Automation #${rule.id} -> assign user ${assistant.user_id}`);
}
const snippet = `<script>
window.LibredeskSettings = {
baseURL: '${base}',
inboxID: '${inbox.uuid}'
};
</script>
<script async src="${base}/widget.js"></script>`;
console.log('\n===== Widget 安装代码 =====');
console.log(snippet);
console.log('\n(用户登录后可用 userJWT 绑定身份,见 docs/libredesk-setup.md 第 6 节)');
}
main().catch((err) => {
console.error('[setup-defaults]', err.message);
process.exit(1);
});
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# crm-new 冒烟:三组件健康 + bridge 鉴权/验证门控
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
# shellcheck disable=SC1091
set -a; . ./.env; set +a
say() { printf '\n== %s ==\n' "$1"; }
code() { curl -s -o /tmp/crmnew-smoke.out -w '%{http_code}' "$@" || true; }
say "LibreDesk /health"
echo "HTTP $(code http://localhost:9000/health) $(head -c 120 /tmp/crmnew-smoke.out 2>/dev/null)"
say "Twenty /healthz"
echo "HTTP $(code http://localhost:3100/healthz)"
say "bridge /healthz"
echo "HTTP $(code http://localhost:4100/healthz) $(head -c 200 /tmp/crmnew-smoke.out 2>/dev/null)"
say "bridge manifest"
echo "HTTP $(code -H "X-Tools-Key: ${TOOLS_SHARED_KEY}" http://localhost:4100/tools/manifest)"
python3 -c "import json;d=json.load(open('/tmp/crmnew-smoke.out'));print('tools:',[t['name'] for t in d['tools']])" 2>/dev/null || true
say "search_products(未验证,应 200)"
echo "HTTP $(code -X POST http://localhost:4100/tools/search_products \
-H "X-Tools-Key: ${TOOLS_SHARED_KEY}" -H 'Content-Type: application/json' \
-d '{"query":"gold hoop earrings"}')"
say "search_orders(未验证,应 403)"
echo "HTTP $(code -X POST http://localhost:4100/tools/search_orders \
-H "X-Tools-Key: ${TOOLS_SHARED_KEY}" -H 'Content-Type: application/json' -d '{}')"
rm -f /tmp/crmnew-smoke.out
echo
echo "冒烟完成。"
+183
View File
@@ -0,0 +1,183 @@
#!/usr/bin/env node
'use strict';
/**
* 在 Twenty 中创建初始用户 + Workspace,并直接在该库里生成 API key(不经过 UI)。
*
* 思路类比旧项目 gen_apikey.js:
* - 不用浏览器注册,脚本调 GraphQL signUp / signUpInNewWorkspace 建好 workspace
* - 再用 Twenty 自带命令 `workspace:generate-api-key` 在库内创建 apiKey + roleTarget,并签出 JWT
* - 结果写回 .env(TWENTY_WORKSPACE_ID / TWENTY_API_KEY),重建 bridge
*
* 用法:node scripts/twenty-bootstrap.js
*/
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { execFileSync } = require('child_process');
const ROOT = path.resolve(__dirname, '..');
const CONTAINER = 'crmnew-twenty-server';
function loadEnv(file) {
const out = {};
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
const t = line.trim();
if (!t || t.startsWith('#')) continue;
const eq = t.indexOf('=');
if (eq < 0) continue;
let v = t.slice(eq + 1).trim();
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
out[t.slice(0, eq).trim()] = v;
}
return out;
}
function setEnv(file, kv) {
const lines = fs.readFileSync(file, 'utf8').split('\n');
for (const [k, v] of Object.entries(kv)) {
const idx = lines.findIndex((l) => l.startsWith(`${k}=`));
if (idx >= 0) lines[idx] = `${k}=${v}`;
else lines.push(`${k}=${v}`);
}
fs.writeFileSync(file, lines.join('\n'), { mode: 0o600 });
}
async function gql(base, query, variables, token) {
// 认证类 mutation 在 core(/metadata)端点,不在 workspace 数据端点 /graphql
const res = await fetch(`${base}/metadata`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ query, variables }),
});
const json = await res.json();
if (json.errors && json.errors.length) {
throw new Error(`GraphQL error: ${JSON.stringify(json.errors).slice(0, 400)}`);
}
return json.data;
}
function psql(sql) {
return execFileSync('docker', ['exec', 'crmnew-postgres', 'psql', '-U', 'crmnew', '-d', 'twenty', '-tAc', sql], {
encoding: 'utf8',
}).trim();
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function main() {
const env = loadEnv(path.join(ROOT, '.env'));
const base = (env.TWENTY_SERVER_URL || 'http://192.168.31.61:3100').replace(/\/+$/, '');
const email = env.TWENTY_ADMIN_EMAIL;
const password = env.TWENTY_ADMIN_PASSWORD;
if (!email || !password) throw new Error('缺少 TWENTY_ADMIN_EMAIL / TWENTY_ADMIN_PASSWORD(写入 .env,不要硬编码)');
// 已存在 workspace 就跳过创建
let workspaceId = env.TWENTY_WORKSPACE_ID || '';
if (workspaceId) {
const exists = psql(`select count(*) from core.workspace where id='${workspaceId}';`);
if (exists === '1') console.log(`复用已有 workspace ${workspaceId}`);
else workspaceId = '';
}
if (!workspaceId) {
console.log('1) signUp…');
const signUp = await gql(base, `
mutation($email: String!, $password: String!) {
signUp(email: $email, password: $password) {
tokens { accessOrWorkspaceAgnosticToken { token } }
}
}
`, { email, password });
const agnostic = signUp.signUp.tokens.accessOrWorkspaceAgnosticToken.token;
console.log('2) signUpInNewWorkspace…');
const created = await gql(base, `
mutation($input: SignUpInNewWorkspaceInput) {
signUpInNewWorkspace(input: $input) {
loginToken { token }
workspace { id }
}
}
`, { input: { displayName: 'Yehwang', subdomain: 'yehwang' } }, agnostic);
workspaceId = created.signUpInNewWorkspace.workspace.id;
const loginToken = created.signUpInNewWorkspace.loginToken.token;
const workspaceOrigin = created.signUpInNewWorkspace.workspace.workspaceUrls?.subdomainUrl || base;
console.log(` workspace: ${workspaceId}`);
// 自托管环境没有真实邮件服务:直接标记邮箱已验证,才能换取 workspace token
psql(`update core."user" set "isEmailVerified"=true where email='${email}';`);
console.log('3) 交换 workspace token…');
const exchanged = await gql(base, `
mutation($loginToken: String!, $origin: String!) {
getAuthTokensFromLoginToken(loginToken: $loginToken, origin: $origin) {
tokens { accessOrWorkspaceAgnosticToken { token } }
}
}
`, { loginToken, origin: base });
const accessToken = exchanged.getAuthTokensFromLoginToken.tokens.accessOrWorkspaceAgnosticToken.token;
console.log('4) 激活 workspace(建 schema / 默认角色)…');
await gql(base, `
mutation($data: ActivateWorkspaceInput!) {
activateWorkspace(data: $data) { id }
}
`, { data: { displayName: 'Yehwang' } }, accessToken);
setEnv(path.join(ROOT, '.env'), {
TWENTY_WORKSPACE_ID: workspaceId,
TWENTY_ADMIN_EMAIL: email,
TWENTY_ADMIN_PASSWORD: password,
});
}
// 等 workspace 初始化完成(PENDING_CREATION -> ACTIVE/CREATED)
for (let i = 0; i < 60; i += 1) {
const status = psql(`select "activationStatus" from core.workspace where id='${workspaceId}';`);
if (status === 'ACTIVE' || status === 'CREATED') {
console.log(` workspace 状态: ${status}`);
break;
}
if (i === 59) throw new Error(`workspace 未就绪,状态=${status}`);
await sleep(2000);
}
// 3) 在库内生成 API key(Twenty 官方命令,dev/test 环境限制)
console.log('5) 生成 API key(workspace:generate-api-key)…');
const out = execFileSync('docker', [
'exec', '-e', 'NODE_ENV=development', CONTAINER, 'sh', '-lc',
`cd /app/packages/twenty-server && yarn command:prod workspace:generate-api-key -w ${workspaceId} -n crm-new`,
], { encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 });
const m = out.match(/TOKEN:(\S+)/);
if (!m) throw new Error(`未解析到 TOKEN。输出尾部:\n${out.slice(-1200)}`);
const apiKey = m[1];
setEnv(path.join(ROOT, '.env'), { TWENTY_API_KEY: apiKey });
console.log(` API key 已写入 .env(${apiKey.slice(0, 24)}…)`);
// 4) 重建 bridge
console.log('6) 重建 bridge…');
execFileSync('docker', ['compose', 'up', '-d', '--force-recreate', 'bridge'], { cwd: ROOT, stdio: 'inherit' });
await sleep(3000);
// 5) 验证
console.log('7) 验证 API key…');
const check = await fetch(`${base}/graphql`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
body: JSON.stringify({ query: '{ companies(first: 1) { edges { node { id } } } }' }),
});
const checkJson = await check.json();
if (!check.ok || checkJson.errors) throw new Error(`API key 验证失败:${JSON.stringify(checkJson).slice(0, 400)}`);
console.log(' ✓ API key 可用(companies 查询成功)');
console.log('\n完成。TWENTY_WORKSPACE_ID / TWENTY_API_KEY 已写入 .env');
}
main().catch((err) => {
console.error('[twenty-bootstrap]', err.message);
process.exit(1);
});
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env node
'use strict';
// 给旧 bridge 的 products.kb_docs 补 Hybrid RAG 所需字段并回填全文索引。
// 幂等;不改动已有数据,只新增列/索引。
const { Client } = require('../bridge/node_modules/pg');
const PG_URL = process.env.KB_OLD_PG_URL || 'postgres://postgres:postgres@127.0.0.1:5432/products';
async function main() {
const client = new Client({ connectionString: PG_URL });
await client.connect();
await client.query('ALTER TABLE kb_docs ADD COLUMN IF NOT EXISTS parent_id INTEGER');
await client.query("ALTER TABLE kb_docs ADD COLUMN IF NOT EXISTS chunk_type TEXT DEFAULT 'parent'");
await client.query('ALTER TABLE kb_docs ADD COLUMN IF NOT EXISTS keywords_text TEXT');
await client.query('ALTER TABLE kb_docs ADD COLUMN IF NOT EXISTS text_tsv tsvector');
await client.query(`
UPDATE kb_docs
SET text_tsv = to_tsvector('english',
coalesce(question, '') || ' ' || coalesce(answer, '') || ' ' ||
coalesce(array_to_string(keywords, ' '), '') || ' ' ||
coalesce(title, '') || ' ' || coalesce(section, ''))
WHERE text_tsv IS NULL
`);
await client.query('CREATE INDEX IF NOT EXISTS kb_docs_tsv_idx ON kb_docs USING GIN (text_tsv)');
await client.query('CREATE INDEX IF NOT EXISTS kb_docs_parent_idx ON kb_docs (parent_id) WHERE parent_id IS NOT NULL');
const r = await client.query("SELECT COUNT(*)::int AS total, COUNT(*) FILTER (WHERE text_tsv IS NOT NULL)::int AS indexed FROM kb_docs");
console.log(JSON.stringify(r.rows[0]));
await client.end();
}
main().catch((err) => { console.error('[upgrade-kb-schema]', err.message); process.exit(1); });
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env node
'use strict';
/**
* 验证 RAG + 引用:创建临时 AI Assistant → 调 preview API 问一个问题 → 打印命中来源 → 删除。
* 用法:node scripts/verify-rag.js "What is the minimum order value?"
*/
const fs = require('fs');
const path = require('path');
const ROOT = path.resolve(__dirname, '..');
const question = process.argv.slice(2).join(' ') || 'What is the minimum order value?';
function loadEnv(file) {
const out = {};
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
const t = line.trim();
if (!t || t.startsWith('#')) continue;
const eq = t.indexOf('=');
if (eq < 0) continue;
let v = t.slice(eq + 1).trim();
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
out[t.slice(0, eq).trim()] = v;
}
return out;
}
function createClient(baseURL) {
const cookies = new Map();
let csrf = '';
const cookieHeader = () => [...cookies.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
function store(res) {
const list = typeof res.headers.getSetCookie === 'function' ? res.headers.getSetCookie() : [res.headers.get('set-cookie')].filter(Boolean);
for (const c of list) {
const [pair] = c.split(';');
const eq = pair.indexOf('=');
if (eq > 0) {
const k = pair.slice(0, eq).trim();
const v = pair.slice(eq + 1).trim();
cookies.set(k, v);
if (k === 'csrf_token') csrf = v;
}
}
}
async function req(method, p, body) {
const headers = { Accept: 'application/json' };
if (cookies.size) headers.Cookie = cookieHeader();
if (body !== undefined && !(body instanceof FormData)) headers['Content-Type'] = 'application/json';
if (csrf && method !== 'GET') headers['X-CSRFTOKEN'] = csrf;
const res = await fetch(baseURL + p, {
method,
headers,
body: body === undefined ? undefined : (body instanceof FormData ? body : JSON.stringify(body)),
});
store(res);
const text = await res.text();
let json = null;
try { json = text ? JSON.parse(text) : null; } catch { /* ignore */ }
if (!res.ok) throw new Error(`${method} ${p} -> HTTP ${res.status}: ${(json && json.message) || text.slice(0, 400)}`);
return json;
}
return { get: (p) => req('GET', p), post: (p, b) => req('POST', p, b), del: (p) => req('DELETE', p) };
}
async function main() {
const env = loadEnv(path.join(ROOT, '.env'));
const base = (env.LIBREDESK_BASE_URL || 'http://192.168.31.61:9000').replace(/\/+$/, '');
const c = createClient(base);
await c.post('/api/v1/auth/login', { email: env.LIBREDESK_SYSTEM_EMAIL || 'System', password: env.LIBREDESK_SYSTEM_USER_PASSWORD });
const data = {
name: `KB Verify ${Date.now()}`,
description: 'temporary assistant for RAG verification',
instructions: 'Answer from the knowledge base. If the knowledge base has no answer, say you could not find it.',
guardrails: '',
expectation: '',
tone: 'professional',
response_length: 'balanced',
max_turns: 5,
fallback_team_id: null,
citations_enabled: true,
handoff_enabled: false,
languages: [],
enabled: true,
tool_ids: [],
};
const form = new FormData();
form.append('data', JSON.stringify(data));
const created = await c.post('/api/v1/ai/assistants', form);
const assistant = created.data;
console.log(`临时 Assistant #${assistant.id} 创建成功`);
try {
const preview = await c.post(`/api/v1/ai/assistants/${assistant.id}/preview`, { message: question });
const out = preview.data || {};
console.log(`\n问题: ${question}`);
console.log(`回复:\n${out.reply}\n`);
console.log('命中来源:');
for (const s of out.sources || []) {
console.log(` - [${s.type}] #${s.id} score=${(s.score || 0).toFixed(3)} ${s.title || ''}${s.url ? ' ' + s.url : ''}`);
}
if (!(out.sources || []).length) console.log(' (无命中)');
} finally {
await c.del(`/api/v1/ai/assistants/${assistant.id}`);
console.log(`\n临时 Assistant #${assistant.id} 已删除`);
}
}
main().catch((err) => {
console.error('[verify-rag]', err.message);
process.exit(1);
});