Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plugins/catalog.json
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@
},
{
"id": "langbot-team/LeadCapture",
"version": "0.2.0",
"version": "0.3.0",
"category": "misc",
"path": "misc/LeadCapture",
"repository": "https://github.com/langbot-app/LangBot/tree/master/plugins/misc/LeadCapture"
Expand Down
10 changes: 7 additions & 3 deletions plugins/misc/LeadCapture/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Purchase Intent Lead Capture

Let your AI customer service notice customers who want to buy. After every AI reply, the plugin reads the conversation with a model you choose and against criteria you write, and decides whether the customer has **strong purchase intent**. If so, it appends a contact request to that reply, only once per customer. When the customer leaves a phone number, WeChat ID or email, the plugin sends the **contact details plus the entire conversation with that customer** to your inbox through [Resend](https://resend.com).
Let your AI customer service notice customers who want to buy. After every AI reply, the plugin reads the conversation with a model you choose and against criteria you write, **scores the customer's purchase intent 0–100** and flags **compliance risk**. At medium intent or above (default ≥ 30) it appends a contact request to that reply, only once per customer. When the customer leaves a phone number, WeChat ID or email, the plugin **extracts lead fields** (company, use case, volume, budget, …) from the whole conversation and sends them with the **contact details, score and the entire conversation** to your inbox through [Resend](https://resend.com).

Works for private chats on any platform (WeChat Official Account, WeCom customer service, Telegram, QQ, …).

Expand Down Expand Up @@ -38,9 +38,13 @@ Works for private chats on any platform (WeChat Official Account, WeCom customer
| Setting | What to enter |
| --- | --- |
| Model for judging intent | A chat model. It is called once after every AI reply, so a fast, inexpensive model is best |
| Intent criteria (prompt) | In plain language, what counts as strong purchase intent for your business, e.g. asking about price, plans, contracts, invoices, trials or go-live dates counts; asking about features or small talk does not. A general default is provided |
| Scoring criteria (prompt) | Which signals add or subtract points; you can paste a keyword/score list. The model scores the whole conversation 0–100 by meaning (including Traditional Chinese and synonyms). A general default is provided |
| High-intent score | Default 60 |
| Score to ask for contact | Default 30: medium (30–59) and high intent are asked once; set it equal to the high-intent score to ask only high intent |
| Compliance risk criteria | Matching conversations (default: fake orders, ticket scalping, cracking, scraping WeChat/banks, VPNs, …) are flagged: **no contact request and no sales email**, listed separately on the page for review |
| Lead fields to extract | Filled from the whole conversation once contact details arrive (default: company, industry, use case, target sites, monthly volume, budget, go-live date, contact name, title, work email, WeChat/phone, current vendor); shown at the top of the email and on the page |
| Messages used for judging | How many recent messages to read, default 20, max 100 |
| Contact request text | Fixed text appended to the AI reply when strong intent is detected |
| Contact request text | Fixed text appended to the AI reply once the ask score is reached |
| Thank-you text | Appended to the next AI reply after contact details arrive; leave empty to disable |
| Resend API key | The `re_…` key from step 1 |
| Sender address | An address on your verified domain, e.g. `leads@example.com` or `Leads <leads@example.com>` |
Expand Down
4 changes: 4 additions & 0 deletions plugins/misc/LeadCapture/components/pages/console/console.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ def summarize(row: dict) -> dict:
"last_judged_at": last_judge.get("at"),
"last_strong": last_judge.get("strong"),
"last_reason": last_judge.get("reason", ""),
"score": row.get("score", last_judge.get("score")),
"level": row.get("level", last_judge.get("level", "")),
"fields": row.get("fields") or {},
"reason": row.get("reason", ""),
"contact": row.get("contact", ""),
"email_status": row.get("email_status", ""),
Expand All @@ -43,6 +46,7 @@ async def handle_api(self, request: PageRequest) -> PageResponse:
totals = {
"sessions": len(rows),
"asked": sum(1 for row in rows if row["status"] == "asked"),
"risk": sum(1 for row in rows if row["status"] == "risk"),
"captured": sum(1 for row in rows if row["status"] == "captured"),
"emailed": sum(1 for row in rows if row["email_status"] == "sent"),
"judgments": sum(row["judgments"] for row in rows),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,5 +37,16 @@
"timeline": "Timeline (newest first)",
"noEvents": "No events recorded.",
"recentConversation": "Recent conversation (UTC)",
"conversationUnavailable": "Conversation unavailable"
"conversationUnavailable": "Conversation unavailable",
"riskCount": "Compliance risk",
"filterRisk": "Compliance risk",
"colScore": "Score",
"statusRisk": "Compliance risk",
"levelHigh": "High",
"levelMedium": "Medium",
"levelLow": "Low",
"eventRisk": "Flagged for compliance review; no contact request",
"eventFields": "Lead fields extracted",
"leadFields": "Lead fields",
"notMentioned": "Not mentioned"
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,5 +37,16 @@
"timeline": "判断时间线(最新在上)",
"noEvents": "暂无记录。",
"recentConversation": "最近对话(UTC)",
"conversationUnavailable": "对话记录不可用"
"conversationUnavailable": "对话记录不可用",
"riskCount": "合规风险",
"filterRisk": "合规风险",
"colScore": "意向评分",
"statusRisk": "合规风险",
"levelHigh": "高意向",
"levelMedium": "中意向",
"levelLow": "低意向",
"eventRisk": "标记为合规风险:不请求联系方式、不发销售邮件",
"eventFields": "已提取线索字段",
"leadFields": "线索信息",
"notMentioned": "未提及"
}
46 changes: 40 additions & 6 deletions plugins/misc/LeadCapture/components/pages/console/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,15 @@
.b-idle { background: rgba(107,114,128,.15); }
.b-asked { background: rgba(245,158,11,.18); color: #b45309; }
.b-captured { background: rgba(16,185,129,.18); color: #047857; }
.b-risk { background: rgba(239,68,68,.15); color: #b91c1c; }
.b-high { background: rgba(239,68,68,.12); color: #b91c1c; }
.b-medium { background: rgba(245,158,11,.18); color: #b45309; }
.b-low { background: rgba(107,114,128,.15); }
.score { font-weight: 600; font-size: 15px; }
.signals { margin-top: 4px; }
.signals span { display: inline-block; margin: 2px 4px 0 0; padding: 0 6px; border-radius: 6px; font-size: 12px; border: 1px solid var(--langbot-border, #e5e7eb); }
.fields { width: 100%; font-size: 13px; }
.fields td { padding: 4px 6px; border-bottom: 1px solid var(--langbot-border, #e5e7eb); }
.b-strong { background: rgba(245,158,11,.18); color: #b45309; }
.b-weak { background: rgba(107,114,128,.15); }
.b-error { background: rgba(239,68,68,.15); color: #b91c1c; }
Expand Down Expand Up @@ -79,13 +88,15 @@ <h1 data-i18n="title">Lead judgments</h1>
<div class="card"><div class="muted" data-i18n="askedCount">Asked for contact</div><div class="num" id="t-asked">-</div></div>
<div class="card"><div class="muted" data-i18n="capturedCount">Contact captured</div><div class="num" id="t-captured">-</div></div>
<div class="card"><div class="muted" data-i18n="emailedCount">Emailed</div><div class="num" id="t-emailed">-</div></div>
<div class="card"><div class="muted" data-i18n="riskCount">Compliance risk</div><div class="num" id="t-risk">-</div></div>
</div>

<div class="toolbar">
<button class="tab active" data-filter="all" data-i18n="filterAll">All</button>
<button class="tab" data-filter="captured" data-i18n="filterCaptured">Captured</button>
<button class="tab" data-filter="asked" data-i18n="filterAsked">Asked, waiting</button>
<button class="tab" data-filter="idle" data-i18n="filterIdle">Not yet</button>
<button class="tab" data-filter="risk" data-i18n="filterRisk">Compliance risk</button>
<button class="tab" data-filter="errors" data-i18n="filterErrors">With errors</button>
<input type="search" id="search" data-i18n-placeholder="searchPlaceholder" placeholder="Search user ID or contact">
<button class="refresh" id="refresh" data-i18n="refresh">Refresh</button>
Expand All @@ -98,6 +109,7 @@ <h1 data-i18n="title">Lead judgments</h1>
<tr>
<th data-i18n="colUser">User</th>
<th data-i18n="colStatus">Status</th>
<th data-i18n="colScore">Score</th>
<th data-i18n="colLast">Latest judgment</th>
<th data-i18n="colCount">Judged (strong)</th>
<th data-i18n="colContact">Contact / email</th>
Expand Down Expand Up @@ -128,14 +140,22 @@ <h1 data-i18n="title">Lead judgments</h1>
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
}
function statusBadge(status) {
var label = { idle: t('statusIdle', 'Not yet'), asked: t('statusAsked', 'Asked'), captured: t('statusCaptured', 'Captured') }[status] || status;
var label = { idle: t('statusIdle', 'Not yet'), asked: t('statusAsked', 'Asked'), captured: t('statusCaptured', 'Captured'), risk: t('statusRisk', 'Compliance risk') }[status] || status;
return '<span class="badge b-' + esc(status) + '">' + esc(label) + '</span>';
}
function judgeBadge(strong) {
if (strong === true) return '<span class="badge b-strong">' + esc(t('strong', 'Strong intent')) + '</span>';
if (strong === false) return '<span class="badge b-weak">' + esc(t('weak', 'Not strong')) + '</span>';
return '<span class="muted">-</span>';
}
function scoreCell(score, level) {
if (score === null || score === undefined) return '<span class="muted">-</span>';
var label = { high: t('levelHigh', 'High'), medium: t('levelMedium', 'Medium'), low: t('levelLow', 'Low') }[level] || '';
return '<span class="score">' + esc(score) + '</span> ' + (label ? '<span class="badge b-' + esc(level) + '">' + esc(label) + '</span>' : '');
}
function signalsHtml(signals) {
return (signals && signals.length) ? '<div class="signals">' + signals.map(function (x) { return '<span>' + esc(x) + '</span>'; }).join('') + '</div>' : '';
}
function emailBadge(status) {
if (!status) return '';
if (status === 'sent') return '<span class="badge b-ok">' + esc(t('emailSent', 'Email sent')) + '</span>';
Expand All @@ -146,7 +166,7 @@ <h1 data-i18n="title">Lead judgments</h1>
var q = state.query.toLowerCase();
return state.sessions.filter(function (s) {
if (state.filter === 'errors' && !s.errors && !(s.email_status && s.email_status !== 'sent')) return false;
if (['idle', 'asked', 'captured'].indexOf(state.filter) >= 0 && s.status !== state.filter) return false;
if (['idle', 'asked', 'captured', 'risk'].indexOf(state.filter) >= 0 && s.status !== state.filter) return false;
if (q && (s.user + ' ' + (s.contact || '')).toLowerCase().indexOf(q) < 0) return false;
return true;
});
Expand All @@ -159,6 +179,7 @@ <h1 data-i18n="title">Lead judgments</h1>
return '<tr data-key="' + esc(s.key) + '"' + (s.key === state.selected ? ' class="selected"' : '') + '>' +
'<td class="mono">' + esc(s.user) + '</td>' +
'<td>' + statusBadge(s.status) + '</td>' +
'<td>' + scoreCell(s.score, s.level) + '</td>' +
'<td>' + judgeBadge(s.last_strong) + '<div class="muted">' + esc(s.last_reason || s.reason || '') + '</div></td>' +
'<td>' + s.judgments + ' (' + s.strong_judgments + ')' + (s.errors ? ' <span class="badge b-error">' + s.errors + ' ' + esc(t('errorsShort', 'errors')) + '</span>' : '') + '</td>' +
'<td>' + esc(s.contact || '') + '<div>' + emailBadge(s.email_status) + '</div></td>' +
Expand All @@ -171,16 +192,18 @@ <h1 data-i18n="title">Lead judgments</h1>
}

function renderTotals(totals) {
['sessions', 'judgments', 'asked', 'captured', 'emailed'].forEach(function (k) {
document.getElementById('t-' + k).textContent = totals[k];
['sessions', 'judgments', 'asked', 'captured', 'emailed', 'risk'].forEach(function (k) {
document.getElementById('t-' + k).textContent = totals[k] == null ? 0 : totals[k];
});
}

function renderEvent(e) {
var cls = 'event', body = '';
if (e.kind === 'judge') {
if (e.strong) cls += ' strong';
body = judgeBadge(e.strong) + ' ' + esc(e.reason || '') +
if (e.risk) cls += ' error'; else if (e.strong) cls += ' strong';
body = (e.score !== undefined ? scoreCell(e.score, e.level) + ' ' : judgeBadge(e.strong) + ' ') + esc(e.reason || '') +
(e.risk ? '<div><span class="badge b-risk">' + esc(t('statusRisk', 'Compliance risk')) + '</span> ' + esc(e.risk_reason || '') + '</div>' : '') +
signalsHtml(e.signals) +
(e.message ? '<div class="msg user">' + esc(t('userSaid', 'User')) + ': ' + esc(e.message) + '</div>' : '') +
(e.reply ? '<div class="msg">AI: ' + esc(e.reply) + '</div>' : '');
} else if (e.kind === 'asked') {
Expand All @@ -190,6 +213,12 @@ <h1 data-i18n="title">Lead judgments</h1>
cls += ' captured';
body = '<b>' + esc(t('eventContact', 'Contact captured')) + ':</b> ' + esc(e.contact) +
(e.message ? '<div class="msg user">' + esc(e.message) + '</div>' : '');
} else if (e.kind === 'risk') {
cls += ' error';
body = '<span class="badge b-risk">' + esc(t('eventRisk', 'Flagged for compliance review; no contact request')) + '</span> ' + esc(e.reason || '');
} else if (e.kind === 'fields') {
cls += ' captured';
body = '<b>' + esc(t('eventFields', 'Lead fields extracted')) + '</b>';
} else if (e.kind === 'email') {
cls += e.status === 'sent' ? ' captured' : ' error';
body = emailBadge(e.status) + ' <span class="muted">#' + esc(e.attempt) + '</span>';
Expand All @@ -210,9 +239,14 @@ <h1 data-i18n="title">Lead judgments</h1>
'<div class="kv">' +
'<span class="muted">' + esc(t('colUser', 'User')) + '</span><span class="mono">' + esc(s.user) + '</span>' +
'<span class="muted">' + esc(t('colStatus', 'Status')) + '</span><span>' + statusBadge(s.status) + '</span>' +
'<span class="muted">' + esc(t('colScore', 'Score')) + '</span><span>' + scoreCell(s.score, s.level) + '</span>' +
(s.reason ? '<span class="muted">' + esc(t('reason', 'Reason')) + '</span><span>' + esc(s.reason) + '</span>' : '') +
(s.contact ? '<span class="muted">' + esc(t('colContact', 'Contact')) + '</span><span>' + esc(s.contact) + ' ' + emailBadge(s.email_status) + '</span>' : '') +
'</div>' +
(Object.keys(s.fields || {}).length ? '<h3>' + esc(t('leadFields', 'Lead fields')) + '</h3><table class="fields">' +
Object.keys(s.fields).map(function (k) {
return '<tr><td class="muted">' + esc(k) + '</td><td>' + (s.fields[k] ? esc(s.fields[k]) : '<span class="muted">' + esc(t('notMentioned', 'Not mentioned')) + '</span>') + '</td></tr>';
}).join('') + '</table>' : '') +
'<h3>' + esc(t('timeline', 'Timeline (newest first)')) + '</h3>' +
(events.length ? events.map(renderEvent).join('') : '<div class="muted">' + esc(t('noEvents', 'No events recorded.')) + '</div>') +
'<h3>' + esc(t('recentConversation', 'Recent conversation (UTC)')) + '</h3>';
Expand Down
Loading
Loading