first commit

This commit is contained in:
Ravi Gulhane 2026-08-31 16:28:31 +05:30
commit fb78920fe9
152 changed files with 21367 additions and 0 deletions

55
README.md Normal file
View File

@ -0,0 +1,55 @@
# EventBuzz Concierge
Independent Node.js service for the EventBuzz Virtual Concierge chatbot. Uses Amazon Bedrock for Call 1 (intent classification). Connects to the same MongoDB as the main EventBuzz backend.
## Setup
```bash
cp .env.example .env
# Set MONGODB_URI and AWS / Bedrock credentials
npm install
npm run dev
```
Server defaults to `http://localhost:3010`.
## Production deploy (same server as backend)
From the repo root (`EventBuzz_Dashboard_V4/`):
```bash
./deploy.sh concierge
# or full stack:
./deploy.sh all
```
Deploys to `/var/www/concierge` on the backend host, reloads PM2 app `concierge-api`, and syncs `public/embed` to the backend so dashboard embed snippets stay current.
First time on the server: create `/var/www/concierge/.env` (copy from `.env.example`), set `MONGODB_URI`, AWS/Bedrock, and mail vars, then run deploy. If `package.json` / lockfile changed, run `npm ci --omit=dev` on the server and deploy again.
## Embed demo
1. Start the server.
2. Open [http://localhost:3010/embed/demo](http://localhost:3010/embed/demo).
3. Set `botId` in the page script to a real `ConciergeBot` document `_id` from MongoDB.
## API
| Method | Path | Purpose |
|--------|------|---------|
| GET | `/api/public/concierge/{botId}/config` | Widget greeting / config |
| POST | `/api/public/concierge/{botId}/chat` | Send message (Call 1 classification) |
| GET | `/api/public/concierge/{botId}/session/{sessionRef}` | Resume session |
| GET | `/webhook/whatsapp` | Meta webhook verification |
| POST | `/webhook/whatsapp` | Inbound WhatsApp (reception + follow-up) |
## Pipeline
1. **Call 1** (Nova Micro) — intent + entities
2. **Call 2** (Claude 3 Haiku) — agenda replies with Sessions DB context
3. **Bookmark mutations** — `agenda_bookmark_add` / `agenda_bookmark_remove` write `SessionBookmark` records (no Call 2)
4. **Email OTP** — required for personal schedule, conflict check, and bookmark mutations; auth is stored on the web chat session
Set `BEDROCK_CALL2_MODEL_ID`, `OTP_PEPPER`, and SMTP/`MAIL_*` vars in `.env` (see `.env.example`). Without SMTP, OTP codes are logged to the server console.
Reception WhatsApp: point the Meta webhook at `GET/POST /webhook/whatsapp` on this service (verify token `META_WHATSAPP_WEBHOOK_VERIFY_TOKEN`). Configure copy and drips in the dashboard **WhatsApp Chatbots → Reception** tab. WABA credentials come from Organisation WhatsApp settings.

25
ecosystem.config.cjs Normal file
View File

@ -0,0 +1,25 @@
/**
* PM2 — EventBuzz Virtual Concierge API (same server as backend).
*
* cd /var/www/concierge && pm2 start ecosystem.config.cjs
* pm2 save
*
* Requires concierge/.env on the server (MONGODB_URI, AWS/Bedrock, MAIL_*).
* Prefer HOST=0.0.0.0 (or nginx → 127.0.0.1:3010 with HOST=localhost).
*/
module.exports = {
apps: [
{
name: 'concierge-api',
script: 'server.js',
instances: 1,
exec_mode: 'fork',
autorestart: true,
max_restarts: 20,
restart_delay: 5000,
env: {
NODE_ENV: 'production',
},
},
],
};

5342
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

31
package.json Normal file
View File

@ -0,0 +1,31 @@
{
"name": "eventbuzz-concierge",
"version": "1.0.0",
"description": "EventBuzz Virtual Concierge — independent Bedrock two-call chatbot service",
"main": "server.js",
"engines": {
"node": ">=20"
},
"scripts": {
"start": "node server.js",
"free-port": "node scripts/free-port.js",
"dev": "node scripts/free-port.js && node --watch server.js",
"livekit-voice-worker": "node src/workers/livekitVoiceAgent.js start"
},
"dependencies": {
"@aws-sdk/client-bedrock-runtime": "^3.1030.0",
"@aws-sdk/client-rekognition": "^3.1030.0",
"@aws-sdk/client-s3": "^3.1101.0",
"@hapi/hapi": "^21.3.0",
"@livekit/agents": "^1.7.1",
"@livekit/rtc-node": "^0.13.34",
"dotenv": "^16.6.1",
"jimp": "0.22.12",
"joi": "^17.13.0",
"jsqr": "^1.4.0",
"livekit-server-sdk": "^2.18.0",
"mongoose": "^8.0.0",
"nodemailer": "^6.9.7",
"qrcode": "^1.5.4"
}
}

60
public/embed-demo.html Normal file
View File

@ -0,0 +1,60 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>EventBuzz Concierge Demo (Step 1)</title>
<style>
body {
margin: 0;
min-height: 100vh;
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
color: #0f172a;
background:
radial-gradient(1200px 600px at 10% -10%, #ccfbf1 0%, transparent 55%),
radial-gradient(900px 500px at 100% 0%, #e0e7ff 0%, transparent 50%),
#f8fafc;
padding: 40px 24px;
}
main {
max-width: 40rem;
}
h1 {
margin: 0 0 8px;
font-size: 1.75rem;
}
p {
color: #475569;
line-height: 1.5;
}
code {
background: #e2e8f0;
padding: 2px 6px;
border-radius: 4px;
font-size: 0.9em;
}
</style>
</head>
<body>
<main>
<h1>Concierge widget demo</h1>
<p>
Step 1 only: messages are classified via Bedrock. The chat bubble shows a temporary intent summary;
the full JSON is in the <code>/chat</code> API response as <code>classification</code>.
</p>
<p>
Set a real ConciergeBot <code>_id</code> in <code>window.EB_CONCIERGE.botId</code> below.
Optional resume: <code>?session=&lt;sessionId or mongo id&gt;</code>
</p>
</main>
<script>
window.EB_CONCIERGE = {
botId: "6a61ddf778df0ea305244efc",
apiBase: location.origin.replace(/\/$/, "") + "/api",
// Optional: accentColor: "rgb(13, 148, 136)" or "#0d9488"
};
</script>
<script async src="/embed/concierge-widget.js?v=26"></script>
</body>
</html>

View File

@ -0,0 +1,854 @@
#eventbuzz-concierge-root {
--ebc-panel: #ffffff;
--ebc-text: #0f172a;
--ebc-muted: #64748b;
--ebc-accent: rgb(13, 148, 136);
--ebc-accent-rgb: 13, 148, 136;
--ebc-accent-text: #ffffff;
--ebc-border: #e2e8f0;
--ebc-user: rgb(204, 251, 241);
--ebc-bot: #f1f5f9;
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
z-index: 2147483000;
position: fixed;
right: 20px;
bottom: 20px;
}
#eventbuzz-concierge-root .ebc-launcher {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
border: none;
border-radius: 999px;
min-width: 72px;
min-height: 72px;
padding: 10px 12px 8px;
background: var(--ebc-accent);
color: var(--ebc-accent-text);
cursor: pointer;
box-shadow: 0 8px 24px rgba(var(--ebc-accent-rgb), 0.35);
}
#eventbuzz-concierge-root .ebc-launcher-logo {
display: block;
width: 36px;
height: 36px;
object-fit: contain;
pointer-events: none;
}
#eventbuzz-concierge-root .ebc-launcher-label {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.02em;
line-height: 1.1;
text-transform: lowercase;
}
#eventbuzz-concierge-root .ebc-panel {
display: none;
flex-direction: column;
width: min(380px, calc(100vw - 32px));
height: min(560px, calc(100vh - 100px));
background: var(--ebc-panel);
color: var(--ebc-text);
border: 1px solid var(--ebc-border);
border-radius: 16px;
overflow: hidden;
box-shadow: 0 16px 48px rgba(15, 23, 42, 0.22);
margin-bottom: 12px;
}
#eventbuzz-concierge-root.ebc-open .ebc-panel {
display: flex;
}
#eventbuzz-concierge-root.ebc-open .ebc-launcher {
display: none;
}
#eventbuzz-concierge-root .ebc-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 12px 14px;
background: var(--ebc-accent);
color: var(--ebc-accent-text);
}
#eventbuzz-concierge-root .ebc-header-brand {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
#eventbuzz-concierge-root .ebc-header-logo {
display: block;
width: 32px;
height: 32px;
object-fit: contain;
flex-shrink: 0;
border-radius: 8px;
background: rgba(255, 255, 255, 0.08);
}
#eventbuzz-concierge-root .ebc-title {
font-weight: 650;
font-size: 14px;
line-height: 1.25;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#eventbuzz-concierge-root .ebc-header-actions {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
#eventbuzz-concierge-root .ebc-close {
border: none;
background: transparent;
color: #cbd5e1;
cursor: pointer;
font-size: 22px;
line-height: 1;
padding: 4px 6px;
border-radius: 6px;
}
#eventbuzz-concierge-root .ebc-close:hover {
color: #fff;
background: rgba(255, 255, 255, 0.08);
}
#eventbuzz-concierge-root .ebc-end-voice {
border: 1px solid rgba(255, 255, 255, 0.22);
background: rgba(255, 255, 255, 0.08);
color: #e2e8f0;
cursor: pointer;
font-size: 11px;
line-height: 1;
padding: 6px 8px;
border-radius: 8px;
white-space: nowrap;
}
#eventbuzz-concierge-root .ebc-end-voice:hover {
color: #fff;
background: rgba(255, 255, 255, 0.16);
}
#eventbuzz-concierge-root .ebc-messages {
flex: 1;
overflow-y: auto;
padding: 14px;
display: flex;
flex-direction: column;
gap: 10px;
background: #f8fafc;
}
#eventbuzz-concierge-root .ebc-msg {
max-width: 88%;
padding: 10px 12px;
border-radius: 12px;
font-size: 14px;
line-height: 1.4;
white-space: pre-wrap;
word-break: break-word;
}
#eventbuzz-concierge-root .ebc-msg.user {
align-self: flex-end;
background: var(--ebc-user);
}
#eventbuzz-concierge-root .ebc-msg.bot {
align-self: flex-start;
background: var(--ebc-bot);
}
#eventbuzz-concierge-root .ebc-msg.ebc-has-cards {
max-width: 96%;
background: #fff;
border: 1px solid var(--ebc-border);
white-space: normal;
}
#eventbuzz-concierge-root .ebc-intro {
margin: 0;
white-space: pre-wrap;
}
#eventbuzz-concierge-root .ebc-intro + .ebc-cards-list,
#eventbuzz-concierge-root .ebc-intro + .ebc-feedback,
#eventbuzz-concierge-root .ebc-cards-list + .ebc-feedback {
margin-top: 10px;
}
#eventbuzz-concierge-root .ebc-cards-list {
display: flex;
flex-direction: column;
gap: 8px;
}
#eventbuzz-concierge-root .ebc-session-card {
border: 1px solid var(--ebc-border);
border-radius: 10px;
padding: 10px 12px;
background: #f8fafc;
}
#eventbuzz-concierge-root .ebc-session-title {
font-weight: 650;
font-size: 14px;
color: var(--ebc-text);
margin-bottom: 4px;
}
#eventbuzz-concierge-root .ebc-session-meta,
#eventbuzz-concierge-root .ebc-session-speakers {
font-size: 12px;
color: var(--ebc-muted);
margin-top: 2px;
}
#eventbuzz-concierge-root .ebc-session-time {
font-weight: 600;
color: #334155;
}
#eventbuzz-concierge-root .ebc-session-track {
color: var(--ebc-accent);
font-weight: 600;
}
#eventbuzz-concierge-root .ebc-session-topics {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--ebc-border);
}
#eventbuzz-concierge-root .ebc-session-topics-label {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.03em;
text-transform: uppercase;
color: #64748b;
margin-bottom: 4px;
}
#eventbuzz-concierge-root .ebc-session-topics-list {
margin: 0;
padding-left: 1.1rem;
font-size: 12px;
color: #475569;
}
#eventbuzz-concierge-root .ebc-session-topics-list li {
margin: 2px 0;
}
#eventbuzz-concierge-root .ebc-session-resources {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
#eventbuzz-concierge-root .ebc-session-resource {
font-size: 12px;
font-weight: 600;
color: var(--ebc-accent);
text-decoration: none;
}
#eventbuzz-concierge-root .ebc-session-resource:hover {
text-decoration: underline;
}
#eventbuzz-concierge-root .ebc-file-card {
border: 1px solid var(--ebc-border);
border-radius: 10px;
padding: 10px 12px;
background: #f8fafc;
}
#eventbuzz-concierge-root .ebc-file-title {
font-weight: 650;
font-size: 14px;
color: var(--ebc-text);
margin-bottom: 8px;
}
#eventbuzz-concierge-root .ebc-file-meta {
font-size: 12px;
color: var(--ebc-muted);
margin-bottom: 8px;
}
#eventbuzz-concierge-root .ebc-file-preview {
display: block;
width: 100%;
max-height: 220px;
object-fit: contain;
border-radius: 8px;
background: #fff;
border: 1px solid var(--ebc-border);
margin-bottom: 8px;
}
#eventbuzz-concierge-root .ebc-file-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
#eventbuzz-concierge-root .ebc-file-btn {
display: inline-block;
padding: 8px 14px;
border-radius: 8px;
border: 1px solid var(--ebc-accent);
font-size: 12px;
font-weight: 650;
text-decoration: none;
cursor: pointer;
}
#eventbuzz-concierge-root .ebc-file-download {
background: var(--ebc-accent);
color: var(--ebc-accent-text);
}
#eventbuzz-concierge-root .ebc-file-view {
background: #fff;
color: var(--ebc-accent);
}
#eventbuzz-concierge-root .ebc-file-btn:hover {
filter: brightness(0.97);
}
#eventbuzz-concierge-root .ebc-cta-card {
margin: 0;
}
#eventbuzz-concierge-root .ebc-cta-btn {
display: block;
width: 100%;
box-sizing: border-box;
padding: 10px 14px;
border-radius: 10px;
border: 1px solid var(--ebc-accent);
background: var(--ebc-accent);
color: var(--ebc-accent-text);
font-size: 13px;
font-weight: 650;
text-align: center;
text-decoration: none;
cursor: pointer;
}
#eventbuzz-concierge-root .ebc-cta-btn:hover {
filter: brightness(0.97);
}
#eventbuzz-concierge-root .ebc-place-card {
border: 1px solid var(--ebc-border);
border-radius: 10px;
padding: 10px 12px;
background: #f8fafc;
}
#eventbuzz-concierge-root .ebc-place-badge {
display: inline-block;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--ebc-muted);
margin-bottom: 4px;
}
#eventbuzz-concierge-root .ebc-place-title {
font-weight: 650;
font-size: 14px;
color: var(--ebc-text);
margin-bottom: 4px;
}
#eventbuzz-concierge-root .ebc-place-meta {
font-size: 12px;
color: var(--ebc-muted);
margin-top: 2px;
}
#eventbuzz-concierge-root .ebc-place-link {
display: inline-block;
margin-top: 8px;
font-size: 12px;
font-weight: 600;
color: var(--ebc-accent);
text-decoration: none;
}
#eventbuzz-concierge-root .ebc-place-link:hover {
text-decoration: underline;
}
#eventbuzz-concierge-root .ebc-speaker-card {
border: 1px solid var(--ebc-border);
border-radius: 10px;
padding: 10px 12px;
background: #f8fafc;
}
#eventbuzz-concierge-root .ebc-speaker-head {
display: flex;
gap: 10px;
align-items: flex-start;
}
#eventbuzz-concierge-root .ebc-speaker-photo {
width: 48px;
height: 48px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
background: #e2e8f0;
}
#eventbuzz-concierge-root .ebc-speaker-info {
min-width: 0;
flex: 1;
}
#eventbuzz-concierge-root .ebc-speaker-name {
font-weight: 650;
font-size: 14px;
color: var(--ebc-text);
}
#eventbuzz-concierge-root .ebc-speaker-meta {
font-size: 12px;
color: var(--ebc-muted);
margin-top: 2px;
}
#eventbuzz-concierge-root .ebc-speaker-bio {
margin-top: 8px;
font-size: 12px;
line-height: 1.45;
color: var(--ebc-text);
white-space: pre-wrap;
}
#eventbuzz-concierge-root .ebc-speaker-links {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
margin-top: 8px;
}
#eventbuzz-concierge-root .ebc-speaker-link {
font-size: 12px;
font-weight: 600;
color: var(--ebc-accent);
text-decoration: none;
}
#eventbuzz-concierge-root .ebc-speaker-link:hover {
text-decoration: underline;
}
#eventbuzz-concierge-root .ebc-exhibitor-card {
border: 1px solid var(--ebc-border);
border-radius: 10px;
padding: 10px 12px;
background: #f8fafc;
}
#eventbuzz-concierge-root .ebc-exhibitor-head {
display: flex;
gap: 10px;
align-items: flex-start;
}
#eventbuzz-concierge-root .ebc-exhibitor-logo {
width: 48px;
height: 48px;
border-radius: 8px;
object-fit: contain;
flex-shrink: 0;
background: #fff;
border: 1px solid var(--ebc-border);
}
#eventbuzz-concierge-root .ebc-exhibitor-info {
min-width: 0;
flex: 1;
}
#eventbuzz-concierge-root .ebc-exhibitor-name {
font-weight: 650;
font-size: 14px;
color: var(--ebc-text);
}
#eventbuzz-concierge-root .ebc-exhibitor-meta {
font-size: 12px;
color: var(--ebc-muted);
margin-top: 2px;
}
#eventbuzz-concierge-root .ebc-exhibitor-desc {
margin-top: 8px;
font-size: 12px;
line-height: 1.45;
color: var(--ebc-text);
white-space: pre-wrap;
}
#eventbuzz-concierge-root .ebc-exhibitor-links {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
margin-top: 8px;
}
#eventbuzz-concierge-root .ebc-exhibitor-link {
font-size: 12px;
font-weight: 600;
color: var(--ebc-accent);
text-decoration: none;
}
#eventbuzz-concierge-root .ebc-exhibitor-link:hover {
text-decoration: underline;
}
#eventbuzz-concierge-root .ebc-ticket-qr-card {
border: 1px solid var(--ebc-border);
border-radius: 10px;
padding: 12px;
background: #f8fafc;
text-align: center;
}
#eventbuzz-concierge-root .ebc-ticket-qr-title {
font-weight: 650;
font-size: 14px;
color: var(--ebc-text);
margin-bottom: 4px;
}
#eventbuzz-concierge-root .ebc-ticket-qr-meta {
font-size: 12px;
color: var(--ebc-muted);
margin-bottom: 10px;
}
#eventbuzz-concierge-root .ebc-ticket-qr-img {
display: block;
width: 200px;
height: 200px;
max-width: 100%;
margin: 0 auto;
border-radius: 8px;
background: #fff;
}
#eventbuzz-concierge-root .ebc-ticket-qr-hint {
margin-top: 8px;
font-size: 11px;
color: var(--ebc-muted);
}
#eventbuzz-concierge-root .ebc-ticket-qr-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
margin-top: 10px;
}
#eventbuzz-concierge-root .ebc-ticket-qr-download,
#eventbuzz-concierge-root .ebc-ticket-qr-email {
display: inline-block;
padding: 8px 14px;
border-radius: 8px;
border: 1px solid var(--ebc-accent);
font-size: 12px;
font-weight: 650;
text-decoration: none;
cursor: pointer;
font-family: inherit;
line-height: 1.2;
}
#eventbuzz-concierge-root .ebc-ticket-qr-download {
background: var(--ebc-accent);
color: var(--ebc-accent-text);
}
#eventbuzz-concierge-root .ebc-ticket-qr-email {
background: #fff;
color: var(--ebc-accent);
}
#eventbuzz-concierge-root .ebc-ticket-qr-download:hover,
#eventbuzz-concierge-root .ebc-ticket-qr-email:hover {
filter: brightness(0.97);
}
#eventbuzz-concierge-root .ebc-ticket-qr-email:disabled {
opacity: 0.55;
cursor: not-allowed;
}
#eventbuzz-concierge-root .ebc-quick-replies {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-self: flex-start;
max-width: 100%;
padding: 2px 0 4px;
}
#eventbuzz-concierge-root .ebc-chip {
border: 1px solid color-mix(in srgb, var(--ebc-accent) 45%, var(--ebc-border));
background: #fff;
color: var(--ebc-accent);
border-radius: 999px;
padding: 7px 12px;
font-size: 13px;
font-weight: 600;
line-height: 1.2;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
#eventbuzz-concierge-root .ebc-chip:hover:not(:disabled) {
background: var(--ebc-accent);
color: var(--ebc-accent-text);
border-color: var(--ebc-accent);
}
#eventbuzz-concierge-root .ebc-chip:disabled {
opacity: 0.55;
cursor: not-allowed;
}
#eventbuzz-concierge-root .ebc-error {
color: #b91c1c;
font-size: 12px;
min-height: 16px;
padding: 0 14px 6px;
}
#eventbuzz-concierge-root .ebc-inputbar {
display: flex;
gap: 8px;
padding: 12px;
border-top: 1px solid var(--ebc-border);
background: #fff;
}
#eventbuzz-concierge-root .ebc-input {
flex: 1;
border: 1px solid var(--ebc-border);
border-radius: 10px;
padding: 10px 12px;
font-size: 14px;
outline: none;
}
#eventbuzz-concierge-root .ebc-input:focus {
border-color: var(--ebc-accent);
}
#eventbuzz-concierge-root .ebc-send {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 42px;
height: 42px;
padding: 0;
border: none;
border-radius: 50%;
background: var(--ebc-accent);
color: var(--ebc-accent-text);
cursor: pointer;
}
#eventbuzz-concierge-root .ebc-send-icon {
display: block;
margin-left: 2px; /* optical center for paper-plane */
}
#eventbuzz-concierge-root .ebc-send:hover:not(:disabled) {
filter: brightness(1.06);
}
#eventbuzz-concierge-root .ebc-send:disabled,
#eventbuzz-concierge-root .ebc-input:disabled,
#eventbuzz-concierge-root .ebc-mic:disabled {
opacity: 0.6;
cursor: not-allowed;
}
#eventbuzz-concierge-root .ebc-hidden {
display: none !important;
}
#eventbuzz-concierge-root .ebc-mic {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 42px;
height: 42px;
padding: 0;
border: 1px solid var(--ebc-border);
border-radius: 50%;
background: #fff;
color: var(--ebc-accent);
cursor: pointer;
}
#eventbuzz-concierge-root .ebc-mic:hover:not(:disabled) {
border-color: var(--ebc-accent);
background: rgba(var(--ebc-accent-rgb), 0.08);
}
#eventbuzz-concierge-root .ebc-mic.is-active {
background: var(--ebc-accent);
border-color: var(--ebc-accent);
color: var(--ebc-accent-text);
}
#eventbuzz-concierge-root .ebc-mic.is-listening {
box-shadow: 0 0 0 4px rgba(var(--ebc-accent-rgb), 0.22);
}
#eventbuzz-concierge-root .ebc-mic.is-speaking,
#eventbuzz-concierge-root .ebc-mic.is-connecting {
animation: ebc-mic-pulse 1.2s ease-in-out infinite;
}
@keyframes ebc-mic-pulse {
0%,
100% {
box-shadow: 0 0 0 0 rgba(var(--ebc-accent-rgb), 0.35);
}
50% {
box-shadow: 0 0 0 6px rgba(var(--ebc-accent-rgb), 0.08);
}
}
/* Typing indicator */
#eventbuzz-concierge-root .ebc-msg.ebc-typing {
padding: 12px 16px;
min-width: 56px;
}
#eventbuzz-concierge-root .ebc-typing-dots {
display: inline-flex;
align-items: center;
gap: 5px;
height: 14px;
}
#eventbuzz-concierge-root .ebc-typing-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--ebc-muted);
opacity: 0.35;
animation: ebc-typing-bounce 1.2s ease-in-out infinite;
}
#eventbuzz-concierge-root .ebc-typing-dot:nth-child(2) {
animation-delay: 0.15s;
}
#eventbuzz-concierge-root .ebc-typing-dot:nth-child(3) {
animation-delay: 0.3s;
}
@keyframes ebc-typing-bounce {
0%,
60%,
100% {
transform: translateY(0);
opacity: 0.35;
}
30% {
transform: translateY(-5px);
opacity: 1;
}
}
/* Reply feedback footer */
#eventbuzz-concierge-root .ebc-feedback {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: 10px;
padding-top: 8px;
border-top: 1px solid var(--ebc-border);
}
#eventbuzz-concierge-root .ebc-feedback-label {
font-size: 11px;
line-height: 1.3;
color: #94a3b8;
font-weight: 400;
}
#eventbuzz-concierge-root .ebc-feedback-actions {
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
}
#eventbuzz-concierge-root .ebc-feedback-btn {
border: none;
background: transparent;
cursor: pointer;
font-size: 13px;
line-height: 1;
padding: 4px 6px;
border-radius: 6px;
opacity: 0.7;
}
#eventbuzz-concierge-root .ebc-feedback-btn:hover:not(:disabled) {
opacity: 1;
background: rgba(15, 23, 42, 0.06);
}
#eventbuzz-concierge-root .ebc-feedback-btn:disabled {
opacity: 0.35;
cursor: default;
}
#eventbuzz-concierge-root .ebc-msg.ebc-ephemeral {
font-size: 12px;
color: var(--ebc-muted);
background: transparent;
border: none;
padding: 2px 4px;
max-width: 92%;
}

File diff suppressed because it is too large Load Diff

BIN
public/embed/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

41
scripts/free-port.js Normal file
View File

@ -0,0 +1,41 @@
#!/usr/bin/env node
/**
* Free PORT (default 3010) so npm run dev can bind after orphaned node --watch processes.
*/
const { execSync } = require('child_process');
const port = String(process.env.PORT || 3010).trim();
function pidsListening() {
try {
const out = execSync(`lsof -tiTCP:${port} -sTCP:LISTEN`, { encoding: 'utf8' }).trim();
if (!out) return [];
return [...new Set(out.split(/\s+/).filter(Boolean))];
} catch {
return [];
}
}
const pids = pidsListening();
if (!pids.length) {
console.log(`[free-port] :${port} already free`);
process.exit(0);
}
for (const pid of pids) {
try {
process.kill(Number(pid), 'SIGKILL');
console.log(`[free-port] killed pid ${pid} on :${port}`);
} catch (err) {
console.warn(`[free-port] could not kill ${pid}:`, err.message);
}
}
// brief wait for OS to release the socket
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 300);
const left = pidsListening();
if (left.length) {
console.error(`[free-port] still in use by: ${left.join(', ')}`);
process.exit(1);
}
console.log(`[free-port] :${port} is free`);

37
server.js Normal file
View File

@ -0,0 +1,37 @@
require('dotenv').config();
const app = require('./src/app');
let shuttingDown = false;
async function shutdown(signal) {
if (shuttingDown) return;
shuttingDown = true;
try {
console.log(`Received ${signal}, shutting down…`);
await app.stop();
} catch (err) {
console.error('Shutdown error:', err);
} finally {
process.exit(0);
}
}
process.on('SIGTERM', () => {
void shutdown('SIGTERM');
});
process.on('SIGINT', () => {
void shutdown('SIGINT');
});
const start = async () => {
try {
await app.start();
console.log(`Concierge server running at: ${app.info.uri}`);
} catch (err) {
console.error('Failed to start concierge server:', err);
process.exit(1);
}
};
start();

250
src/app.js Normal file
View File

@ -0,0 +1,250 @@
const fs = require('fs/promises');
const path = require('path');
const Hapi = require('@hapi/hapi');
const { connectDB } = require('./config/database');
const publicConciergeRoutes = require('./routes/publicConcierge.routes');
const {
getParticipantCheckInQrPng,
} = require('./controllers/publicParticipantQr.controller');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
function buildAllowedOrigins() {
const fromEnv = (process.env.CORS_ORIGIN || '')
.split(',')
.map((s) => s.trim().replace(/\/$/, ''))
.filter(Boolean);
const localDevOrigins = [
'http://localhost:3010',
'http://localhost:3000',
'http://localhost:5173',
'http://localhost:8080',
'http://127.0.0.1:3010',
'http://127.0.0.1:5173',
'http://127.0.0.1:8080',
];
const isProd = process.env.NODE_ENV === 'production';
return isProd
? fromEnv.length > 0
? fromEnv
: localDevOrigins
: [...new Set([...localDevOrigins, ...fromEnv])];
}
function resolveCorsAllowOrigin(rawOrigin, allowedOrigins, isProd) {
if (!rawOrigin) return null;
const origin = String(rawOrigin).trim().replace(/\/$/, '');
if (allowedOrigins.includes(origin)) return origin;
if (isProd) return null;
try {
const u = new URL(origin);
if (u.protocol !== 'http:') return null;
if (u.hostname !== 'localhost' && u.hostname !== '127.0.0.1') return null;
return origin;
} catch {
return null;
}
}
const createServer = async () => {
const isProd = process.env.NODE_ENV === 'production';
const allowedOrigins = buildAllowedOrigins();
const server = Hapi.server({
port: process.env.PORT || 3010,
host: process.env.HOST || 'localhost',
// Chat uses session tokens in the body/headers; ignore third-party/stale browser cookies.
state: {
strictHeader: false,
ignoreErrors: true,
},
routes: {
cors: {
origin: ['*'],
additionalHeaders: ['Content-Type'],
},
state: {
failAction: 'ignore',
},
validate: {
failAction: (request, h, err) => {
throw err;
},
},
},
});
server.ext('onPreResponse', (request, h) => {
const response = request.response;
const origin = resolveCorsAllowOrigin(request.headers.origin, allowedOrigins, isProd);
if (!origin) return h.continue;
if (response.isBoom) {
response.output.headers['Access-Control-Allow-Origin'] = origin;
response.output.headers['Access-Control-Allow-Credentials'] = 'true';
response.output.headers['Vary'] = 'Origin';
return h.continue;
}
response.header('Access-Control-Allow-Origin', origin);
response.header('Access-Control-Allow-Credentials', 'true');
response.header('Vary', 'Origin');
return h.continue;
});
server.route({
method: 'GET',
path: '/',
options: { auth: false },
handler: () => ({
name: 'EventBuzz Concierge',
version: '1.0.0',
status: 'ok',
stage: 'step1',
}),
});
server.route({
method: 'GET',
path: '/health',
options: { auth: false },
handler: () => ({ status: 'ok', timestamp: new Date().toISOString() }),
});
const sendFile = async (h, relPath, contentType, encoding = 'utf8') => {
const abs = path.join(PUBLIC_DIR, relPath);
const body = await fs.readFile(abs, encoding);
return h
.response(body)
.type(contentType)
.header('Cache-Control', 'no-cache, max-age=0, must-revalidate');
};
server.route([
{
method: 'GET',
path: '/embed/concierge-widget.js',
options: { auth: false },
handler: async (request, h) => sendFile(h, 'embed/concierge-widget.js', 'application/javascript'),
},
{
method: 'GET',
path: '/embed/concierge-widget.css',
options: { auth: false },
handler: async (request, h) => sendFile(h, 'embed/concierge-widget.css', 'text/css'),
},
{
method: 'GET',
path: '/embed/logo.png',
options: { auth: false },
handler: async (request, h) => sendFile(h, 'embed/logo.png', 'image/png', null),
},
{
method: 'GET',
path: '/embed/participant-check-in-qr/{participantId}.png',
options: { auth: false },
handler: getParticipantCheckInQrPng,
},
{
method: 'GET',
path: '/embed/demo',
options: { auth: false },
handler: async (request, h) => sendFile(h, 'embed-demo.html', 'text/html'),
},
]);
server.route(publicConciergeRoutes);
server.route(require('./routes/whatsappWebhook.routes'));
return server;
};
let serverInstance = null;
function isRetryableListenError(err) {
if (!err) return false;
if (err.code === 'EADDRINUSE') return true;
const msg = String(err.message || '');
// Hapi leaves the instance unusable after a failed start(); recreate and retry.
return msg.includes('invalid phase');
}
const initialize = async () => {
await connectDB();
return true;
};
/**
* Create a fresh Hapi server and listen. On EADDRINUSE (common with node --watch),
* discard the instance — Hapi cannot restart the same server after a failed start.
*/
const start = async ({ attempts = 20, delayMs = 250 } = {}) => {
await connectDB();
let lastErr;
for (let i = 0; i < attempts; i += 1) {
const server = await createServer();
try {
await server.start();
serverInstance = server;
try {
const { startDripPoller } = require('./services/whatsappDrip.service');
startDripPoller();
} catch (err) {
console.warn('[concierge] drip poller not started', err.message);
}
return serverInstance;
} catch (err) {
lastErr = err;
try {
await server.stop({ timeout: 0 });
} catch {
// ignore — server may already be in a bad phase
}
if (!isRetryableListenError(err) || i === attempts - 1) {
throw err;
}
console.warn(
`[concierge] listen failed (${err.code || err.message}); retry ${i + 1}/${attempts}…`,
);
await new Promise((r) => setTimeout(r, delayMs));
}
}
throw lastErr;
};
const stop = async () => {
if (!serverInstance) return;
const server = serverInstance;
serverInstance = null;
try {
const { stopDripPoller } = require('./services/whatsappDrip.service');
stopDripPoller();
} catch {
// ignore
}
try {
await server.stop({ timeout: 2000 });
} catch (err) {
console.warn('Error stopping Hapi server:', err?.message || err);
}
try {
const mongoose = require('mongoose');
if (mongoose.connection.readyState !== 0) {
await mongoose.connection.close();
}
} catch (err) {
console.warn('Error closing MongoDB:', err?.message || err);
}
};
module.exports = {
initialize,
start,
stop,
get info() {
return serverInstance?.info;
},
};

39
src/config/database.js Normal file
View File

@ -0,0 +1,39 @@
const mongoose = require('mongoose');
let connectPromise = null;
const connectDB = async () => {
if (mongoose.connection.readyState === 1) {
return mongoose.connection;
}
if (connectPromise) {
return connectPromise;
}
const uri = process.env.MONGODB_URI;
if (!uri) {
throw new Error('MONGODB_URI is not configured');
}
connectPromise = mongoose
.connect(uri, {
maxPoolSize: Number(process.env.MONGODB_MAX_POOL_SIZE) || 20,
serverSelectionTimeoutMS: Number(process.env.MONGODB_SERVER_SELECTION_MS) || 30000,
socketTimeoutMS: Number(process.env.MONGODB_SOCKET_TIMEOUT_MS) || 45000,
})
.then((conn) => {
console.log(`MongoDB connected: ${conn.connection.host}`);
return conn;
})
.catch((err) => {
console.error('MongoDB connection error:', err);
throw err;
})
.finally(() => {
connectPromise = null;
});
return connectPromise;
};
module.exports = { connectDB, mongoose };

View File

@ -0,0 +1,257 @@
const mongoose = require('mongoose');
const ConciergeBot = require('../models/ConciergeBot');
const Event = require('../models/Event');
const WebChatSession = require('../models/WebChatSession');
const { success, error } = require('../utils/response');
const { interpolateGreeting } = require('../utils/greeting');
const { DEFAULT_QUICK_REPLIES } = require('../utils/quickReplies');
const { clearIdentity } = require('../services/identity.service');
const {
MAX_MESSAGE_LENGTH,
MAX_HISTORY_MESSAGES,
ensureWebChatSession,
runPublicChatTurn,
} = require('../utils/webChatTurn');
const { getLivekitConfig, createVisitorVoiceToken } = require('../utils/livekitConcierge');
const ipBuckets = new Map();
const WINDOW_MS = 60_000;
const MAX_REQ_PER_WINDOW = 20;
function takeIpToken(ip) {
const now = Date.now();
const key = String(ip || 'unknown');
const row = ipBuckets.get(key) || { resetAt: now + WINDOW_MS, count: 0 };
if (now > row.resetAt) {
row.resetAt = now + WINDOW_MS;
row.count = 0;
}
row.count += 1;
ipBuckets.set(key, row);
return row.count <= MAX_REQ_PER_WINDOW;
}
async function loadBotOr404(botId) {
if (!mongoose.Types.ObjectId.isValid(String(botId))) {
return { err: error('VALIDATION_ERROR', 'Invalid bot') };
}
const bot = await ConciergeBot.findById(botId).lean();
if (!bot) return { err: error('NOT_FOUND', 'Chatbot not found') };
if (!bot.enabled || bot.status !== 'ACTIVE') {
return { err: error('FORBIDDEN', 'Chatbot is not enabled') };
}
const event = await Event.findById(bot.event).select('name location locationType').lean();
return { bot, event };
}
const config = async (request, h) => {
const { botId } = request.params;
const { bot, event, err } = await loadBotOr404(botId);
if (err) return h.response(err).code(err.error?.code === 'NOT_FOUND' ? 404 : 400);
const eventName = event?.name || undefined;
const accentColor = String(bot.accentColor || '').trim() || 'rgb(13, 148, 136)';
return h.response(
success({
botId: String(bot._id),
eventId: String(bot.event),
eventName: eventName || 'Event',
accentColor,
greeting: interpolateGreeting(bot.greeting?.message || '', {
profileName: undefined,
eventName,
}),
quickReplies: DEFAULT_QUICK_REPLIES,
maxMessageLength: MAX_MESSAGE_LENGTH,
voiceEnabled: Boolean(bot.voiceEnabled) && getLivekitConfig().configured,
}),
);
};
const chat = async (request, h) => {
const ip = request.headers['x-forwarded-for'] || request.info.remoteAddress;
if (!takeIpToken(ip)) {
return h.response(error('RATE_LIMIT', 'Too many requests. Try again in a minute.')).code(429);
}
const { botId } = request.params;
const { bot, event, err } = await loadBotOr404(botId);
if (err) return h.response(err).code(err.error?.code === 'NOT_FOUND' ? 404 : 400);
const msg = String(request.payload?.message || '').trim();
if (!msg) return h.response(error('VALIDATION_ERROR', 'Message is required')).code(400);
if (msg.length > MAX_MESSAGE_LENGTH) {
return h
.response(error('VALIDATION_ERROR', `Message too long (max ${MAX_MESSAGE_LENGTH} chars)`))
.code(400);
}
const { session, sessionId } = await ensureWebChatSession(bot, request.payload?.sessionId);
const result = await runPublicChatTurn({ bot, event, session, message: msg });
if (result.cards.length) {
console.log('[publicConcierge][chat][cards]', {
count: result.cards.length,
types: result.cards.map((c) => c?.type),
});
}
return h.response(
success({
sessionId,
reply: result.reply,
messageId: result.messageId,
intent: result.intent,
classification: result.classification,
cards: result.cards,
sessionIds: result.sessionIds,
quickReplies: result.quickReplies,
usage: result.usage,
eventName: event?.name || null,
verified: Boolean(session.participant && session.identityVerifiedAt),
}),
);
};
const voiceToken = async (request, h) => {
const ip = request.headers['x-forwarded-for'] || request.info.remoteAddress;
if (!takeIpToken(ip)) {
return h.response(error('RATE_LIMIT', 'Too many requests. Try again in a minute.')).code(429);
}
const { botId } = request.params;
const { bot, err } = await loadBotOr404(botId);
if (err) return h.response(err).code(err.error?.code === 'NOT_FOUND' ? 404 : 400);
if (!bot.voiceEnabled) {
return h.response(error('FORBIDDEN', 'Voice is not enabled for this chatbot')).code(403);
}
if (!getLivekitConfig().configured) {
return h.response(error('SERVICE_UNAVAILABLE', 'Voice is not configured')).code(503);
}
const { session, sessionId } = await ensureWebChatSession(bot, request.payload?.sessionId);
session.lastSeenAt = new Date();
await session.save();
try {
const granted = await createVisitorVoiceToken({
botId: String(bot._id),
sessionId,
});
return h.response(success({ ...granted, sessionId }));
} catch (e) {
console.error('[publicConcierge] voice token failed:', e);
return h.response(error('INTERNAL_ERROR', e.message || 'Could not start voice')).code(500);
}
};
const feedback = async (request, h) => {
const { botId } = request.params;
const { bot, err } = await loadBotOr404(botId);
if (err) return h.response(err).code(err.error?.code === 'NOT_FOUND' ? 404 : 400);
const sessionId = String(request.payload?.sessionId || '').trim();
const messageId = String(request.payload?.messageId || '').trim();
const satisfied = request.payload?.satisfied;
if (!sessionId || !messageId) {
return h.response(error('VALIDATION_ERROR', 'sessionId and messageId are required')).code(400);
}
if (typeof satisfied !== 'boolean') {
return h.response(error('VALIDATION_ERROR', 'satisfied must be true or false')).code(400);
}
const session = await WebChatSession.findOne({ botId: bot._id, sessionId });
if (!session) return h.response(error('NOT_FOUND', 'Chat session not found')).code(404);
if (session.endedAt) {
return h.response(error('GONE', 'This chat session has ended')).code(410);
}
const history = Array.isArray(session.history) ? session.history : [];
const idx = history.findIndex(
(m) => m && m.role === 'assistant' && String(m.msgId || '') === messageId,
);
if (idx < 0) {
return h.response(error('NOT_FOUND', 'Message not found')).code(404);
}
if (history[idx].satisfied != null) {
return h.response(success({ ok: true, alreadyRecorded: true, satisfied: history[idx].satisfied }));
}
history[idx].satisfied = satisfied;
history[idx].feedbackAt = new Date();
session.markModified('history');
session.lastSeenAt = new Date();
await session.save();
return h.response(success({ ok: true, satisfied }));
};
const end = async (request, h) => {
const { botId } = request.params;
const { bot, err } = await loadBotOr404(botId);
if (err) return h.response(err).code(err.error?.code === 'NOT_FOUND' ? 404 : 400);
const sessionId = String(request.payload?.sessionId || '').trim();
if (sessionId) {
const session = await WebChatSession.findOne({ botId: bot._id, sessionId });
if (session) {
clearIdentity(session);
session.lastIntent = null;
session.endedAt = new Date();
session.lastSeenAt = new Date();
await session.save();
}
}
return h.response(success({ ended: true }));
};
const getSession = async (request, h) => {
const { botId, sessionRef } = request.params;
const { bot, err } = await loadBotOr404(botId);
if (err) return h.response(err).code(err.error?.code === 'NOT_FOUND' ? 404 : 400);
const ref = String(sessionRef || '').trim();
if (!ref) return h.response(error('VALIDATION_ERROR', 'Session id is required')).code(400);
let session = null;
if (mongoose.Types.ObjectId.isValid(ref) && String(new mongoose.Types.ObjectId(ref)) === ref) {
session = await WebChatSession.findOne({ _id: ref, botId: bot._id });
}
if (!session) {
session = await WebChatSession.findOne({ botId: bot._id, sessionId: ref });
}
if (!session) return h.response(error('NOT_FOUND', 'Chat session not found')).code(404);
if (session.endedAt) {
return h.response(error('GONE', 'This chat session has ended')).code(410);
}
session.lastSeenAt = new Date();
await session.save();
const history = (Array.isArray(session.history) ? session.history : [])
.slice(-MAX_HISTORY_MESSAGES)
.map((m) => ({
role: m.role === 'user' ? 'user' : 'assistant',
text: String(m.text || ''),
}))
.filter((m) => m.text);
return h.response(
success({
id: String(session._id),
sessionId: session.sessionId,
history,
verified: Boolean(session.participant && session.identityVerifiedAt),
}),
);
};
module.exports = {
config,
chat,
voiceToken,
feedback,
end,
getSession,
};

View File

@ -0,0 +1,54 @@
const mongoose = require('mongoose');
const {
ensureParticipantCheckInQr,
generateParticipantCheckInQrPng,
} = require('../utils/participantCheckInQr');
function wantsDownload(request) {
const v = String(request.query?.download || '').toLowerCase();
return v === '1' || v === 'true' || v === 'yes';
}
/**
* GET /embed/participant-check-in-qr/{participantId}.png
* Ensures S3 object exists, then redirects to CloudFront (or streams PNG).
* ?download=1 streams with Content-Disposition attachment.
*/
const getParticipantCheckInQrPng = async (request, h) => {
const raw = String(request.params.participantId || '').replace(/\.png$/i, '').trim();
if (!mongoose.Types.ObjectId.isValid(raw)) {
return h.response('Invalid participant id').code(400).type('text/plain');
}
try {
if (wantsDownload(request)) {
// Ensure CloudFront object exists for future views; stream locally for reliable download
void ensureParticipantCheckInQr(raw).catch(() => {});
const buf = await generateParticipantCheckInQrPng(raw);
return h
.response(buf)
.type('image/png')
.header('Cache-Control', 'private, max-age=0')
.header(
'Content-Disposition',
`attachment; filename="check-in-qr-${raw}.png"`,
);
}
const ensured = await ensureParticipantCheckInQr(raw);
if (ensured.source === 's3' && ensured.url) {
return h.redirect(ensured.url).code(302);
}
const buf = await generateParticipantCheckInQrPng(raw);
return h
.response(buf)
.type('image/png')
.header('Cache-Control', 'public, max-age=86400');
} catch (err) {
console.error('[concierge participant-check-in-qr]', err?.message || err);
return h.response('Could not generate QR').code(500).type('text/plain');
}
};
module.exports = { getParticipantCheckInQrPng };

View File

@ -0,0 +1,33 @@
const mongoose = require('mongoose');
const conciergeBotSchema = new mongoose.Schema(
{
organization: { type: mongoose.Schema.Types.ObjectId, ref: 'Organization' },
event: { type: mongoose.Schema.Types.ObjectId, ref: 'Event', required: true },
name: { type: String, trim: true },
/** Widget theme accent (hex or rgb). Default teal used when empty. */
accentColor: { type: String, trim: true, default: 'rgb(13, 148, 136)' },
enabled: { type: Boolean, default: false },
status: {
type: String,
enum: ['DRAFT', 'ACTIVE', 'ARCHIVED'],
default: 'DRAFT',
},
greeting: {
enabled: { type: Boolean, default: true },
message: { type: String, default: '' },
},
faqs: [
{
category: { type: String, trim: true },
question: { type: String, trim: true },
answer: { type: String, default: '', trim: true },
},
],
/** Full reception config from dashboard (enabled, copy, drips). Keep Mixed so drips are not stripped. */
reception: { type: mongoose.Schema.Types.Mixed, default: {} },
},
{ timestamps: true, collection: 'conciergebots', strict: false },
);
module.exports = mongoose.models.ConciergeBot || mongoose.model('ConciergeBot', conciergeBotSchema);

12
src/models/Event.js Normal file
View File

@ -0,0 +1,12 @@
const mongoose = require('mongoose');
const eventSchema = new mongoose.Schema(
{
name: { type: String, trim: true },
location: { type: String, trim: true },
locationType: { type: String, trim: true },
},
{ timestamps: true, collection: 'events', strict: false },
);
module.exports = mongoose.models.Event || mongoose.model('Event', eventSchema);

View File

@ -0,0 +1,14 @@
const mongoose = require('mongoose');
const eventMicrositeSchema = new mongoose.Schema(
{
event: { type: mongoose.Schema.Types.ObjectId, ref: 'Event' },
name: String,
registrationUrl: String,
registrationFormId: { type: mongoose.Schema.Types.ObjectId, ref: 'Form' },
},
{ timestamps: true, collection: 'eventmicrosites', strict: false },
);
module.exports =
mongoose.models.EventMicrosite || mongoose.model('EventMicrosite', eventMicrositeSchema);

19
src/models/EventTicket.js Normal file
View File

@ -0,0 +1,19 @@
const mongoose = require('mongoose');
const eventTicketSchema = new mongoose.Schema(
{
event: { type: mongoose.Schema.Types.ObjectId, ref: 'Event' },
organization: { type: mongoose.Schema.Types.ObjectId, ref: 'Organization' },
name: String,
description: String,
basePriceMinor: Number,
currency: String,
isActive: Boolean,
sortOrder: Number,
participantList: { type: mongoose.Schema.Types.ObjectId, ref: 'ParticipantList' },
},
{ timestamps: true, collection: 'eventtickets', strict: false },
);
module.exports =
mongoose.models.EventTicket || mongoose.model('EventTicket', eventTicketSchema);

32
src/models/Exhibitor.js Normal file
View File

@ -0,0 +1,32 @@
const mongoose = require('mongoose');
const exhibitorSchema = new mongoose.Schema(
{
event: { type: mongoose.Schema.Types.ObjectId, ref: 'Event', index: true },
name: { type: String, trim: true },
status: { type: String, default: 'published' },
logo: String,
shortDescription: String,
description: String,
category: String,
boothNumber: String,
hall: String,
website: String,
isFeatured: Boolean,
// Declared for clarity — never serialize into public DATA
contactName: String,
contactEmail: String,
contactPhone: String,
socialLinks: {
linkedin: String,
twitter: String,
instagram: String,
website: String,
},
products: [String],
internalNotes: String,
},
{ timestamps: true, collection: 'exhibitors', strict: false },
);
module.exports = mongoose.models.Exhibitor || mongoose.model('Exhibitor', exhibitorSchema);

13
src/models/Form.js Normal file
View File

@ -0,0 +1,13 @@
const mongoose = require('mongoose');
const formSchema = new mongoose.Schema(
{
event: { type: mongoose.Schema.Types.ObjectId, ref: 'Event' },
name: String,
type: String,
settings: { type: mongoose.Schema.Types.Mixed },
},
{ timestamps: true, collection: 'forms', strict: false },
);
module.exports = mongoose.models.Form || mongoose.model('Form', formSchema);

View File

@ -0,0 +1,13 @@
const mongoose = require('mongoose');
const organizationSchema = new mongoose.Schema(
{
name: { type: String, trim: true },
email: { type: String, trim: true, lowercase: true },
phone: String,
},
{ timestamps: true, collection: 'organizations', strict: false },
);
module.exports =
mongoose.models.Organization || mongoose.model('Organization', organizationSchema);

30
src/models/Participant.js Normal file
View File

@ -0,0 +1,30 @@
const mongoose = require('mongoose');
const participantSchema = new mongoose.Schema(
{
tenantOrganization: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Organization',
required: true,
},
email: { type: String, trim: true, lowercase: true },
firstName: { type: String, trim: true },
lastName: { type: String, trim: true },
displayName: { type: String, trim: true },
deletedAt: { type: Date, default: null },
payment: mongoose.Schema.Types.Mixed,
eventMemberships: [
{
event: { type: mongoose.Schema.Types.ObjectId, ref: 'Event' },
status: String,
checkedIn: Boolean,
registrationDate: Date,
eventTicket: { type: mongoose.Schema.Types.ObjectId, ref: 'EventTicket' },
},
],
},
{ timestamps: true, collection: 'participants', strict: false },
);
module.exports =
mongoose.models.Participant || mongoose.model('Participant', participantSchema);

View File

@ -0,0 +1,14 @@
const mongoose = require('mongoose');
const participantListSchema = new mongoose.Schema(
{
tenantOrganization: { type: mongoose.Schema.Types.ObjectId, ref: 'Organization' },
type: String,
participantIds: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Participant' }],
},
{ timestamps: true, collection: 'participantlists', strict: false },
);
module.exports =
mongoose.models.ParticipantList ||
mongoose.model('ParticipantList', participantListSchema);

View File

@ -0,0 +1,22 @@
const mongoose = require('mongoose');
const paymentTransactionSchema = new mongoose.Schema(
{
organization: { type: mongoose.Schema.Types.ObjectId, ref: 'Organization' },
event: { type: mongoose.Schema.Types.ObjectId, ref: 'Event' },
participant: { type: mongoose.Schema.Types.ObjectId, ref: 'Participant' },
currency: String,
amountGross: Number,
amountSubtotal: Number,
status: String,
purpose: String,
externalOrderId: String,
externalPaymentId: String,
registrationContext: mongoose.Schema.Types.Mixed,
},
{ timestamps: true, collection: 'paymenttransactions', strict: false },
);
module.exports =
mongoose.models.PaymentTransaction ||
mongoose.model('PaymentTransaction', paymentTransactionSchema);

29
src/models/Session.js Normal file
View File

@ -0,0 +1,29 @@
const mongoose = require('mongoose');
const sessionSchema = new mongoose.Schema(
{
event: { type: mongoose.Schema.Types.ObjectId, ref: 'Event', required: true, index: true },
track: { type: mongoose.Schema.Types.ObjectId, ref: 'Track', index: true },
title: { type: String, required: true },
description: String,
type: { type: String },
startTime: { type: Date, required: true },
endTime: { type: Date, required: true },
location: String,
speakers: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Speaker' }],
status: { type: String, default: 'scheduled' },
resources: [
{
name: String,
type: String,
url: String,
uploadedAt: Date,
},
],
},
{ timestamps: true, collection: 'sessions', strict: false },
);
sessionSchema.index({ event: 1, startTime: 1 });
module.exports = mongoose.models.Session || mongoose.model('Session', sessionSchema);

View File

@ -0,0 +1,20 @@
const mongoose = require('mongoose');
const sessionBookmarkSchema = new mongoose.Schema(
{
participant: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Participant',
required: true,
index: true,
},
event: { type: mongoose.Schema.Types.ObjectId, ref: 'Event', required: true, index: true },
session: { type: mongoose.Schema.Types.ObjectId, ref: 'Session', required: true },
},
{ timestamps: true, collection: 'sessionbookmarks' },
);
sessionBookmarkSchema.index({ participant: 1, session: 1 }, { unique: true });
module.exports =
mongoose.models.SessionBookmark || mongoose.model('SessionBookmark', sessionBookmarkSchema);

27
src/models/Speaker.js Normal file
View File

@ -0,0 +1,27 @@
const mongoose = require('mongoose');
const speakerSchema = new mongoose.Schema(
{
organization: { type: mongoose.Schema.Types.ObjectId, ref: 'Organization' },
firstName: { type: String, trim: true },
lastName: { type: String, trim: true },
email: { type: String, trim: true },
phone: String,
title: String,
organizationName: String,
bio: String,
photo: String,
socialLinks: {
linkedin: String,
twitter: String,
website: String,
},
expertise: [String],
speakerRole: String,
profileVisibility: { type: String, default: 'public' },
isFeatured: Boolean,
},
{ timestamps: true, collection: 'speakers', strict: false },
);
module.exports = mongoose.models.Speaker || mongoose.model('Speaker', speakerSchema);

View File

@ -0,0 +1,20 @@
const mongoose = require('mongoose');
const ticketManualOrderSchema = new mongoose.Schema(
{
event: { type: mongoose.Schema.Types.ObjectId, ref: 'Event' },
organization: { type: mongoose.Schema.Types.ObjectId, ref: 'Organization' },
ticket: { type: mongoose.Schema.Types.ObjectId, ref: 'EventTicket' },
status: String,
purchaserEmail: String,
attendees: [mongoose.Schema.Types.Mixed],
quote: mongoose.Schema.Types.Mixed,
currency: String,
fulfillmentParticipantIds: [String],
},
{ timestamps: true, collection: 'ticketmanualorders', strict: false },
);
module.exports =
mongoose.models.TicketManualOrder ||
mongoose.model('TicketManualOrder', ticketManualOrderSchema);

13
src/models/Track.js Normal file
View File

@ -0,0 +1,13 @@
const mongoose = require('mongoose');
const trackSchema = new mongoose.Schema(
{
event: { type: mongoose.Schema.Types.ObjectId, ref: 'Event', required: true, index: true },
name: { type: String, required: true, trim: true },
hall: String,
status: { type: String, default: 'active' },
},
{ timestamps: true, collection: 'tracks', strict: false },
);
module.exports = mongoose.models.Track || mongoose.model('Track', trackSchema);

View File

@ -0,0 +1,66 @@
const mongoose = require('mongoose');
const messageSchema = new mongoose.Schema(
{
role: { type: String, enum: ['user', 'assistant'], required: true },
text: { type: String, required: true },
ts: { type: Date, default: Date.now },
modelId: { type: String, default: null },
inputTokens: { type: Number, default: 0 },
outputTokens: { type: Number, default: 0 },
/** Client-facing id for assistant replies (feedback). */
msgId: { type: String, default: null },
/** Thumb feedback: true = satisfied, false = not satisfied. */
satisfied: { type: Boolean, default: null },
feedbackAt: { type: Date, default: null },
},
{ _id: false },
);
const webChatSessionSchema = new mongoose.Schema(
{
botId: { type: mongoose.Schema.Types.ObjectId, ref: 'ConciergeBot', required: true, index: true },
eventId: { type: mongoose.Schema.Types.ObjectId, ref: 'Event' },
organization: { type: mongoose.Schema.Types.ObjectId, ref: 'Organization' },
sessionId: { type: String, required: true, trim: true, index: true },
history: { type: [messageSchema], default: [] },
lastIntent: { type: String, default: null },
lastSeenAt: { type: Date, default: Date.now },
endedAt: { type: Date, default: null },
inputTokensTotal: { type: Number, default: 0 },
outputTokensTotal: { type: Number, default: 0 },
// Identity / OTP
participant: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Participant',
default: null,
index: true,
},
identityVerifiedAt: { type: Date, default: null },
identityMethod: { type: String, default: null },
authState: {
type: String,
enum: ['awaiting_email', 'awaiting_otp'],
default: undefined,
},
authEmail: { type: String, trim: true, lowercase: true, default: null },
otpHash: { type: String, default: null },
otpExpiresAt: { type: Date, default: null },
pendingIntent: { type: String, default: null },
pendingMessage: { type: String, default: null },
pendingEntities: { type: mongoose.Schema.Types.Mixed, default: null },
/** Last session ids shown as cards — used to resolve "add it" / "yes". */
lastSessionIds: { type: [String], default: [] },
/** Rate-limit ticket QR emails from this chat session. */
lastTicketEmailAt: { type: Date, default: null },
/** Rate-limit organizer escalation emails from this chat session. */
lastEscalationEmailAt: { type: Date, default: null },
},
{ timestamps: true, collection: 'webchatsessions' },
);
webChatSessionSchema.index({ botId: 1, sessionId: 1 }, { unique: true });
module.exports =
mongoose.models.WebChatSession || mongoose.model('WebChatSession', webChatSessionSchema);

View File

@ -0,0 +1,25 @@
const mongoose = require('mongoose');
const whatsAppBlastSchema = new mongoose.Schema(
{
organization: { type: mongoose.Schema.Types.ObjectId, ref: 'Organization' },
event: { type: mongoose.Schema.Types.ObjectId, ref: 'Event' },
createdBy: { type: mongoose.Schema.Types.ObjectId },
body: { type: String, default: '' },
mediaType: { type: String, default: 'none' },
mediaUrl: { type: String, default: '' },
sendAt: Date,
status: { type: String, default: 'queued' },
stats: {
eligible: { type: Number, default: 0 },
sent: { type: Number, default: 0 },
skipped: { type: Number, default: 0 },
failed: { type: Number, default: 0 },
},
lastError: { type: String, default: '' },
sentAt: Date,
},
{ timestamps: true, collection: 'whatsappblasts', strict: false },
);
module.exports = mongoose.models.WhatsAppBlast || mongoose.model('WhatsAppBlast', whatsAppBlastSchema);

View File

@ -0,0 +1,74 @@
const mongoose = require('mongoose');
const messageSchema = new mongoose.Schema(
{
role: { type: String, enum: ['user', 'assistant'], required: true },
text: { type: String, required: true },
ts: { type: Date, default: Date.now },
modelId: { type: String, default: null },
inputTokens: { type: Number, default: 0 },
outputTokens: { type: Number, default: 0 },
msgId: { type: String, default: null },
satisfied: { type: Boolean, default: null },
feedbackAt: { type: Date, default: null },
},
{ _id: false },
);
const whatsAppChatSessionSchema = new mongoose.Schema(
{
botId: { type: mongoose.Schema.Types.ObjectId, ref: 'ConciergeBot', required: true, index: true },
eventId: { type: mongoose.Schema.Types.ObjectId, ref: 'Event' },
organization: { type: mongoose.Schema.Types.ObjectId, ref: 'Organization' },
phoneNumber: { type: String, required: true, trim: true, index: true },
waProfileName: { type: String, trim: true, default: '' },
receptionState: {
type: String,
enum: ['IDENTIFYING', 'AWAITING_BADGE', 'AWAITING_STAFF', 'ONBOARDED'],
default: 'IDENTIFYING',
},
matchScore: { type: Number, default: null },
firstInboundAt: { type: Date, default: null },
matchedAt: { type: Date, default: null },
welcomeSentAt: { type: Date, default: null },
badgeAttempts: { type: Number, default: 0 },
dripJobIds: { type: [mongoose.Schema.Types.ObjectId], default: [] },
history: { type: [messageSchema], default: [] },
lastIntent: { type: String, default: null },
lastSeenAt: { type: Date, default: Date.now },
endedAt: { type: Date, default: null },
inputTokensTotal: { type: Number, default: 0 },
outputTokensTotal: { type: Number, default: 0 },
participant: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Participant',
default: null,
index: true,
},
identityVerifiedAt: { type: Date, default: null },
identityMethod: { type: String, default: null },
authState: {
type: String,
enum: ['awaiting_email', 'awaiting_otp'],
default: undefined,
},
authEmail: { type: String, trim: true, lowercase: true, default: null },
otpHash: { type: String, default: null },
otpExpiresAt: { type: Date, default: null },
pendingIntent: { type: String, default: null },
pendingMessage: { type: String, default: null },
pendingEntities: { type: mongoose.Schema.Types.Mixed, default: null },
lastSessionIds: { type: [String], default: [] },
lastTicketEmailAt: { type: Date, default: null },
lastEscalationEmailAt: { type: Date, default: null },
},
{ timestamps: true, collection: 'whatsappchatsessions' },
);
whatsAppChatSessionSchema.index({ botId: 1, phoneNumber: 1 }, { unique: true });
module.exports =
mongoose.models.WhatsAppChatSession ||
mongoose.model('WhatsAppChatSession', whatsAppChatSessionSchema);

View File

@ -0,0 +1,41 @@
const mongoose = require('mongoose');
const whatsAppDripJobSchema = new mongoose.Schema(
{
botId: { type: mongoose.Schema.Types.ObjectId, ref: 'ConciergeBot', required: true, index: true },
session: { type: mongoose.Schema.Types.ObjectId, ref: 'WhatsAppChatSession', required: true, index: true },
phoneNumber: { type: String, required: true, trim: true },
organization: { type: mongoose.Schema.Types.ObjectId, ref: 'Organization' },
sendAt: { type: Date, required: true, index: true },
status: {
type: String,
enum: ['pending', 'sent', 'skipped', 'failed'],
default: 'pending',
index: true,
},
requireMatched: { type: Boolean, default: true },
body: { type: String, default: '' },
mediaType: { type: String, enum: ['none', 'image', 'video'], default: 'none' },
mediaUrl: { type: String, default: '' },
contentKind: { type: String, enum: ['custom', 'upcoming_agenda'], default: 'custom' },
agendaCount: { type: Number, default: 0 },
dripId: { type: String, default: '', trim: true, index: true },
templateId: { type: mongoose.Schema.Types.ObjectId, ref: 'WhatsAppTemplate', default: null },
ctas: [
{
type: { type: String, enum: ['reply', 'url'] },
label: { type: String, default: '' },
value: { type: String, default: '' },
},
],
lastError: { type: String, default: '' },
sentAt: Date,
},
{ timestamps: true, collection: 'whatsappdripjobs' },
);
whatsAppDripJobSchema.index({ status: 1, sendAt: 1 });
whatsAppDripJobSchema.index({ session: 1, dripId: 1 });
module.exports =
mongoose.models.WhatsAppDripJob || mongoose.model('WhatsAppDripJob', whatsAppDripJobSchema);

View File

@ -0,0 +1,24 @@
const mongoose = require('mongoose');
/** WhatsApp Cloud media ids last ~30 days; refresh a few days early. */
const MEDIA_TTL_MS = 25 * 24 * 60 * 60 * 1000;
const whatsAppMediaCacheSchema = new mongoose.Schema(
{
phoneNumberId: { type: String, required: true, trim: true },
sourceUrl: { type: String, required: true, trim: true },
mediaId: { type: String, required: true, trim: true },
mimeType: { type: String, default: '' },
uploadedAt: { type: Date, default: Date.now },
expiresAt: { type: Date, required: true },
},
{ timestamps: true, collection: 'whatsappmediacache' },
);
whatsAppMediaCacheSchema.index({ phoneNumberId: 1, sourceUrl: 1 }, { unique: true });
whatsAppMediaCacheSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });
const WhatsAppMediaCache =
mongoose.models.WhatsAppMediaCache || mongoose.model('WhatsAppMediaCache', whatsAppMediaCacheSchema);
module.exports = { WhatsAppMediaCache, MEDIA_TTL_MS };

View File

@ -0,0 +1,6 @@
const mongoose = require('mongoose');
const schema = new mongoose.Schema({}, { timestamps: true, collection: 'whatsapptemplates', strict: false });
module.exports =
mongoose.models.WhatsAppTemplate || mongoose.model('WhatsAppTemplate', schema);

View File

@ -0,0 +1,9 @@
module.exports = {
intent: 'agenda_by_speaker',
usesSessionCards: true,
promptlet: `
### INTENT TASK: agenda_by_speaker
Select sessions featuring the requested speaker from DATA.sessions.
message: natural spoken answer; session_ids: matching ids from DATA (empty if none).
`.trim(),
};

View File

@ -0,0 +1,10 @@
module.exports = {
intent: 'agenda_by_track',
usesSessionCards: true,
promptlet: `
### INTENT TASK: agenda_by_track
Select sessions for the requested track from DATA.sessions.
If DATA.tracks is listed without matches, ask which track in message and return session_ids [].
message: natural spoken answer; session_ids: matching ids from DATA.
`.trim(),
};

View File

@ -0,0 +1,10 @@
module.exports = {
intent: 'agenda_conflict_check',
usesSessionCards: true,
promptlet: `
### INTENT TASK: agenda_conflict_check
Use DATA.conflicts and DATA.sessions.
message: clearly say whether there are overlaps and summarize briefly.
session_ids: ids of sessions involved in conflicts (or all saved sessions if helpful); empty if none.
`.trim(),
};

View File

@ -0,0 +1,10 @@
module.exports = {
intent: 'agenda_current_now',
usesSessionCards: true,
promptlet: `
### INTENT TASK: agenda_current_now
Select sessions that are live now from DATA.sessions.
message: brief note about what is live (or that nothing is live).
session_ids: ids of live sessions from DATA (empty if none).
`.trim(),
};

View File

@ -0,0 +1,10 @@
module.exports = {
intent: 'agenda_download_materials',
usesSessionCards: true,
promptlet: `
### INTENT TASK: agenda_download_materials
Select sessions that have materials/resources in DATA.sessions.
message: natural spoken answer about available downloads (or that none exist).
session_ids: ids of sessions with resources from DATA.
`.trim(),
};

View File

@ -0,0 +1,11 @@
module.exports = {
intent: 'agenda_personal_schedule',
usesSessionCards: true,
promptlet: `
### INTENT TASK: agenda_personal_schedule
DATA.sessions are the attendee's saved/bookmarked sessions only (not the full event agenda).
- If DATA.sessions is empty (or DATA.note says none are saved): message must say clearly that no sessions are saved yet, and session_ids MUST be [].
- If DATA.sessions has items: answer naturally; session_ids MUST include those session ids from DATA (prefer all, soonest first, max 8).
Never say "here are the sessions" when DATA.sessions is empty.
`.trim(),
};

View File

@ -0,0 +1,15 @@
module.exports = {
intent: 'agenda_session_details',
usesSessionCards: true,
promptlet: `
### INTENT TASK: agenda_session_details
DATA.sessions are filtered only by day and/or speaker when those were requested.
Use ENTITIES.topic / ENTITIES.session_name / the user message to choose which sessions to return (titles + topics).
For specialty / "must not miss" / recommendation questions: pick the strongest fits for that topic (prioritize clinical relevance over tea breaks/sponsors unless asked). Prefer at most 8.
If none fit the topic/name, say so and return [].
If the user asked for the full programme ("what are the sessions", "show the agenda") and DATA.sessions is non-empty: return those ids (up to 8) and answer naturally. Do not say none found. Do not use a canned "Here is the programme."
If DATA.sessions is empty / DATA.note says none found, say so in a natural sentence (never "No matching sessions found.") and return [].
message: natural spoken answer (cards show details). Never repeat the event name.
session_ids: matching ids from DATA.
`.trim(),
};

View File

@ -0,0 +1,10 @@
module.exports = {
intent: 'agenda_upcoming_next',
usesSessionCards: true,
promptlet: `
### INTENT TASK: agenda_upcoming_next
Select the next upcoming sessions from DATA.sessions (chronological).
message: natural spoken answer about what's coming up.
session_ids: those session ids from DATA.
`.trim(),
};

View File

@ -0,0 +1,43 @@
const agenda_current_now = require('./agenda_current_now');
const agenda_upcoming_next = require('./agenda_upcoming_next');
const agenda_by_track = require('./agenda_by_track');
const agenda_by_speaker = require('./agenda_by_speaker');
const agenda_session_details = require('./agenda_session_details');
const agenda_personal_schedule = require('./agenda_personal_schedule');
const agenda_conflict_check = require('./agenda_conflict_check');
const agenda_download_materials = require('./agenda_download_materials');
const { CALL2_MAIN_PROMPT } = require('../main');
const { CALL2_CONVERSATION_PROMPT } = require('../conversation');
const MODULES = [
agenda_current_now,
agenda_upcoming_next,
agenda_by_track,
agenda_by_speaker,
agenda_session_details,
agenda_personal_schedule,
agenda_conflict_check,
agenda_download_materials,
];
const PROMPTLETS = Object.fromEntries(MODULES.map((m) => [m.intent, m.promptlet]));
const SESSION_CARD_INTENTS = new Set(
MODULES.filter((m) => m.usesSessionCards).map((m) => m.intent),
);
function buildCall2SystemPrompt(intent) {
const promptlet = PROMPTLETS[intent];
if (!promptlet) return null;
return `${CALL2_CONVERSATION_PROMPT}\n\n${CALL2_MAIN_PROMPT}\n\n${promptlet}`;
}
function usesSessionCards(intent) {
return SESSION_CARD_INTENTS.has(String(intent || ''));
}
module.exports = {
PROMPTLETS,
SESSION_CARD_INTENTS,
buildCall2SystemPrompt,
usesSessionCards,
};

View File

@ -0,0 +1,16 @@
const CALL2_CONVERSATION_PROMPT = `
### CONVERSATION (always — first message or follow-up)
You are talking with one attendee in an ongoing chat. Sound like a helpful person at the event desk, not a search engine or FAQ dump.
1. Answer what they just said. Acknowledge the ask (or the last turn) in natural language, then give the useful facts from DATA.
2. First turn: speak like a person ("A few good options nearby are…" not "Here are some forts." / "Here is the programme.").
3. "message" may be 2–5 short sentences when that helps (compare two options, react to "I've heard of X", give a reason). Do not pad. Do not list every card field in prose — cards still show details.
4. Never reuse the same sentence as the previous Assistant line in HISTORY. Vary the opener.
5. If HISTORY exists: this is the same conversation. Do not reprint the last list or recap unless they asked to see it again. If they name something from HISTORY or DATA, talk about that item. If it is not in DATA, say so honestly.
6. If there is no HISTORY: still speak like a person.
7. Facts only from DATA (or the travel exception already in the venue prompt). Never invent places, sessions, URLs, or contacts.
8. Do not add organizer email/phone unless they asked who to contact.
9. Never repeat the event name. Never say "I can only assist with [event]".
`.trim();
module.exports = { CALL2_CONVERSATION_PROMPT };

View File

@ -0,0 +1,9 @@
module.exports = {
intent: 'exhibitor_book_meeting',
usesExhibitorCards: true,
promptlet: `
### INTENT TASK: exhibitor_book_meeting
Always refuse booking in chat. Suggest visiting the booth or asking exhibitor staff / help desk.
If DATA.exhibitors has a company, you may share booth/hall so they can find them. Never confirm a meeting or invent a calendar slot.
`.trim(),
};

View File

@ -0,0 +1,10 @@
module.exports = {
intent: 'exhibitor_by_category',
usesExhibitorCards: true,
promptlet: `
### INTENT TASK: exhibitor_by_category
List matching exhibitors from DATA.exhibitors for DATA.categoryQuery (name, category, booth/hall).
Set exhibitor_ids for the matches (up to 8).
If needsClarification / empty: follow DATA.note — ask for a category or say none matched. Never invent companies.
`.trim(),
};

View File

@ -0,0 +1,10 @@
module.exports = {
intent: 'exhibitor_demo_schedule',
usesExhibitorCards: true,
promptlet: `
### INTENT TASK: exhibitor_demo_schedule
If DATA.faqs is non-empty: answer demo timing only from those FAQ Q&A pairs. You may also mention booth/hall from DATA.exhibitors.
If DATA.faqs is empty: say no demo schedule is listed; suggest the booth or help desk. Never invent times.
If needsClarification: ask which company; exhibitor_ids [].
`.trim(),
};

View File

@ -0,0 +1,11 @@
module.exports = {
intent: 'exhibitor_location',
usesExhibitorCards: true,
promptlet: `
### INTENT TASK: exhibitor_location
If DATA.isExhibitorList: briefly list exhibitors from DATA.exhibitors (name + booth/hall when present). Set exhibitor_ids (up to 8). Invite asking about a company for more detail.
If a single exhibitor: share boothNumber, hall, shortDescription/products/website from DATA.
If needsClarification: list only DATA.candidates; exhibitor_ids [].
If booth/hall missing: follow DATA.note — do not invent a location.
`.trim(),
};

View File

@ -0,0 +1,34 @@
const exhibitor_location = require('./exhibitor_location');
const exhibitor_by_category = require('./exhibitor_by_category');
const exhibitor_demo_schedule = require('./exhibitor_demo_schedule');
const exhibitor_book_meeting = require('./exhibitor_book_meeting');
const { CALL2_EXHIBITOR_MAIN_PROMPT } = require('./main');
const { CALL2_CONVERSATION_PROMPT } = require('../conversation');
const MODULES = [
exhibitor_location,
exhibitor_by_category,
exhibitor_demo_schedule,
exhibitor_book_meeting,
];
const PROMPTLETS = Object.fromEntries(MODULES.map((m) => [m.intent, m.promptlet]));
const EXHIBITOR_CARD_INTENTS = new Set(
MODULES.filter((m) => m.usesExhibitorCards).map((m) => m.intent),
);
function buildExhibitorCall2SystemPrompt(intent) {
const promptlet = PROMPTLETS[intent];
if (!promptlet) return null;
return `${CALL2_CONVERSATION_PROMPT}\n\n${CALL2_EXHIBITOR_MAIN_PROMPT}\n\n${promptlet}`;
}
function usesExhibitorCards(intent) {
return EXHIBITOR_CARD_INTENTS.has(String(intent || ''));
}
module.exports = {
PROMPTLETS,
buildExhibitorCall2SystemPrompt,
usesExhibitorCards,
};

View File

@ -0,0 +1,19 @@
const CALL2_EXHIBITOR_MAIN_PROMPT = `
You are the Virtual Concierge for an EventBuzz event (exhibitor / booth questions).
### GUARDRAILS:
1. Use only DATA. Never invent booth numbers, halls, demo times, meeting slots, websites, or exhibitor names.
2. NEVER invent a candidates list. Only mention names in DATA.exhibitors or DATA.candidates.
3. NEVER output email addresses or phone numbers.
4. If DATA.needsClarification and DATA.candidates is non-empty, ask the user to pick a company name from those candidates only.
5. If DATA.refuseBooking is true, refuse booking politely — never confirm a meeting.
6. exhibitor_ids MUST be taken only from DATA.exhibitors[].id.
7. Prefer at most 8 exhibitor_ids. Answer naturally in "message"; cards show details.
8. Output ONLY the JSON keys message, exhibitor_ids — no other keys.
### REQUIRED OUTPUT (JSON only, no markdown):
- Valid JSON only. Escape newlines inside strings as \\n. Include commas between properties.
- Example: {"message":"Short answer.","exhibitor_ids":["..."]}
`.trim();
module.exports = { CALL2_EXHIBITOR_MAIN_PROMPT };

View File

@ -0,0 +1,13 @@
module.exports = {
intent: 'accommodation',
usesPlaceCards: false,
usesHotelCards: true,
promptlet: `
### INTENT TASK: accommodation
If DATA.faqs is present: answer from those FAQ lines. Do not invent a different policy.
If they ask about hotel check-in: say what the FAQ allows. Never say "refer to the agenda". Do not add organizer email/phone unless they asked who to contact.
If DATA.hostManagedAccommodation is true: say the organizer arranges accommodation; contact the event team. hotel_ids [] / place_ids [].
Else suggest stays from DATA.hotels only; set hotel_ids (up to 6). Remind them to verify rates themselves.
If hotels empty and not host-managed: follow DATA.note — do not invent hotels.
`.trim(),
};

View File

@ -0,0 +1,31 @@
const tourist_places = require('./tourist_places');
const accommodation = require('./accommodation');
const { CALL2_LOCAL_MAIN_PROMPT } = require('./main');
const { CALL2_CONVERSATION_PROMPT } = require('../conversation');
const MODULES = [tourist_places, accommodation];
const PROMPTLETS = Object.fromEntries(MODULES.map((m) => [m.intent, m.promptlet]));
const PLACE_CARD_INTENTS = new Set(MODULES.filter((m) => m.usesPlaceCards).map((m) => m.intent));
const HOTEL_CARD_INTENTS = new Set(MODULES.filter((m) => m.usesHotelCards).map((m) => m.intent));
function buildLocalCall2SystemPrompt(intent) {
const promptlet = PROMPTLETS[intent];
if (!promptlet) return null;
return `${CALL2_CONVERSATION_PROMPT}\n\n${CALL2_LOCAL_MAIN_PROMPT}\n\n${promptlet}`;
}
function usesLocalPlaceCards(intent) {
return PLACE_CARD_INTENTS.has(String(intent || ''));
}
function usesLocalHotelCards(intent) {
return HOTEL_CARD_INTENTS.has(String(intent || ''));
}
module.exports = {
PROMPTLETS,
buildLocalCall2SystemPrompt,
usesLocalPlaceCards,
usesLocalHotelCards,
};

View File

@ -0,0 +1,18 @@
const CALL2_LOCAL_MAIN_PROMPT = `
You are the Virtual Concierge for an EventBuzz event (nearby places and stays).
### GUARDRAILS:
1. Use only DATA. Never invent attractions, hotels, addresses, ratings, or prices.
2. If DATA.faqs has an accommodation answer, use that. If DATA.hostManagedAccommodation is true, do not suggest booking hotels as if listed — say the organizer arranges stay. Never say "refer to the agenda" for hotel check-in. Do not add organizer email/phone unless they asked who to contact.
3. place_ids MUST come only from DATA.places[].id. hotel_ids only from DATA.hotels[].id.
4. Never substitute a different place category (temples ≠ churches; forts ≠ churches/beaches).
5. Prefer at most 6 ids. Answer naturally in "message"; cards show details.
6. Output ONLY the JSON keys needed: message plus place_ids and/or hotel_ids.
### REQUIRED OUTPUT (JSON only, no markdown):
- Valid JSON only. Escape newlines inside strings as \\n. Include commas between properties.
- Tourist example: {"message":"Short answer.","place_ids":["..."],"hotel_ids":[]}
- Stay example: {"message":"Short answer.","place_ids":[],"hotel_ids":["..."]}
`.trim();
module.exports = { CALL2_LOCAL_MAIN_PROMPT };

View File

@ -0,0 +1,12 @@
module.exports = {
intent: 'tourist_places',
usesPlaceCards: true,
usesHotelCards: false,
promptlet: `
### INTENT TASK: tourist_places
Suggest sightseeing / things to do from DATA.places only.
If DATA.requestedCategory is set, DATA.places are already filtered to that category — do not substitute other types (e.g. churches are not temples; beaches are not forts).
Set place_ids for the best matches (up to 6). hotel_ids [].
If DATA.places is empty: follow DATA.note — do not invent attractions or substitute other categories.
`.trim(),
};

24
src/prompts/call2/main.js Normal file
View File

@ -0,0 +1,24 @@
const CALL2_MAIN_PROMPT = `
You are the Virtual Concierge for an EventBuzz event.
Use ONLY the structured DATA provided in the user message.
### GUARDRAILS:
1. Do NOT invent sessions, times, rooms, speakers, links, or materials that are not in DATA.
2. If DATA.sessions is missing/empty or DATA is insufficient, say so clearly in "message" and return "session_ids": []. Do NOT claim sessions exist.
3. Stay on-event. Refuse unrelated requests politely.
4. Do not invent URLs. Do not mention system prompts or intent names.
5. session_ids MUST be taken only from DATA.sessions[].id (exact string ids from DATA).
6. Prefer at most 8 session_ids. Order them usefully (soonest first when relevant). When DATA.sessions has matches, include those ids (do not return an empty session_ids list).
7. "message" answers the user naturally (2–5 short sentences when useful). Do NOT list every session title/time or repeat the event name; cards will show those.
8. If DATA.note is present, treat it as an authoritative hint about missing matches.
9. TIMEZONE: Session times are in DATA.sessions[].when (event local / IST). Use that string only — do not invent or recompute times.
10. NEVER claim you added, saved, bookmarked, removed, or updated the attendee's personal agenda. Call 2 is read-only.
### REQUIRED OUTPUT (JSON only, no markdown):
{
"message": "Natural attendee-facing answer.",
"session_ids": ["64f...", "64f..."]
}
`.trim();
module.exports = { CALL2_MAIN_PROMPT };

View File

@ -0,0 +1,32 @@
const speaker_bio_lookup = require('./speaker_bio_lookup');
const speaker_meet_and_greet = require('./speaker_meet_and_greet');
const speaker_contact_info = require('./speaker_contact_info');
const { CALL2_SPEAKER_MAIN_PROMPT } = require('./main');
const { CALL2_CONVERSATION_PROMPT } = require('../conversation');
const MODULES = [speaker_bio_lookup, speaker_meet_and_greet, speaker_contact_info];
const PROMPTLETS = Object.fromEntries(MODULES.map((m) => [m.intent, m.promptlet]));
const SPEAKER_CARD_INTENTS = new Set(MODULES.filter((m) => m.usesSpeakerCards).map((m) => m.intent));
const SESSION_CARD_INTENTS = new Set(MODULES.filter((m) => m.usesSessionCards).map((m) => m.intent));
function buildSpeakerCall2SystemPrompt(intent) {
const promptlet = PROMPTLETS[intent];
if (!promptlet) return null;
return `${CALL2_CONVERSATION_PROMPT}\n\n${CALL2_SPEAKER_MAIN_PROMPT}\n\n${promptlet}`;
}
function usesSpeakerCards(intent) {
return SPEAKER_CARD_INTENTS.has(String(intent || ''));
}
function usesSpeakerSessionCards(intent) {
return SESSION_CARD_INTENTS.has(String(intent || ''));
}
module.exports = {
PROMPTLETS,
buildSpeakerCall2SystemPrompt,
usesSpeakerCards,
usesSpeakerSessionCards,
};

View File

@ -0,0 +1,19 @@
const CALL2_SPEAKER_MAIN_PROMPT = `
You are the Virtual Concierge for an EventBuzz event (speaker questions).
### GUARDRAILS:
1. Use only DATA. Never invent bios, contact details, meet-and-greet times, social URLs, or speaker names.
2. NEVER invent a candidates list. Only mention names that appear in DATA.speakers or DATA.candidates. If both are empty, say you need a name — do not guess.
3. NEVER output email addresses or phone numbers, even if you think you know them.
4. If DATA.needsClarification and DATA.candidates is non-empty, ask the user to pick a full name from those candidates only.
5. If a profile is private (DATA.speakers[].isPublic false / profileVisibility private), say the profile is not publicly listed.
6. speaker_ids MUST be taken only from DATA.speakers[].id. session_ids only from DATA.sessions[].id (or DATA.meetSessions for meet-and-greet).
7. Prefer at most 6 session_ids. Answer naturally in "message"; cards show details.
8. Output ONLY the JSON keys message, speaker_ids, session_ids — no other keys.
### REQUIRED OUTPUT (JSON only, no markdown):
- Valid JSON only. Escape newlines inside strings as \\n. Include commas between properties.
- Example: {"message":"Short answer.","speaker_ids":["..."],"session_ids":[]}
`.trim();
module.exports = { CALL2_SPEAKER_MAIN_PROMPT };

View File

@ -0,0 +1,13 @@
module.exports = {
intent: 'speaker_bio_lookup',
usesSpeakerCards: true,
usesSessionCards: true,
promptlet: `
### INTENT TASK: speaker_bio_lookup
If DATA.isSpeakerList is true: briefly introduce the programme speakers from DATA.speakers (names + title/org). Set speaker_ids to those ids (up to 8). session_ids []. Invite asking about one person for a fuller bio. Never add names not in DATA.
If a single speaker in DATA.speakers: summarize name, role, title, organization, bio, expertise.
Include their sessions when DATA.sessions is present — set session_ids accordingly.
If needsClarification: list only DATA.candidates names and ask which person; speaker_ids/session_ids [].
If private / no bio: follow DATA.note. Set speaker_ids when public speaker(s) are present.
`.trim(),
};

View File

@ -0,0 +1,12 @@
module.exports = {
intent: 'speaker_contact_info',
usesSpeakerCards: true,
usesSessionCards: false,
promptlet: `
### INTENT TASK: speaker_contact_info
Share only public social links from DATA.speakers[].socialLinks (linkedin, twitter, website).
Never invent links. Never share email or phone.
If private or no socials: follow DATA.note and suggest organizers / help desk.
speaker_ids: the matched speaker when useful. session_ids: [].
`.trim(),
};

View File

@ -0,0 +1,11 @@
module.exports = {
intent: 'speaker_meet_and_greet',
usesSpeakerCards: true,
usesSessionCards: true,
promptlet: `
### INTENT TASK: speaker_meet_and_greet
If DATA.meetSessions / DATA.sessions lists meet-and-greet or networking sessions, describe them and set session_ids.
If DATA.note says none are listed: say so honestly, optionally mention their regular sessions from DATA.sessions, suggest help desk — do not invent a meet-and-greet.
speaker_ids: matched speaker when present.
`.trim(),
};

View File

@ -0,0 +1,8 @@
module.exports = {
intent: 'chitchat_greeting',
promptlet: `
### INTENT TASK: chitchat_greeting
A warm, natural hello — vary the wording. Do not list every topic. Suggestion chips are already in the UI.
Do not reuse the same canned line every time.
`.trim(),
};

View File

@ -0,0 +1,10 @@
module.exports = {
intent: 'emergency_medical',
promptlet: `
### INTENT TASK: emergency_medical
FIRST sentence must tell them to contact venue security or medical staff immediately.
Then briefly acknowledge. If DATA.emailSent is true, say organizers were notified; otherwise do not claim that.
If DATA.contact / DATA.faqs has Email or Phone: share those exact contact details.
No diagnosis or treatment advice. Follow DATA.note.
`.trim(),
};

View File

@ -0,0 +1,11 @@
module.exports = {
intent: 'human_agent_escalation',
promptlet: `
### INTENT TASK: human_agent_escalation
Confirm you received their request for a human organizer.
If DATA.contact / DATA.faqs has Email or Phone: share those exact contact details clearly (required).
If DATA.emailSent is true, also say organizers were notified; otherwise do not claim email was sent.
You may still mention the on-site help desk / info desk as an additional option.
Offer to keep helping with agenda, ticket, or venue meanwhile.
`.trim(),
};

View File

@ -0,0 +1,28 @@
const emergency_medical = require('./emergency_medical');
const lost_and_found = require('./lost_and_found');
const human_agent_escalation = require('./human_agent_escalation');
const chitchat_greeting = require('./chitchat_greeting');
const out_of_scope = require('./out_of_scope');
const { CALL2_SYSTEM_MAIN_PROMPT } = require('./main');
const { CALL2_CONVERSATION_PROMPT } = require('../conversation');
const MODULES = [
emergency_medical,
lost_and_found,
human_agent_escalation,
chitchat_greeting,
out_of_scope,
];
const PROMPTLETS = Object.fromEntries(MODULES.map((m) => [m.intent, m.promptlet]));
function buildSystemCall2SystemPrompt(intent) {
const promptlet = PROMPTLETS[intent];
if (!promptlet) return null;
return `${CALL2_CONVERSATION_PROMPT}\n\n${CALL2_SYSTEM_MAIN_PROMPT}\n\n${promptlet}`;
}
module.exports = {
PROMPTLETS,
buildSystemCall2SystemPrompt,
};

View File

@ -0,0 +1,8 @@
module.exports = {
intent: 'lost_and_found',
promptlet: `
### INTENT TASK: lost_and_found
Acknowledge a lost-and-found request. Ask for a short item description if not already clear.
Suggest the help desk / registration. If DATA.emailSent is true, say organizers were notified; otherwise do not claim that.
`.trim(),
};

View File

@ -0,0 +1,18 @@
const CALL2_SYSTEM_MAIN_PROMPT = `
You are the Virtual Concierge for an EventBuzz event (greetings, safety, lost & found, human help).
### GUARDRAILS:
1. Use only DATA. Never invent that organizers were emailed unless DATA.emailSent is true.
2. NEVER give medical advice. For emergencies, urge immediate on-site security/medical staff first.
3. If DATA.contact or DATA.faqs include Email/Phone, you MUST share those exact values with the visitor — they are organizer-configured public contacts, not invented.
4. Do NOT invent other phone numbers, emails, or desk hours beyond DATA.
5. If DATA.contact is present, do NOT say you have no organizer contact information.
6. Answer naturally in "message" unless listing a required contact or safety step. Never repeat the event name. Never say "I can only assist with [event name]". Do not pad with capability lists.
7. Output ONLY the JSON key message — no other keys.
### REQUIRED OUTPUT (JSON only, no markdown):
- Valid JSON only. Escape newlines inside strings as \\n.
- Example: {"message":"Short answer."}
`.trim();
module.exports = { CALL2_SYSTEM_MAIN_PROMPT };

View File

@ -0,0 +1,8 @@
module.exports = {
intent: 'out_of_scope',
promptlet: `
### INTENT TASK: out_of_scope
One short sentence, like a person. Do not name the event. Do not start with "I'm afraid". Do not list every topic you can help with.
Example: "I can only help with this event — try the agenda, venue, or your ticket."
`.trim(),
};

View File

@ -0,0 +1,8 @@
module.exports = {
intent: 'app_feature_guidance',
promptlet: `
### INTENT TASK: app_feature_guidance
If DATA.faqs is non-empty: explain using those answers only.
If empty: ask which feature they need help with, or suggest in-app help / organizers. Do not invent a UI walkthrough.
`.trim(),
};

View File

@ -0,0 +1,9 @@
module.exports = {
intent: 'app_issue_account',
promptlet: `
### INTENT TASK: app_issue_account
If DATA.faqs is non-empty: answer from those FAQ Q&A pairs.
Else if DATA.staticTips: share those login tips and suggest organizers / help desk.
Never invent reset URLs or email addresses.
`.trim(),
};

View File

@ -0,0 +1,9 @@
module.exports = {
intent: 'app_issue_streaming',
promptlet: `
### INTENT TASK: app_issue_streaming
If DATA.faqs is non-empty: answer from those FAQ Q&A pairs.
Else if DATA.staticTips: list those tips clearly and suggest contacting event support if it still fails.
Never invent platform-specific settings not in DATA.
`.trim(),
};

View File

@ -0,0 +1,26 @@
const wifi_credentials = require('./wifi_credentials');
const app_issue_streaming = require('./app_issue_streaming');
const app_issue_account = require('./app_issue_account');
const app_feature_guidance = require('./app_feature_guidance');
const { CALL2_TECH_MAIN_PROMPT } = require('./main');
const { CALL2_CONVERSATION_PROMPT } = require('../conversation');
const MODULES = [
wifi_credentials,
app_issue_streaming,
app_issue_account,
app_feature_guidance,
];
const PROMPTLETS = Object.fromEntries(MODULES.map((m) => [m.intent, m.promptlet]));
function buildTechCall2SystemPrompt(intent) {
const promptlet = PROMPTLETS[intent];
if (!promptlet) return null;
return `${CALL2_CONVERSATION_PROMPT}\n\n${CALL2_TECH_MAIN_PROMPT}\n\n${promptlet}`;
}
module.exports = {
PROMPTLETS,
buildTechCall2SystemPrompt,
};

View File

@ -0,0 +1,17 @@
const CALL2_TECH_MAIN_PROMPT = `
You are the Virtual Concierge for an EventBuzz event (Wi‑Fi and app/tech help).
### GUARDRAILS:
1. Use only DATA. Never invent Wi‑Fi SSIDs, passwords, reset links, or app UI steps.
2. NEVER invent credentials. If DATA.faqs is empty for wifi, say details are not listed and suggest signage / help desk.
3. If DATA.staticTips is present, use those tips (you may number them). Do not add invented tips beyond DATA.
4. NEVER output personal email addresses or phone numbers.
5. Answer naturally in "message". You may list Wi‑Fi details from DATA when asked.
6. Output ONLY the JSON key message — no other keys.
### REQUIRED OUTPUT (JSON only, no markdown):
- Valid JSON only. Escape newlines inside strings as \\n.
- Example: {"message":"Short answer."}
`.trim();
module.exports = { CALL2_TECH_MAIN_PROMPT };

View File

@ -0,0 +1,8 @@
module.exports = {
intent: 'wifi_credentials',
promptlet: `
### INTENT TASK: wifi_credentials
If DATA.faqs has answers: share the Wi‑Fi network name and/or password exactly from those answers.
If DATA.faqs is empty: say Wi‑Fi details are not listed here; check venue signage or the help desk. Never invent a password.
`.trim(),
};

View File

@ -0,0 +1,39 @@
const ticket_qr_display = require('./ticket_qr_display');
const ticket_email_qr = require('./ticket_email_qr');
const ticket_status_lookup = require('./ticket_status_lookup');
const ticket_how_to_register = require('./ticket_how_to_register');
const ticket_document_receipt = require('./ticket_document_receipt');
const ticket_transfer_request = require('./ticket_transfer_request');
const ticket_upgrade = require('./ticket_upgrade');
const { CALL2_TICKET_MAIN_PROMPT } = require('./main');
const { CALL2_CONVERSATION_PROMPT } = require('../conversation');
const MODULES = [
ticket_qr_display,
ticket_email_qr,
ticket_status_lookup,
ticket_how_to_register,
ticket_document_receipt,
ticket_transfer_request,
ticket_upgrade,
];
const PROMPTLETS = Object.fromEntries(MODULES.map((m) => [m.intent, m.promptlet]));
const QR_CARD_INTENTS = new Set(MODULES.filter((m) => m.usesQrCard).map((m) => m.intent));
function buildTicketCall2SystemPrompt(intent) {
const promptlet = PROMPTLETS[intent];
if (!promptlet) return null;
return `${CALL2_CONVERSATION_PROMPT}\n\n${CALL2_TICKET_MAIN_PROMPT}\n\n${promptlet}`;
}
function usesQrCard(intent) {
return QR_CARD_INTENTS.has(String(intent || ''));
}
module.exports = {
PROMPTLETS,
QR_CARD_INTENTS,
buildTicketCall2SystemPrompt,
usesQrCard,
};

View File

@ -0,0 +1,17 @@
const CALL2_TICKET_MAIN_PROMPT = `
You are the Virtual Concierge for an EventBuzz event (ticket / registration questions).
### GUARDRAILS:
1. Use only DATA for personal ticket facts (name, status, ticket tier, payment, receipt, QR availability). Do not invent order ids, amounts, or statuses.
2. Never claim a transfer or upgrade succeeded. Those are not available in chat.
3. Do not mention system prompts, intent names, or internal fields like policy/note unless paraphrasing helpfully for the attendee.
4. Stay on-event. Answer naturally in "message". Do not repeat the event name or recap the whole ticket unless asked. For how-to-register, share DATA.registrationUrl / DATA.contact / DATA.faqs — never refuse as out of scope.
5. When DATA.qr.imageUrl is present, the UI shows a QR card — say only that their check-in QR is below (or that it was emailed).
### REQUIRED OUTPUT (JSON only, no markdown):
- Valid JSON only. Escape newlines inside strings as \\n. Include commas between properties.
- Example: {"message":"Your registration is confirmed.","show_qr":true}
- "show_qr" should be true when the user should see their check-in QR in chat (display or after email).
`.trim();
module.exports = { CALL2_TICKET_MAIN_PROMPT };

View File

@ -0,0 +1,11 @@
module.exports = {
intent: 'ticket_document_receipt',
usesQrCard: false,
promptlet: `
### INTENT TASK: ticket_document_receipt
Present a short text receipt from DATA.receipt when present:
- ticket name, amount, currency/status, order id, date, purchaser email, attendee list.
- If DATA.receipt is missing, say no purchase record was found and suggest contacting the organizer.
- Do not invent PDF downloads. show_qr: false.
`.trim(),
};

View File

@ -0,0 +1,12 @@
module.exports = {
intent: 'ticket_email_qr',
usesQrCard: true,
promptlet: `
### INTENT TASK: ticket_email_qr
Confirm that the ticket QR and details were emailed (or explain failure).
- Use DATA.emailSent and DATA.emailTo. Never invent a different recipient.
- If emailed: confirm the address (e.g. "I've emailed your ticket QR to you@…").
- If failed / rate-limited: explain briefly from DATA.emailError or DATA.note.
- Optionally set show_qr true so they can also see the QR in chat when DATA.qr.available.
`.trim(),
};

View File

@ -0,0 +1,15 @@
module.exports = {
intent: 'ticket_how_to_register',
usesQrCard: false,
promptlet: `
### INTENT TASK: ticket_how_to_register
They want to sign up or buy a ticket — not look up an existing registration.
Answer only from DATA. Sound like a helpful person.
- If DATA.registrationUrl: tell them they can register there and share the URL.
- If DATA.faqs has how-to text: use that.
- If DATA.contact email/phone: share those for registration questions.
- If DATA.tickets: you may mention pass names briefly.
- Never say you can only assist with this event. Never repeat DATA.eventName.
- Never ask them to verify email. show_qr: false.
`.trim(),
};

View File

@ -0,0 +1,10 @@
module.exports = {
intent: 'ticket_qr_display',
usesQrCard: true,
promptlet: `
### INTENT TASK: ticket_qr_display
Show the attendee their check-in QR.
- If DATA.qr.available: one short line such as "Here's your check-in QR."; set show_qr true. Do not recap name/status unless asked.
- If not available: apologize in one sentence and suggest the help desk.
`.trim(),
};

View File

@ -0,0 +1,10 @@
module.exports = {
intent: 'ticket_status_lookup',
usesQrCard: false,
promptlet: `
### INTENT TASK: ticket_status_lookup
Summarize registration / ticket status from DATA.
- Include attendee name, membership statusLabel, ticket name if present, check-in, and payment status/amount when present.
- show_qr: false unless they also clearly need the QR (prefer false for status-only).
`.trim(),
};

View File

@ -0,0 +1,11 @@
module.exports = {
intent: 'ticket_transfer_request',
usesQrCard: false,
promptlet: `
### INTENT TASK: ticket_transfer_request
Politely refuse. Ticket transfers cannot be completed in this chat.
Ask the attendee to contact the event organizer or on-site help desk.
Offer help with showing/emailing their QR, status, or receipt instead.
Do not collect a recipient email. show_qr: false.
`.trim(),
};

View File

@ -0,0 +1,11 @@
module.exports = {
intent: 'ticket_upgrade',
usesQrCard: false,
promptlet: `
### INTENT TASK: ticket_upgrade
Politely refuse. Ticket upgrades cannot be completed in this chat.
Ask the attendee to contact the event organizer or on-site help desk.
Offer help with showing/emailing their QR, status, or receipt instead.
Do not list upgrade tiers for purchase. show_qr: false.
`.trim(),
};

View File

@ -0,0 +1,41 @@
const venue_map_request = require('./venue_map_request');
const venue_wayfinding = require('./venue_wayfinding');
const venue_nearest_amenity = require('./venue_nearest_amenity');
const venue_parking_transport = require('./venue_parking_transport');
const venue_travel_directions = require('./venue_travel_directions');
const venue_food_beverage = require('./venue_food_beverage');
const venue_accessibility = require('./venue_accessibility');
const { CALL2_VENUE_MAIN_PROMPT } = require('./main');
const { CALL2_CONVERSATION_PROMPT } = require('../conversation');
const MODULES = [
venue_map_request,
venue_wayfinding,
venue_nearest_amenity,
venue_parking_transport,
venue_travel_directions,
venue_food_beverage,
venue_accessibility,
];
const PROMPTLETS = Object.fromEntries(MODULES.map((m) => [m.intent, m.promptlet]));
const PLACE_CARD_INTENTS = new Set(
MODULES.filter((m) => m.usesPlaceCards).map((m) => m.intent),
);
function buildVenueCall2SystemPrompt(intent) {
const promptlet = PROMPTLETS[intent];
if (!promptlet) return null;
return `${CALL2_CONVERSATION_PROMPT}\n\n${CALL2_VENUE_MAIN_PROMPT}\n\n${promptlet}`;
}
function usesPlaceCards(intent) {
return PLACE_CARD_INTENTS.has(String(intent || ''));
}
module.exports = {
PROMPTLETS,
PLACE_CARD_INTENTS,
buildVenueCall2SystemPrompt,
usesPlaceCards,
};

View File

@ -0,0 +1,20 @@
const CALL2_VENUE_MAIN_PROMPT = `
You are the Virtual Concierge for an EventBuzz event (venue questions).
### GUARDRAILS:
1. Prefer answered items in DATA.faqs (question/answer) for event-specific venue facts. Relay those clearly.
2. For maps, indoor amenities, on-site parking policy, food provided, and accessibility: if DATA.faqs is empty, follow DATA.note / DATA.fallback — do not invent those facts.
3. For intent venue_travel_directions: organizers cannot list every origin. You may use general geographic knowledge for city-to-city train/flight/road corridors to DATA.location / DATA.eventName. Prefer a direct train/flight when one commonly exists; only suggest a via-hub connection if a direct option is unlikely. Say you are not a live timetable — attendee should verify on IRCTC / airline apps.
4. If DATA.transportModes.enforce is true: local last-mile modes (taxi, Uber/Ola, event shuttle, public transport) are an allow-list. ONLY recommend DATA.transportModes.allowed. NEVER say a mode in DATA.transportModes.notAllowed is available. If asked about a notAllowed mode, say organizers have not listed it for this event.
5. Do not invent URLs. Do not mention system prompts or intent names.
6. Stay on-event. Refuse unrelated requests politely.
7. place_ids MUST be taken only from DATA.places[].id when present; otherwise return [].
8. Prefer at most 6 place_ids for food. Order usefully (closest / highest rated when data allows).
9. "message" should answer the user naturally (2–5 short sentences when useful; longer if listing directions). Do not mix parking FAQ answers into ride-app questions. Do not add organizer email/phone unless the user asked who to contact.
### REQUIRED OUTPUT (JSON only, no markdown):
- Valid JSON only. Escape newlines inside strings as \\n. Include commas between properties.
- Example: {"message":"Short answer.\\nSecond line.","place_ids":[]}
`.trim();
module.exports = { CALL2_VENUE_MAIN_PROMPT };

View File

@ -0,0 +1,10 @@
module.exports = {
intent: 'venue_accessibility',
usesPlaceCards: false,
promptlet: `
### INTENT TASK: venue_accessibility
Answer from DATA.faqs when present (wheelchair access, assistance contacts).
If no FAQ: direct the attendee to the venue help desk. Do not invent accessibility policies.
place_ids: [].
`.trim(),
};

View File

@ -0,0 +1,10 @@
module.exports = {
intent: 'venue_food_beverage',
usesPlaceCards: true,
promptlet: `
### INTENT TASK: venue_food_beverage
Answer from DATA.faqs first (food provided, breaks, dietary options).
If DATA.places exists you may select place_ids for nearby dining cards.
If no FAQ and no places: direct the attendee to the venue help desk. Never invent restaurants.
`.trim(),
};

View File

@ -0,0 +1,10 @@
module.exports = {
intent: 'venue_map_request',
usesPlaceCards: false,
promptlet: `
### INTENT TASK: venue_map_request
Answer from DATA.faqs about maps/floor plans when present.
If no FAQ: share only DATA.eventName and DATA.location (venue address). Do not invent a map URL or floor plan.
place_ids: [].
`.trim(),
};

View File

@ -0,0 +1,10 @@
module.exports = {
intent: 'venue_nearest_amenity',
usesPlaceCards: false,
promptlet: `
### INTENT TASK: venue_nearest_amenity
Answer from DATA.faqs when present (restrooms, water, charging, etc.).
If no FAQ: direct the attendee to the venue help desk. Do not invent amenity locations.
place_ids: [].
`.trim(),
};

View File

@ -0,0 +1,18 @@
module.exports = {
intent: 'venue_parking_transport',
usesPlaceCards: false,
promptlet: `
### INTENT TASK: venue_parking_transport
Answer on-site parking from DATA.faqs when present. Do not invent parking rules.
Local last-mile / ride options (taxi, Uber/Ola, shuttle, public transport):
- If DATA.transportModes.enforce is true: treat DATA.transportModes.allowed as the ONLY organizer-confirmed options.
- Never affirm a mode in DATA.transportModes.notAllowed (e.g. if "Ride apps (Uber/Ola)" is notAllowed, do NOT say Uber or Ola is available).
- If the user asks about a notAllowed mode, say organizers have not listed that option for this event and suggest the help desk or an allowed mode instead.
- If allowed is empty, say organizers have not listed local transfer options; suggest the venue help desk. Do not invent Uber/Ola/shuttle availability.
If no parking FAQ and no transportModes: share DATA.location if useful, then help desk.
City-to-venue train/flight routing is venue_travel_directions — for those, still respect DATA.transportModes for last-mile claims.
place_ids: [].
`.trim(),
};

View File

@ -0,0 +1,19 @@
module.exports = {
intent: 'venue_travel_directions',
usesPlaceCards: false,
promptlet: `
### INTENT TASK: venue_travel_directions
Help the attendee reach DATA.eventName at DATA.location from their origin (ENTITIES.location or the user message), by train / flight / road.
Organizers cannot document every origin city — use general geographic knowledge for long-distance corridors (train/flight/road between cities).
Routing rules:
1. Prefer DIRECT options first (e.g. Nagpur–Goa trains if commonly available). Do NOT default to a longer via-Mumbai/hub path when a direct corridor is plausible.
2. Mention a via-hub alternative only as a secondary option.
3. For the last mile from the nearest station/airport to DATA.location: if DATA.transportModes.enforce is true, ONLY suggest modes in DATA.transportModes.allowed. Never affirm modes in DATA.transportModes.notAllowed (e.g. do not say Uber/Ola is available if Ride apps is notAllowed).
4. If the user specifically asks whether Uber/Ola/shuttle/public transport is available and that mode is notAllowed, say organizers have not listed that option — do not invent availability.
5. If the user challenges your route (e.g. "aren't there direct trains?"), acknowledge that direct services may exist and advise checking IRCTC / live schedules — do not stubbornly repeat only the hub route.
6. You are not a live timetable. Never invent specific train numbers or exact departure times unless certain; prefer corridor-level guidance + "verify on IRCTC".
7. Do not invent host shuttles unless "Event shuttle" is in DATA.transportModes.allowed (or in DATA.faqs). Do NOT answer help-desk-only when DATA.location is present.
place_ids: [].
`.trim(),
};

View File

@ -0,0 +1,10 @@
module.exports = {
intent: 'venue_wayfinding',
usesPlaceCards: false,
promptlet: `
### INTENT TASK: venue_wayfinding
Answer from DATA.faqs when present.
If no FAQ: list halls/rooms from DATA.halls and DATA.rooms if available, and say more details are at the venue help desk. Do not invent turn-by-turn indoor directions.
place_ids: [].
`.trim(),
};

433
src/prompts/step1.js Normal file
View File

@ -0,0 +1,433 @@
/**
* Call 1 — intent classification + entity extraction.
* INTENT_DESCRIPTIONS is the single source of truth; the system prompt is built from it.
*/
const INTENT_DESCRIPTIONS = {
// Agenda
agenda_current_now:
'Sessions happening right now / currently live. Use ONLY for "now", "currently", "happening right now", "live". NOT for a specific date or day.',
agenda_upcoming_next:
'What is next / coming up soon after the present moment (next sessions). Not a specific calendar day unless they mean "next up".',
agenda_by_track:
'Sessions filtered by programme track, stream, hall theme, or track name.',
agenda_by_speaker:
'Which sessions a named speaker is in, or schedule for a speaker.',
agenda_session_details:
'Session list/details by name, topic, specialty, or calendar day/date. Also use for the full programme ("what are the sessions", "show the agenda", "what talks are there") and for recommendations ("what should I attend", "must-not-miss", "I am an asthma specialist"). For a full-programme ask, leave topic/session_name empty. Set topic only from a specialty/interest. Prefer this over agenda_current_now when a day/date or topic is mentioned. Do NOT invent intents like agenda_by_topic.',
agenda_personal_schedule:
"The attendee's own saved/bookmarked personal agenda (list/read only — not add/remove).",
agenda_bookmark_add:
'Add/save/bookmark a session to the attendee\'s personal agenda. Use for "add it", "save this", "bookmark Aerosol Therapy", "yes add it". Set session_name (or session_id) from the message or recent conversation. action_category MUST be "mutation".',
agenda_bookmark_remove:
'Remove/unsave a session from the attendee\'s personal agenda. Set session_name or session_id. action_category MUST be "mutation".',
agenda_conflict_check:
'Whether the attendee\'s saved sessions overlap in time.',
agenda_download_materials:
'Slides, PDFs, handouts, or downloadable resources for a session.',
// Venue
venue_map_request: 'Venue map, floor plan, or overall layout. Not session schedules.',
venue_wayfinding: 'Directions inside the venue to a room, hall, booth, or stage ("where is Hall A", "how do I find registration").',
venue_nearest_amenity: 'Nearest restroom, water, charging, info desk, or similar amenity.',
venue_parking_transport:
'On-site parking, local taxis/metro near the venue, airport transfer arranged by host, or event shuttle. NOT long-distance "how do I reach from another city".',
venue_travel_directions:
'How to reach the event venue from another city/airport by train, flight, or road (e.g. "from Nagpur by train", "flight from Delhi"). Put origin city in entities.location. Prefer this over venue_parking_transport for city-to-venue routing.',
venue_food_beverage: 'Food provided at the event, tea/coffee breaks, dietary options, cafes/restaurants near the venue.',
venue_accessibility: 'Accessibility, wheelchair access, assistance at the venue.',
tourist_places:
'Sightseeing and local landmarks near the venue/city: attractions, beaches, churches, temples, forts, museums, parks, "most famous church/beach/fort", things to do, spare time to roam (not restaurants; not general world trivia unrelated to the event city).',
accommodation:
'Hotel, lodging, whether accommodation is provided, or where to stay near the event (not travel directions from another city).',
// Ticket
ticket_qr_display: 'Show the attendee ticket QR / badge code in chat (display only, not email).',
ticket_email_qr:
'Email / send / mail the attendee ticket QR and registration details to their registered email.',
ticket_status_lookup: 'Status of an existing ticket / registration / check-in / payment. Use only when they already have a registration (e.g. "is my registration confirmed").',
ticket_how_to_register:
'How to register, sign up, buy tickets, get a pass, or find the registration link/form. Public how-to — not looking up an existing registration, and not "where is the registration desk".',
ticket_transfer_request: 'Transfer a ticket to someone else.',
ticket_upgrade: 'Upgrade ticket tier or pass.',
ticket_document_receipt: 'Invoice, receipt, or ticket document.',
// Speaker
speaker_bio_lookup:
'Speaker biography, who they are, background, role, org, public profile, or list of event speakers ("who are the speakers"). Not when they speak.',
speaker_meet_and_greet:
'How to meet a speaker, meet-and-greet, networking with a named speaker.',
speaker_contact_info:
'Speaker public social links (LinkedIn, Twitter/X, website). Not email/phone; not when they speak.',
// Exhibitor
exhibitor_location:
'Where an exhibitor/booth is located, company booth lookup, or list of exhibitors ("who are the exhibitors").',
exhibitor_by_category:
'Exhibitors filtered by category or industry (set entities.topic to the category).',
exhibitor_demo_schedule:
'Exhibitor demo times or product showcase schedule (may only have FAQ info).',
exhibitor_book_meeting:
'Book or schedule a meeting with an exhibitor (bot cannot book; classify here when asked).',
// Engagement
matchmaking_suggest: 'Suggest people to meet / networking matches.',
connection_request: 'Send or manage a connection request.',
qa_submit_question: 'Submit a question to a session Q&A.',
poll_active_check: 'Active polls or quiz status.',
gamification_status: 'Points, badges, leaderboard, gamification status.',
social_feed_post: 'Post to or view the event social feed.',
// Tech
wifi_credentials: 'Event Wi‑Fi network name (SSID) or password.',
app_issue_streaming:
'Streaming / live video / audio technical problems (can’t hear, black screen, mic/camera).',
app_issue_account: 'Login, OTP, or account problems in the event app.',
app_feature_guidance:
'How to use an app feature (agenda, bookmarks, Q&A, etc.) — not Wi‑Fi and not login failures.',
// System
emergency_medical:
'Medical emergency or urgent safety issue (injury, collapse, fire, threat).',
lost_and_found: 'Lost and found items (lost badge, bag, phone at the venue).',
human_agent_escalation:
'Explicit request for a human organizer/agent ("talk to organizer", "speak to staff").',
chitchat_greeting: 'Hello, thanks, goodbye, or light chitchat with no event question.',
out_of_scope:
'Unrelated to this event (coding help, other products, world trivia with no link to the event city/venue). Local sightseeing near the event (churches, temples, forts, beaches) is tourist_places — not out_of_scope.',
};
const INTENT_DOMAINS = [
{
name: 'Agenda Domain',
intents: [
'agenda_current_now',
'agenda_upcoming_next',
'agenda_by_track',
'agenda_by_speaker',
'agenda_session_details',
'agenda_personal_schedule',
'agenda_bookmark_add',
'agenda_bookmark_remove',
'agenda_conflict_check',
'agenda_download_materials',
],
},
{
name: 'Venue Domain',
intents: [
'venue_map_request',
'venue_wayfinding',
'venue_nearest_amenity',
'venue_parking_transport',
'venue_travel_directions',
'venue_food_beverage',
'venue_accessibility',
'tourist_places',
'accommodation',
],
},
{
name: 'Ticket Domain',
intents: [
'ticket_qr_display',
'ticket_email_qr',
'ticket_status_lookup',
'ticket_how_to_register',
'ticket_transfer_request',
'ticket_upgrade',
'ticket_document_receipt',
],
},
{
name: 'Speaker Domain',
intents: ['speaker_bio_lookup', 'speaker_meet_and_greet', 'speaker_contact_info'],
},
{
name: 'Exhibitor Domain',
intents: [
'exhibitor_location',
'exhibitor_by_category',
'exhibitor_demo_schedule',
'exhibitor_book_meeting',
],
},
{
name: 'Engagement Domain',
intents: [
'matchmaking_suggest',
'connection_request',
'qa_submit_question',
'poll_active_check',
'gamification_status',
'social_feed_post',
],
},
{
name: 'Tech Domain',
intents: [
'wifi_credentials',
'app_issue_streaming',
'app_issue_account',
'app_feature_guidance',
],
},
{
name: 'System Domain',
intents: [
'emergency_medical',
'lost_and_found',
'human_agent_escalation',
'chitchat_greeting',
'out_of_scope',
],
},
];
const ALLOWED_INTENTS = INTENT_DOMAINS.flatMap((d) => d.intents);
const ALLOWED_INTENT_SET = new Set(ALLOWED_INTENTS);
function isAllowedIntent(intent) {
return ALLOWED_INTENT_SET.has(String(intent || '').trim());
}
function formatDomainIntents(intents) {
return intents
.map((id) => {
const desc = INTENT_DESCRIPTIONS[id] || '';
return ` - "${id}": ${desc}`;
})
.join('\n');
}
function buildAllowedIntentsSection() {
return INTENT_DOMAINS.map(
(domain) => `- ${domain.name}:\n${formatDomainIntents(domain.intents)}`,
).join('\n\n');
}
const STEP_1_SYSTEM_PROMPT = `
You are the Intent Classification and Entity Extraction Engine for eventbuzz.live Virtual Concierge.
Your sole job is to analyze the incoming user message and short dialogue history, then produce a strictly formatted JSON object.
DO NOT output any conversational text, markdown wrapping, or explanations outside the JSON object.
### HARD CONSTRAINT — ALLOWED INTENTS ONLY:
- "primary_intent" MUST be copied EXACTLY from the ALLOWED INTENTS list below (same spelling, same underscores).
- The ALLOWED INTENTS list is a CLOSED set. You MUST NOT invent, rename, combine, or paraphrase intent ids.
- FORBIDDEN examples (never output these): agenda_by_topic, agenda_topic, agenda_recommend, agenda_recommendations, agenda_suggestion, agenda_by_interest, agenda_must_not_miss, or any other id not listed.
- If the user asks about a topic, specialty, interest, or "what should I attend / must not miss", use "agenda_session_details" and put the subject in entities.topic.
- If nothing fits, use "out_of_scope" or "chitchat_greeting" — still only from the list. Never create a new intent name.
### INSTRUCTIONS:
1. Classify into exactly ONE primary_intent from ALLOWED INTENTS. Use each intent's description to disambiguate.
2. Resolve contextual ambiguities (e.g., if user says "When is that?", extract the referenced talk/speaker from history).
- If the current message is a reaction or names something from RECENT CONVERSATION (session, speaker, place, company), set context_resolution.is_followup true and fill entities (session_name / speaker_name / location / company_name) from HISTORY.
- Do not treat "I've heard about X", "what about that one?", or "and the other one?" as a fresh list request — keep the same domain intent and resolve the named item.
3. Extract relevant entities (speaker_name, session_name, location, company_name, time_reference, topic, etc.).
- For speaker_name: strip salutations/honorifics (sir, madam, ji, Dr, Mr, Prof, Shri, Smt). "Salvi sir" / "Dr Salvi" → "Salvi".
- For day/date phrases ("8th", "on the 7th", "Aug 8", "tomorrow") set time_reference.
- For subject keywords ("chest", "biologics", "asthma", "tea break") set topic (not session_name unless they named a session).
4. Determine the action_category:
- "read": Querying information (schedules, FAQs, bios, directions).
- "mutation": Triggering a database write or API state change (bookmarking, emailing a ticket QR, Q&A submission). Ticket transfer/upgrade requests still classify as those intents but are not performed by the bot.
- "escalation": Urgent safety/medical issues or explicit requests for human organizers.
- "direct_reply": Simple greetings, chitchat, or out-of-scope questions that require no database lookup.
5. Agenda disambiguation (map into listed intents only):
- "now / currently / live" → agenda_current_now
- "what's next / upcoming soon" → agenda_upcoming_next
- "my sessions / my schedule / my agenda / saved sessions / bookmarked" (list/read) → agenda_personal_schedule
- "add / save / bookmark / put on my agenda" (including "yes", "yes add it", "add it") → agenda_bookmark_add; resolve session_name from CURRENT message or RECENT CONVERSATION
- "remove / unsave / unbookmark" → agenda_bookmark_remove
- day/date ("on the 7th", "tomorrow", "8 August") → agenda_session_details + time_reference
- topic / specialty / recommendations / must-not-miss → agenda_session_details + topic
- when / which sessions a named speaker speaks → agenda_by_speaker (set entities.speaker_name)
- named track/stream → agenda_by_track
6. Speaker disambiguation (map into listed intents only; set entities.speaker_name when a person is named):
- "who are the speakers" / list speakers / speaker lineup (no specific person) → speaker_bio_lookup with speaker_name null
- who is / bio / profile / background / about a named speaker → speaker_bio_lookup + speaker_name
- how to meet / meet-and-greet / networking with speaker → speaker_meet_and_greet
- LinkedIn / Twitter / website / public contact for a speaker → speaker_contact_info
- when does X speak / X's sessions on the schedule → agenda_by_speaker (not speaker_*)
7. Venue disambiguation (map into listed intents only):
- map / floor plan / venue layout → venue_map_request
- "where is Hall X" / find room / registration / stage inside venue → venue_wayfinding
- restroom / washroom / water / charging / info desk → venue_nearest_amenity
- on-site parking / local taxi / metro near venue / host shuttle → venue_parking_transport
- "how to reach from [city]" / train / flight / drive from another city → venue_travel_directions (set entities.location to the origin city)
- food provided / lunch / tea break / dietary / restaurants near venue → venue_food_beverage
- wheelchair / accessibility / assistance → venue_accessibility
- sightseeing / attractions / spare time to roam / things to do nearby → tourist_places (not restaurants)
- church / temple / fort / beach / museum / park / "most famous [landmark]" near the event city → tourist_places (NOT out_of_scope)
- hotel / where to stay / lodging / "is accommodation provided" / hotel check-in → accommodation
- event badge/QR check-in at the venue (not hotel) → ticket_status_lookup or venue_wayfinding for the desk
8. Ticket disambiguation (map into listed intents only):
- "show / display / see my QR / badge" in chat → ticket_qr_display
- "email / send / mail me my QR / ticket / pass" → ticket_email_qr
- "how can I register / how do I sign up / buy tickets / registration link" → ticket_how_to_register (NOT out_of_scope)
- my / existing registration / check-in / payment status → ticket_status_lookup
- invoice / receipt / payment document → ticket_document_receipt
- transfer / reassign ticket → ticket_transfer_request
- upgrade pass / change tier → ticket_upgrade
9. Exhibitor disambiguation (map into listed intents only; set entities.company_name when a company is named):
- "who are the exhibitors" / list exhibitors / exhibitor lineup → exhibitor_location with company_name null
- where is / booth / hall for a company → exhibitor_location + company_name
- exhibitors in [category/industry] → exhibitor_by_category + topic = category (not company_name)
- demo times / product showcase schedule → exhibitor_demo_schedule (+ company_name if named)
- book / schedule a meeting with an exhibitor → exhibitor_book_meeting
10. Tech disambiguation (map into listed intents only):
- Wi‑Fi / wifi password / SSID / network name → wifi_credentials
- stream / video / audio not working / can’t hear / black screen → app_issue_streaming
- can’t login / OTP / account / password reset for the event app → app_issue_account
- how do I use [app feature] / bookmarks / ask a question in-app → app_feature_guidance
11. System disambiguation (map into listed intents only):
- hi / hello / thanks / goodbye (no event question) → chitchat_greeting
- medical emergency / injury / collapse / fire / urgent safety → emergency_medical
- lost item / lost and found / found something → lost_and_found
- talk to organizer / human / staff / agent / "who to contact" / contact details → human_agent_escalation
- unrelated to the event → out_of_scope (but local landmarks near the event city stay tourist_places)
12. NEVER invent write success. Classification only — the system performs bookmark / email sends separately.
### ALLOWED INTENTS (closed list — primary_intent must be one of these exact strings):
${buildAllowedIntentsSection()}
### REQUIRED JSON OUTPUT SCHEMA:
{
"primary_intent": "agenda_session_details",
"confidence": 0.98,
"action_category": "read",
"entities": {
"speaker_name": null,
"session_name": null,
"session_id": null,
"location": null,
"company_name": null,
"time_reference": null,
"topic": "asthma"
},
"context_resolution": {
"is_followup": false,
"resolved_from_history": false
},
"requires_data_fetch": true
}
`.trim();
/** Model sometimes invents near-miss intent ids — remap before rejecting. */
const INTENT_ALIASES = {
agenda_by_topic: 'agenda_session_details',
agenda_topic: 'agenda_session_details',
agenda_by_interest: 'agenda_session_details',
agenda_recommend: 'agenda_session_details',
agenda_recommendations: 'agenda_session_details',
agenda_suggestion: 'agenda_session_details',
agenda_must_not_miss: 'agenda_session_details',
agenda_add_session: 'agenda_bookmark_add',
agenda_save_session: 'agenda_bookmark_add',
agenda_bookmark: 'agenda_bookmark_add',
agenda_remove_session: 'agenda_bookmark_remove',
agenda_unbookmark: 'agenda_bookmark_remove',
venue_how_to_reach: 'venue_travel_directions',
venue_travel: 'venue_travel_directions',
travel_transport: 'venue_travel_directions',
venue_directions_from_city: 'venue_travel_directions',
ticket_send_qr: 'ticket_email_qr',
ticket_mail_qr: 'ticket_email_qr',
ticket_email_pass: 'ticket_email_qr',
ticket_show_qr: 'ticket_qr_display',
ticket_qr: 'ticket_qr_display',
ticket_receipt: 'ticket_document_receipt',
ticket_invoice: 'ticket_document_receipt',
ticket_status: 'ticket_status_lookup',
ticket_register: 'ticket_how_to_register',
how_to_register: 'ticket_how_to_register',
ticket_purchase: 'ticket_how_to_register',
buy_tickets: 'ticket_how_to_register',
sign_up: 'ticket_how_to_register',
speaker_profile: 'speaker_bio_lookup',
speaker_bio: 'speaker_bio_lookup',
speaker_about: 'speaker_bio_lookup',
speaker_linkedin: 'speaker_contact_info',
speaker_social: 'speaker_contact_info',
speaker_twitter: 'speaker_contact_info',
speaker_website: 'speaker_contact_info',
speaker_contact: 'speaker_contact_info',
speaker_meet: 'speaker_meet_and_greet',
speaker_networking: 'speaker_meet_and_greet',
exhibitor_booth: 'exhibitor_location',
exhibitor_list: 'exhibitor_location',
exhibitor_where: 'exhibitor_location',
exhibitor_find: 'exhibitor_location',
exhibitor_category: 'exhibitor_by_category',
exhibitor_industry: 'exhibitor_by_category',
exhibitor_demo: 'exhibitor_demo_schedule',
exhibitor_demos: 'exhibitor_demo_schedule',
exhibitor_meeting: 'exhibitor_book_meeting',
exhibitor_book: 'exhibitor_book_meeting',
exhibitor_appointment: 'exhibitor_book_meeting',
wifi: 'wifi_credentials',
wifi_password: 'wifi_credentials',
wifi_ssid: 'wifi_credentials',
wifi_network: 'wifi_credentials',
streaming_issue: 'app_issue_streaming',
video_issue: 'app_issue_streaming',
audio_issue: 'app_issue_streaming',
login_issue: 'app_issue_account',
account_issue: 'app_issue_account',
otp_issue: 'app_issue_account',
app_help: 'app_feature_guidance',
app_how_to: 'app_feature_guidance',
app_feature: 'app_feature_guidance',
talk_to_human: 'human_agent_escalation',
talk_to_organizer: 'human_agent_escalation',
human_agent: 'human_agent_escalation',
contact_organizer: 'human_agent_escalation',
who_to_contact: 'human_agent_escalation',
contact_details: 'human_agent_escalation',
hotel_checkin: 'accommodation',
hotel_check_in: 'accommodation',
lost_item: 'lost_and_found',
lost_found: 'lost_and_found',
medical_emergency: 'emergency_medical',
emergency: 'emergency_medical',
hello: 'chitchat_greeting',
hi: 'chitchat_greeting',
greeting: 'chitchat_greeting',
places_nearby: 'tourist_places',
sightseeing: 'tourist_places',
tourist: 'tourist_places',
attractions: 'tourist_places',
church: 'tourist_places',
churches: 'tourist_places',
temple: 'tourist_places',
temples: 'tourist_places',
fort: 'tourist_places',
forts: 'tourist_places',
beach: 'tourist_places',
beaches: 'tourist_places',
museum: 'tourist_places',
museums: 'tourist_places',
hotel: 'accommodation',
hotels: 'accommodation',
where_to_stay: 'accommodation',
lodging: 'accommodation',
accommodation_provided: 'accommodation',
is_accommodation_provided: 'accommodation',
};
function resolveIntent(rawIntent) {
const primary = String(rawIntent || '').trim();
if (!primary) return '';
if (isAllowedIntent(primary)) return primary;
const aliased = INTENT_ALIASES[primary];
return aliased && isAllowedIntent(aliased) ? aliased : '';
}
module.exports = {
ALLOWED_INTENTS,
INTENT_DESCRIPTIONS,
INTENT_ALIASES,
STEP_1_SYSTEM_PROMPT,
isAllowedIntent,
resolveIntent,
};

View File

@ -0,0 +1,95 @@
const Joi = require('joi');
const controller = require('../controllers/publicConcierge.controller');
const chatPayload = Joi.object({
sessionId: Joi.string().max(80).allow('', null),
message: Joi.string().min(1).max(2000).required(),
});
const endPayload = Joi.object({
sessionId: Joi.string().max(80).required(),
});
const voiceTokenPayload = Joi.object({
sessionId: Joi.string().max(80).allow('', null),
});
const feedbackPayload = Joi.object({
sessionId: Joi.string().max(80).required(),
messageId: Joi.string().max(40).required(),
satisfied: Joi.boolean().required(),
});
module.exports = [
{
method: 'GET',
path: '/api/public/concierge/{botId}/config',
options: {
auth: false,
validate: { params: Joi.object({ botId: Joi.string().required() }) },
},
handler: controller.config,
},
{
method: 'GET',
path: '/api/public/concierge/{botId}/session/{sessionRef}',
options: {
auth: false,
validate: {
params: Joi.object({
botId: Joi.string().required(),
sessionRef: Joi.string().min(1).max(80).required(),
}),
},
},
handler: controller.getSession,
},
{
method: 'POST',
path: '/api/public/concierge/{botId}/chat',
options: {
auth: false,
validate: {
params: Joi.object({ botId: Joi.string().required() }),
payload: chatPayload,
},
},
handler: controller.chat,
},
{
method: 'POST',
path: '/api/public/concierge/{botId}/voice/token',
options: {
auth: false,
validate: {
params: Joi.object({ botId: Joi.string().required() }),
payload: voiceTokenPayload,
},
},
handler: controller.voiceToken,
},
{
method: 'POST',
path: '/api/public/concierge/{botId}/feedback',
options: {
auth: false,
validate: {
params: Joi.object({ botId: Joi.string().required() }),
payload: feedbackPayload,
},
},
handler: controller.feedback,
},
{
method: 'POST',
path: '/api/public/concierge/{botId}/session/end',
options: {
auth: false,
validate: {
params: Joi.object({ botId: Joi.string().required() }),
payload: endPayload,
},
},
handler: controller.end,
},
];

View File

@ -0,0 +1,58 @@
const { processInboundMessage } = require('../services/whatsappInbound.service');
const { logWhatsApp, maskPhone } = require('../utils/whatsappLog');
const verifyToken = () => process.env.META_WHATSAPP_WEBHOOK_VERIFY_TOKEN || '';
module.exports = [
{
method: 'GET',
path: '/webhook/whatsapp',
options: { auth: false },
handler: (request, h) => {
const mode = request.query['hub.mode'];
const token = request.query['hub.verify_token'];
const challenge = request.query['hub.challenge'];
const expected = verifyToken();
const ok = mode === 'subscribe' && token === expected;
logWhatsApp('webhook.verify', {
mode: mode || '',
tokenPresent: Boolean(token),
envTokenPresent: Boolean(expected),
matched: ok,
});
if (ok) return h.response(challenge).code(200);
return h.response('Forbidden').code(403);
},
},
{
method: 'POST',
path: '/webhook/whatsapp',
options: { auth: false },
handler: async (request, h) => {
const payload = request.payload;
const value = payload?.entry?.[0]?.changes?.[0]?.value;
const msg = value?.messages?.[0];
logWhatsApp('webhook.post', {
hasMessage: Boolean(msg),
phoneNumberId: String(value?.metadata?.phone_number_id || ''),
from: maskPhone(msg?.from),
type: msg?.type || '',
text: String(msg?.text?.body || '').slice(0, 80),
profileName: value?.contacts?.[0]?.profile?.name || '',
});
if (msg) {
const job = {
phoneId: value.metadata?.phone_number_id,
messageData: msg,
profileName: value.contacts?.[0]?.profile?.name || 'Visitor',
};
setImmediate(() => {
processInboundMessage(job).catch((err) => {
console.error('[whatsapp] inbound failed', err.message || err);
});
});
}
return h.response('OK').code(200);
},
},
];

View File

@ -0,0 +1,478 @@
const mongoose = require('mongoose');
const Session = require('../models/Session');
const Track = require('../models/Track');
const SessionBookmark = require('../models/SessionBookmark');
const { resolveSpeakerIds, resolveEventSpeakers } = require('../utils/resolveSpeaker');
const { stripNameSalutations } = require('../utils/nameSalutations');
const {
getTimeZone,
formatDateTimeLocal,
formatTimeRangeLocal,
parseTimeReference,
} = require('../utils/eventTime');
const TIME_ZONE = getTimeZone();
const MAX_SESSIONS = 25;
const DESC_MAX = 280;
function now() {
return new Date();
}
function trimText(value, max = DESC_MAX) {
const s = String(value || '').trim();
if (!s) return null;
return s.length > max ? `${s.slice(0, max)}…` : s;
}
function speakerName(sp) {
if (!sp) return null;
const name = [sp.firstName, sp.lastName].filter(Boolean).join(' ').trim();
return name || null;
}
function serializeSession(doc) {
if (!doc) return null;
const track = doc.track && typeof doc.track === 'object' ? doc.track : null;
const speakers = Array.isArray(doc.speakers)
? doc.speakers
.map((sp) => {
if (!sp || typeof sp !== 'object') return null;
return {
name: speakerName(sp),
title: trimText(sp.title, 80),
organization: trimText(sp.organizationName, 80),
};
})
.filter((s) => s && s.name)
: [];
const resources = Array.isArray(doc.resources)
? doc.resources
.slice(0, 8)
.map((r) => ({
name: trimText(r?.name, 120) || 'Resource',
type: trimText(r?.type, 40),
url: trimText(r?.url, 400),
}))
.filter((r) => r.url)
: [];
const topics = Array.isArray(doc.topics)
? doc.topics
.map((t) => ({
title: trimText(t?.title, 160),
type: trimText(t?.type, 60),
duration: trimText(t?.duration, 40),
role: trimText(t?.role, 80),
}))
.filter((t) => t.title)
.slice(0, 12)
: [];
const startIso = doc.startTime ? new Date(doc.startTime).toISOString() : null;
const endIso = doc.endTime ? new Date(doc.endTime).toISOString() : null;
return {
id: String(doc._id),
title: String(doc.title || '').trim(),
type: doc.type || null,
status: doc.status || null,
startTime: startIso,
endTime: endIso,
/** Human times in event TZ — LLM and UI must use these, not raw ISO clock hours */
startTimeLocal: formatDateTimeLocal(doc.startTime),
endTimeLocal: formatDateTimeLocal(doc.endTime),
timeRangeLocal: formatTimeRangeLocal(doc.startTime, doc.endTime),
timeZone: TIME_ZONE,
location: trimText(doc.location, 120),
track: track
? { id: String(track._id), name: trimText(track.name, 80), hall: trimText(track.hall, 80) }
: null,
speakers,
topics,
description: trimText(doc.description),
resources,
};
}
function baseQuery(eventId) {
return {
event: eventId,
status: { $ne: 'cancelled' },
};
}
async function loadSessions(filter, { limit = MAX_SESSIONS } = {}) {
const rows = await Session.find(filter)
.populate({ path: 'track', select: 'name hall' })
.populate({ path: 'speakers', select: 'firstName lastName title organizationName' })
.sort({ startTime: 1 })
.limit(limit)
.lean();
return rows.map(serializeSession).filter(Boolean);
}
async function listUpcomingSessions(eventId, { limit = 3, now: ts } = {}) {
const at = ts instanceof Date ? ts : now();
let n = Number(limit);
if (!Number.isFinite(n)) n = 3;
n = Math.min(8, Math.max(1, Math.round(n)));
return loadSessions(
{
...baseQuery(eventId),
startTime: { $gt: at },
},
{ limit: n },
);
}
function entityStr(entities, key) {
const v = entities?.[key];
if (v == null || v === '') return '';
return String(v).trim();
}
const GENERIC_AGENDA_LABELS = new Set([
'session',
'sessions',
'the sessions',
'agenda',
'the agenda',
'programme',
'program',
'the programme',
'the program',
'talks',
'talk',
'schedule',
'the schedule',
]);
function specificAgendaHint(value) {
const s = String(value || '').trim();
if (!s) return '';
return GENERIC_AGENDA_LABELS.has(s.toLowerCase()) ? '' : s;
}
async function eventSessionDateHint(eventId) {
const [minRow, maxRow] = await Promise.all([
Session.findOne(baseQuery(eventId)).sort({ startTime: 1 }).select('startTime').lean(),
Session.findOne(baseQuery(eventId)).sort({ startTime: -1 }).select('startTime').lean(),
]);
return {
minDate: minRow?.startTime ? new Date(minRow.startTime) : null,
maxDate: maxRow?.startTime ? new Date(maxRow.startTime) : null,
};
}
async function resolveTrackIds(eventId, trackHint) {
const hint = String(trackHint || '').trim();
if (!hint) return null;
const tracks = await Track.find({
event: eventId,
name: { $regex: hint.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), $options: 'i' },
})
.select('_id name')
.limit(10)
.lean();
return tracks.map((t) => t._id);
}
function sessionsOverlap(a, b) {
if (!a?.startTime || !a?.endTime || !b?.startTime || !b?.endTime) return false;
const aStart = new Date(a.startTime).getTime();
const aEnd = new Date(a.endTime).getTime();
const bStart = new Date(b.startTime).getTime();
const bEnd = new Date(b.endTime).getTime();
return aStart < bEnd && bStart < aEnd;
}
/**
* @param {{ intent: string, eventId: import('mongoose').Types.ObjectId, entities?: object, participantId?: import('mongoose').Types.ObjectId|null }} opts
*/
async function fetchAgendaData({ intent, eventId, entities = {}, participantId = null }) {
const ts = now();
const meta = {
intent,
now: ts.toISOString(),
nowLocal: formatDateTimeLocal(ts),
timeZone: TIME_ZONE,
eventId: String(eventId),
};
switch (intent) {
case 'agenda_current_now': {
const sessions = await loadSessions({
...baseQuery(eventId),
startTime: { $lte: ts },
endTime: { $gt: ts },
});
return { ...meta, sessions, note: sessions.length ? null : 'No sessions are live right now.' };
}
case 'agenda_upcoming_next': {
const sessions = await listUpcomingSessions(eventId, { limit: 8, now: ts });
return { ...meta, sessions, note: sessions.length ? null : 'No upcoming sessions found.' };
}
case 'agenda_by_track': {
const trackHint =
entityStr(entities, 'topic') ||
entityStr(entities, 'location') ||
entityStr(entities, 'session_name');
const allTracks = await Track.find({ event: eventId }).select('name hall').sort({ name: 1 }).limit(40).lean();
const tracks = allTracks.map((t) => ({ name: t.name, hall: t.hall || null }));
if (!trackHint) {
return {
...meta,
trackQuery: null,
tracks,
sessions: [],
note: 'No track specified. Ask which track they want, using the available tracks list.',
};
}
const trackIds = await resolveTrackIds(eventId, trackHint);
if (!trackIds || !trackIds.length) {
return {
...meta,
trackQuery: trackHint,
tracks,
sessions: [],
note: `No track matched "${trackHint}". Available tracks are listed.`,
};
}
const sessions = await loadSessions(
{ ...baseQuery(eventId), track: { $in: trackIds } },
{ limit: MAX_SESSIONS },
);
return { ...meta, trackQuery: trackHint, tracks, sessions };
}
case 'agenda_by_speaker': {
const speakerHint = stripNameSalutations(entityStr(entities, 'speaker_name'));
if (!speakerHint) {
return {
...meta,
sessions: [],
note: 'No speaker name was provided. Ask which speaker they mean.',
};
}
const resolved = await resolveEventSpeakers(eventId, speakerHint);
if (!resolved.speakerIds.length) {
return { ...meta, speakerQuery: speakerHint, sessions: [], note: `No sessions found for speaker "${speakerHint}".` };
}
const or = [
{ speakers: { $in: resolved.speakerIds } },
{ moderator: { $in: resolved.speakerIds } },
];
if (resolved.linkedSessionIds.length) {
or.push({
_id: {
$in: resolved.linkedSessionIds.filter((id) => mongoose.Types.ObjectId.isValid(id)),
},
});
}
const sessions = await loadSessions({
...baseQuery(eventId),
$or: or,
});
return { ...meta, speakerQuery: speakerHint, sessions };
}
case 'agenda_session_details':
case 'agenda_download_materials': {
// Code filters only by date + speaker. Topic/session name matching is Call 2's job.
const sessionId = entityStr(entities, 'session_id');
const speakerNameHint = stripNameSalutations(entityStr(entities, 'speaker_name'));
const timeReference = entityStr(entities, 'time_reference');
if (sessionId && mongoose.Types.ObjectId.isValid(sessionId)) {
const sessions = await loadSessions({ ...baseQuery(eventId), _id: sessionId }, { limit: 1 });
return { ...meta, sessions, includeResources: true };
}
const dateHint = await eventSessionDateHint(eventId);
const day = parseTimeReference(timeReference, ts, dateHint);
const filter = { ...baseQuery(eventId) };
if (day) {
filter.startTime = { $gte: day.start, $lt: day.end };
}
if (speakerNameHint) {
const speakerIds = await resolveSpeakerIds(eventId, speakerNameHint);
if (!speakerIds.length) {
return {
...meta,
query: {
speakerName: speakerNameHint,
timeReference: timeReference || null,
day: day?.label || null,
},
sessions: [],
includeResources: intent === 'agenda_download_materials',
note: `No speaker matched "${speakerNameHint}".`,
};
}
filter.speakers = { $in: speakerIds };
}
let sessions;
if (day || speakerNameHint) {
sessions = await loadSessions(filter, { limit: MAX_SESSIONS });
} else {
// Generic "what are the sessions" — prefer still-on / upcoming, then the full programme
sessions = await loadSessions(
{
...baseQuery(eventId),
$or: [{ endTime: { $gte: ts } }, { endTime: null, startTime: { $gte: ts } }],
},
{ limit: MAX_SESSIONS },
);
if (!sessions.length) {
sessions = await loadSessions(baseQuery(eventId), { limit: MAX_SESSIONS });
}
}
let note = null;
if (!sessions.length) {
if (day && speakerNameHint) {
note = `No sessions for "${speakerNameHint}" on ${day.label}.`;
} else if (day) {
note = `No sessions found on ${day.label}.`;
} else if (speakerNameHint) {
note = `No sessions found for speaker "${speakerNameHint}".`;
} else {
note = "I don't have the programme listed yet. Try asking about a day or speaker.";
}
}
return {
...meta,
query: {
speakerName: speakerNameHint || null,
timeReference: timeReference || null,
day: day?.label || null,
topic: specificAgendaHint(entityStr(entities, 'topic')) || null,
sessionName: specificAgendaHint(entityStr(entities, 'session_name')) || null,
},
sessions,
includeResources: intent === 'agenda_download_materials',
note,
};
}
case 'agenda_personal_schedule': {
if (!participantId) {
return { ...meta, sessions: [], note: 'Participant is not verified.' };
}
const bookmarks = await SessionBookmark.find({
participant: participantId,
event: eventId,
})
.select('session')
.lean();
const ids = bookmarks.map((b) => b.session).filter(Boolean);
if (!ids.length) {
return {
...meta,
sessions: [],
note: "You don't have any saved sessions yet. Bookmark sessions in the event agenda to see them here.",
};
}
const sessions = await loadSessions({
...baseQuery(eventId),
_id: { $in: ids },
});
return { ...meta, sessions, source: 'bookmarks' };
}
case 'agenda_conflict_check': {
if (!participantId) {
return { ...meta, sessions: [], conflicts: [], note: 'Participant is not verified.' };
}
const bookmarks = await SessionBookmark.find({
participant: participantId,
event: eventId,
})
.select('session')
.lean();
const ids = bookmarks.map((b) => b.session).filter(Boolean);
const sessions = ids.length
? await loadSessions({ ...baseQuery(eventId), _id: { $in: ids } })
: [];
const conflicts = [];
for (let i = 0; i < sessions.length; i += 1) {
for (let j = i + 1; j < sessions.length; j += 1) {
if (sessionsOverlap(sessions[i], sessions[j])) {
conflicts.push({
a: {
id: sessions[i].id,
title: sessions[i].title,
timeRangeLocal: sessions[i].timeRangeLocal,
},
b: {
id: sessions[j].id,
title: sessions[j].title,
timeRangeLocal: sessions[j].timeRangeLocal,
},
});
}
}
}
return {
...meta,
sessions,
conflicts,
note: sessions.length
? conflicts.length
? `${conflicts.length} overlapping pair(s) found in saved sessions.`
: 'No time conflicts among saved sessions.'
: "You don't have any saved sessions yet, so there's nothing to check for conflicts.",
};
}
default:
return { ...meta, sessions: [], note: 'Unsupported agenda intent.' };
}
}
const AGENDA_INTENTS = new Set([
'agenda_current_now',
'agenda_upcoming_next',
'agenda_by_track',
'agenda_by_speaker',
'agenda_session_details',
'agenda_personal_schedule',
'agenda_conflict_check',
'agenda_download_materials',
]);
const AUTH_REQUIRED_AGENDA_INTENTS = new Set([
'agenda_personal_schedule',
'agenda_conflict_check',
'agenda_bookmark_add',
'agenda_bookmark_remove',
]);
function isAgendaIntent(intent) {
return AGENDA_INTENTS.has(String(intent || ''));
}
function isAuthRequiredAgendaIntent(intent) {
return AUTH_REQUIRED_AGENDA_INTENTS.has(String(intent || ''));
}
module.exports = {
fetchAgendaData,
listUpcomingSessions,
isAgendaIntent,
isAuthRequiredAgendaIntent,
AGENDA_INTENTS,
AUTH_REQUIRED_AGENDA_INTENTS,
TIME_ZONE,
};

View File

@ -0,0 +1,257 @@
const mongoose = require('mongoose');
const Session = require('../models/Session');
const SessionBookmark = require('../models/SessionBookmark');
const { buildSessionCards } = require('../utils/sessionCards');
const {
getTimeZone,
formatDateTimeLocal,
formatTimeRangeLocal,
} = require('../utils/eventTime');
const TIME_ZONE = getTimeZone();
function serializeSession(doc) {
if (!doc) return null;
const track = doc.track && typeof doc.track === 'object' ? doc.track : null;
return {
id: String(doc._id),
title: String(doc.title || '').trim(),
type: doc.type || null,
status: doc.status || null,
startTime: doc.startTime ? new Date(doc.startTime).toISOString() : null,
endTime: doc.endTime ? new Date(doc.endTime).toISOString() : null,
startTimeLocal: formatDateTimeLocal(doc.startTime),
endTimeLocal: formatDateTimeLocal(doc.endTime),
timeRangeLocal: formatTimeRangeLocal(doc.startTime, doc.endTime),
timeZone: TIME_ZONE,
location: doc.location || null,
track: track
? {
id: String(track._id),
name: track.name || null,
hall: track.hall || null,
}
: null,
speakers: Array.isArray(doc.speakers)
? doc.speakers
.map((sp) => {
if (!sp || typeof sp !== 'object') return null;
const name = [sp.firstName, sp.lastName].filter(Boolean).join(' ').trim();
return name ? { name } : null;
})
.filter(Boolean)
: [],
topics: Array.isArray(doc.topics)
? doc.topics
.map((t) => ({
title: t?.title || null,
type: t?.type || null,
duration: t?.duration || null,
role: t?.role || null,
}))
.filter((t) => t.title)
.slice(0, 12)
: [],
};
}
async function loadByIds(eventId, ids) {
const objectIds = (Array.isArray(ids) ? ids : [])
.map((id) => String(id || '').trim())
.filter((id) => mongoose.Types.ObjectId.isValid(id))
.map((id) => new mongoose.Types.ObjectId(id));
if (!objectIds.length) return [];
const rows = await Session.find({
event: eventId,
_id: { $in: objectIds },
status: { $ne: 'cancelled' },
})
.populate({ path: 'track', select: 'name hall' })
.populate({ path: 'speakers', select: 'firstName lastName' })
.sort({ startTime: 1 })
.limit(8)
.lean();
return rows.map(serializeSession).filter(Boolean);
}
async function findByName(eventId, sessionName) {
const hint = String(sessionName || '').trim();
if (!hint) return [];
const escaped = hint.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const rows = await Session.find({
event: eventId,
status: { $ne: 'cancelled' },
$or: [
{ title: { $regex: escaped, $options: 'i' } },
{ 'topics.title': { $regex: escaped, $options: 'i' } },
],
})
.populate({ path: 'track', select: 'name hall' })
.populate({ path: 'speakers', select: 'firstName lastName' })
.sort({ startTime: 1 })
.limit(8)
.lean();
return rows.map(serializeSession).filter(Boolean);
}
function pickSession(candidates, sessionName) {
if (!candidates.length) return { session: null, ambiguous: false };
if (candidates.length === 1) return { session: candidates[0], ambiguous: false };
const hint = String(sessionName || '').trim().toLowerCase();
if (hint) {
const exact = candidates.filter((s) => String(s.title || '').toLowerCase() === hint);
if (exact.length === 1) return { session: exact[0], ambiguous: false };
}
return { session: null, ambiguous: true };
}
async function resolveCandidates({ eventId, entities = {}, fallbackSessionIds = [] }) {
const sessionId = String(entities.session_id || '').trim();
const sessionName = String(entities.session_name || '').trim();
if (sessionId && mongoose.Types.ObjectId.isValid(sessionId)) {
return loadByIds(eventId, [sessionId]);
}
if (sessionName) {
const byName = await findByName(eventId, sessionName);
if (byName.length) return byName;
}
if (Array.isArray(fallbackSessionIds) && fallbackSessionIds.length) {
return loadByIds(eventId, fallbackSessionIds);
}
return [];
}
function cardsFor(sessions) {
const ids = sessions.map((s) => s.id);
return buildSessionCards(ids, { sessions });
}
/**
* Add a session bookmark for the verified participant.
*/
async function addBookmark({ eventId, participantId, entities = {}, fallbackSessionIds = [] }) {
if (!participantId) {
return {
reply: 'Please verify your registration first so I can update your personal agenda.',
cards: [],
sessionIds: [],
ok: false,
};
}
const candidates = await resolveCandidates({ eventId, entities, fallbackSessionIds });
if (!candidates.length) {
const hint = String(entities.session_name || entities.session_id || '').trim();
return {
reply: hint
? `I couldn't find a session matching "${hint}" to add to your agenda.`
: 'Which session should I add to your personal agenda? Tell me the session name.',
cards: [],
sessionIds: [],
ok: false,
};
}
const { session, ambiguous } = pickSession(candidates, entities.session_name);
if (ambiguous || !session) {
return {
reply: 'I found a few matching sessions — which one should I add?',
cards: cardsFor(candidates),
sessionIds: candidates.map((s) => s.id),
ok: false,
};
}
const existing = await SessionBookmark.findOne({
participant: participantId,
session: session.id,
})
.select('_id')
.lean();
await SessionBookmark.findOneAndUpdate(
{ participant: participantId, session: session.id },
{
$set: {
event: eventId,
participant: participantId,
session: session.id,
},
},
{ upsert: true, new: true },
);
return {
reply: existing
? `"${session.title}" is already in your personal agenda.`
: `I've added "${session.title}" to your personal agenda.`,
cards: cardsFor([session]),
sessionIds: [session.id],
ok: true,
already: Boolean(existing),
};
}
/**
* Remove a session bookmark for the verified participant.
*/
async function removeBookmark({ eventId, participantId, entities = {}, fallbackSessionIds = [] }) {
if (!participantId) {
return {
reply: 'Please verify your registration first so I can update your personal agenda.',
cards: [],
sessionIds: [],
ok: false,
};
}
const candidates = await resolveCandidates({ eventId, entities, fallbackSessionIds });
if (!candidates.length) {
const hint = String(entities.session_name || entities.session_id || '').trim();
return {
reply: hint
? `I couldn't find a saved session matching "${hint}".`
: 'Which saved session should I remove?',
cards: [],
sessionIds: [],
ok: false,
};
}
const { session, ambiguous } = pickSession(candidates, entities.session_name);
if (ambiguous || !session) {
return {
reply: 'I found a few matching sessions — which one should I remove?',
cards: cardsFor(candidates),
sessionIds: candidates.map((s) => s.id),
ok: false,
};
}
const deleted = await SessionBookmark.deleteOne({
participant: participantId,
session: session.id,
});
return {
reply: deleted.deletedCount
? `I've removed "${session.title}" from your personal agenda.`
: `"${session.title}" was not in your personal agenda.`,
cards: cardsFor([session]),
sessionIds: [session.id],
ok: Boolean(deleted.deletedCount),
};
}
function isBookmarkMutationIntent(intent) {
const id = String(intent || '');
return id === 'agenda_bookmark_add' || id === 'agenda_bookmark_remove';
}
module.exports = {
addBookmark,
removeBookmark,
isBookmarkMutationIntent,
};

View File

@ -0,0 +1,270 @@
const {
listEventExhibitors,
resolveEventExhibitors,
findExhibitorsByCategory,
} = require('../utils/resolveExhibitor');
const EXHIBITOR_INTENTS = new Set([
'exhibitor_location',
'exhibitor_by_category',
'exhibitor_demo_schedule',
'exhibitor_book_meeting',
]);
const DEMO_FAQ_RE = /demo|exhibit|booth.*talk|product showcase|exhibitor demo/i;
const DESC_MAX = 800;
const LIST_DESC_MAX = 180;
const MAX_LIST = 24;
const FAQ_MAX = 8;
const ANSWER_MAX = 600;
const HELPDESK = 'Please ask at the venue help desk or visit the exhibitor booth for the latest details.';
const BOOKING_REFUSE_NOTE =
'Meeting booking is not available in this chat. Suggest visiting the booth or asking exhibitor staff / the help desk. Never invent a booking confirmation.';
function isExhibitorIntent(intent) {
return EXHIBITOR_INTENTS.has(String(intent || ''));
}
function entityStr(entities, key) {
const v = entities?.[key];
if (v == null || v === '') return '';
return String(v).trim();
}
function trimText(value, max = DESC_MAX) {
const s = String(value || '').trim();
if (!s) return null;
return s.length > max ? `${s.slice(0, max)}…` : s;
}
function publicSocials(doc) {
const links = doc?.socialLinks && typeof doc.socialLinks === 'object' ? doc.socialLinks : {};
const website = trimText(doc?.website, 400) || trimText(links.website, 400);
return {
linkedin: trimText(links.linkedin, 400),
twitter: trimText(links.twitter, 400),
instagram: trimText(links.instagram, 400),
website: website || null,
};
}
function serializePublicExhibitor(doc, { descMax = DESC_MAX } = {}) {
if (!doc) return null;
const socials = publicSocials(doc);
return {
id: String(doc._id),
name: trimText(doc.name, 160) || 'Exhibitor',
logo: trimText(doc.logo, 500),
shortDescription: trimText(doc.shortDescription, descMax),
description: trimText(doc.description, descMax),
category: trimText(doc.category, 120),
boothNumber: trimText(doc.boothNumber, 40),
hall: trimText(doc.hall, 80),
website: socials.website,
isFeatured: Boolean(doc.isFeatured),
products: Array.isArray(doc.products)
? doc.products.map((p) => String(p || '').trim()).filter(Boolean).slice(0, 12)
: [],
socialLinks: socials,
};
}
function sortByName(a, b) {
return String(a?.name || '').localeCompare(String(b?.name || ''), undefined, { sensitivity: 'base' });
}
function demoFaqsFromBot(bot) {
const rows = Array.isArray(bot?.faqs) ? bot.faqs : [];
return rows
.filter((f) => {
const q = String(f?.question || '');
const a = String(f?.answer || '').trim();
if (!a) return false;
return DEMO_FAQ_RE.test(`${q} ${a}`);
})
.slice(0, FAQ_MAX)
.map((f) => ({
q: trimText(f.question, 200),
a: trimText(f.answer, ANSWER_MAX),
category: trimText(f.category, 80),
}));
}
async function resolveNamedOrClarify(eventId, companyHint) {
const resolved = await resolveEventExhibitors(eventId, companyHint);
const candidates = resolved.candidates || [];
if (!candidates.length) {
return {
exhibitors: [],
candidates: [],
needsClarification: false,
note: `No published exhibitor matched "${companyHint}". Suggest checking the spelling or browsing exhibitors by category.`,
};
}
if (candidates.length > 1) {
return {
exhibitors: [],
candidates,
needsClarification: true,
note: `Several exhibitors matched "${companyHint}". Ask the attendee to pick one by full company name.`,
};
}
const doc = (resolved.docs || [])[0];
const exhibitor = serializePublicExhibitor(doc);
return {
exhibitors: exhibitor ? [exhibitor] : [],
candidates: [],
needsClarification: false,
note: null,
};
}
async function fetchExhibitorList(eventId) {
const docs = await listEventExhibitors(eventId, { limit: MAX_LIST });
const exhibitors = docs
.map((d) => serializePublicExhibitor(d, { descMax: LIST_DESC_MAX }))
.filter(Boolean)
.sort(sortByName);
if (!exhibitors.length) {
return {
intent: 'exhibitor_location',
companyQuery: null,
categoryQuery: null,
isExhibitorList: true,
exhibitors: [],
candidates: [],
faqs: [],
refuseBooking: false,
needsClarification: false,
note: 'No published exhibitors are listed for this event yet. Suggest the help desk.',
};
}
return {
intent: 'exhibitor_location',
companyQuery: null,
categoryQuery: null,
isExhibitorList: true,
exhibitors,
candidates: [],
faqs: [],
refuseBooking: false,
needsClarification: false,
note: 'List exhibitors from DATA.exhibitors only. Invite asking about a company for booth details. Do not invent names.',
};
}
/**
* @param {{ intent: string, eventId: object|string, bot?: object, entities?: object }} opts
*/
async function fetchExhibitorData({ intent, eventId, bot = null, entities = {} } = {}) {
const intentKey = String(intent || '');
const companyHint = entityStr(entities, 'company_name');
const categoryHint = entityStr(entities, 'topic');
const base = {
intent: intentKey,
companyQuery: companyHint || null,
categoryQuery: categoryHint || null,
isExhibitorList: false,
exhibitors: [],
candidates: [],
faqs: [],
refuseBooking: false,
needsClarification: false,
note: null,
};
if (intentKey === 'exhibitor_location') {
if (!companyHint) {
return fetchExhibitorList(eventId);
}
const resolved = await resolveNamedOrClarify(eventId, companyHint);
Object.assign(base, resolved);
if (base.exhibitors[0] && !base.exhibitors[0].boothNumber && !base.exhibitors[0].hall) {
base.note =
'Booth/hall is not listed for this exhibitor. Share what is in DATA and suggest the help desk or floor plan.';
}
return base;
}
if (intentKey === 'exhibitor_by_category') {
if (!categoryHint && !companyHint) {
return {
...base,
needsClarification: true,
note: 'No category was provided. Ask which industry or category they want (e.g. MedTech, AI). Do not invent exhibitors.',
};
}
const hint = categoryHint || companyHint;
const docs = await findExhibitorsByCategory(eventId, hint, { limit: MAX_LIST });
const exhibitors = docs
.map((d) => serializePublicExhibitor(d, { descMax: LIST_DESC_MAX }))
.filter(Boolean)
.sort(sortByName);
base.categoryQuery = hint;
base.companyQuery = null;
base.isExhibitorList = exhibitors.length > 1;
base.exhibitors = exhibitors;
if (!exhibitors.length) {
base.note = `No published exhibitors matched category "${hint}". Suggest another category or ask for a company name.`;
} else {
base.note = 'List matching exhibitors with booth/hall from DATA only.';
}
return base;
}
if (intentKey === 'exhibitor_demo_schedule') {
const faqs = demoFaqsFromBot(bot);
base.faqs = faqs;
if (companyHint) {
const resolved = await resolveNamedOrClarify(eventId, companyHint);
Object.assign(base, {
exhibitors: resolved.exhibitors,
candidates: resolved.candidates,
needsClarification: resolved.needsClarification,
});
if (resolved.needsClarification) {
base.note = resolved.note;
return base;
}
}
if (faqs.length) {
base.note =
'Answer demo timing only from DATA.faqs. You may also share booth/hall from DATA.exhibitors if present. Never invent demo times.';
} else {
base.note =
`No demo schedule FAQ is configured. Do not invent times. Suggest visiting the booth${
base.exhibitors[0] ? ` (${base.exhibitors[0].name})` : ''
} or asking at the help desk. ${HELPDESK}`;
}
return base;
}
if (intentKey === 'exhibitor_book_meeting') {
base.refuseBooking = true;
base.note = BOOKING_REFUSE_NOTE;
if (companyHint) {
const resolved = await resolveNamedOrClarify(eventId, companyHint);
if (resolved.needsClarification) {
return { ...base, ...resolved, refuseBooking: true, note: resolved.note };
}
base.exhibitors = resolved.exhibitors;
if (!resolved.exhibitors.length) {
base.note = `${BOOKING_REFUSE_NOTE} Also: ${resolved.note}`;
}
}
return base;
}
return base;
}
module.exports = {
EXHIBITOR_INTENTS,
isExhibitorIntent,
fetchExhibitorData,
BOOKING_REFUSE_NOTE,
};

View File

@ -0,0 +1,274 @@
const crypto = require('crypto');
const Participant = require('../models/Participant');
const { sendConciergeOtpEmail } = require('../utils/mail');
const OTP_TTL_MS = 10 * 60 * 1000;
const EMAIL_RE = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i;
function extractEmail(value) {
const match = String(value || '').match(EMAIL_RE);
return match ? match[0].trim().toLowerCase() : '';
}
function normalizeEmail(value) {
return String(value || '').trim().toLowerCase();
}
function isFixedOtpMode() {
return String(process.env.CONCIERGE_OTP_MODE || 'dynamic').toLowerCase() === 'fixed';
}
function generateOtp() {
if (isFixedOtpMode()) {
const fixed = String(process.env.CONCIERGE_FIXED_OTP || '123123').replace(/\D/g, '');
return fixed.padStart(6, '0').slice(-6);
}
return String(crypto.randomInt(0, 1_000_000)).padStart(6, '0');
}
function hashOtp(otp, eventId, email) {
const pepper = process.env.OTP_PEPPER || 'eventbuzz-concierge-otp';
return crypto
.createHash('sha256')
.update(`${pepper}|${otp}|${eventId}|${email}`)
.digest('hex');
}
function isVerified(session) {
return Boolean(session?.participant && session?.identityVerifiedAt);
}
function clearAuthChallenge(session) {
if (typeof session.$unset === 'function') {
session.$unset('authState');
} else {
session.authState = undefined;
}
session.authEmail = null;
session.otpHash = null;
session.otpExpiresAt = null;
}
function clearPending(session) {
session.pendingIntent = null;
session.pendingMessage = null;
session.pendingEntities = null;
}
function clearIdentity(session) {
session.participant = null;
session.identityVerifiedAt = null;
session.identityMethod = null;
clearAuthChallenge(session);
clearPending(session);
}
function membershipForEvent(participant, eventId) {
const eid = String(eventId);
const rows = Array.isArray(participant?.eventMemberships) ? participant.eventMemberships : [];
return rows.find((m) => String(m.event) === eid) || null;
}
const AUTH_CANCEL_ONLY_RE =
/^(please\s+)?(ignore|cancel|skip|stop)(\s+(that|this|it))?([.!]*)?$|^(never mind|nevermind|forget it|not now|no thanks|no thank you|later)([.!]*)?$/i;
function isAuthCancelMessage(text) {
const t = String(text || '').trim();
if (!t || extractEmail(t)) return false;
if (AUTH_CANCEL_ONLY_RE.test(t)) return true;
return /\b(ignore|cancel|skip|never mind|nevermind|forget it|not now|stop asking)\b/i.test(t);
}
function isAuthCancelOnly(text) {
return AUTH_CANCEL_ONLY_RE.test(String(text || '').trim());
}
function looksLikeNewQuestionDuringAuth(text) {
const t = String(text || '').trim();
if (!t || extractEmail(t)) return false;
if (/^\d{4,8}$/.test(t.replace(/\s/g, ''))) return false;
if (t.includes('?')) return true;
return t.split(/\s+/).filter(Boolean).length >= 4;
}
async function beginEmailAuth(session, { intent, message, eventName, entities = null }) {
session.pendingIntent = intent || null;
session.pendingMessage = message || null;
session.pendingEntities = entities && typeof entities === 'object' ? entities : null;
clearAuthChallengeFieldsKeepPending(session);
session.authState = 'awaiting_email';
return {
reply: 'Send the email you registered with, or say cancel.',
classification: null,
usage: { inputTokens: 0, outputTokens: 0 },
modelId: null,
resumed: false,
};
}
function clearAuthChallengeFieldsKeepPending(session) {
session.authEmail = null;
session.otpHash = null;
session.otpExpiresAt = null;
}
async function sendOtpForEmail(session, { email, eventId, eventName }) {
const em = normalizeEmail(email);
const participant = await Participant.findOne({
tenantOrganization: session.organization,
email: em,
deletedAt: null,
}).lean();
if (!participant || !membershipForEvent(participant, eventId)) {
session.authState = 'awaiting_email';
clearAuthChallengeFieldsKeepPending(session);
return {
reply:
`I couldn’t find a registration for ${em} on this event. `
+ `Please use the email you registered with, or contact the organizers if you need help.`,
verified: false,
};
}
const otp = generateOtp();
session.authState = 'awaiting_otp';
session.authEmail = em;
session.otpHash = hashOtp(otp, String(eventId), em);
session.otpExpiresAt = new Date(Date.now() + OTP_TTL_MS);
if (isFixedOtpMode()) {
console.log(`[concierge OTP — fixed mode] to=${em} code=${otp}`);
return {
reply:
`I found your registration for ${em}. `
+ `This environment uses a fixed verification code — reply with that 6-digit code to continue.`,
verified: false,
};
}
const sent = await sendConciergeOtpEmail({ to: em, otp, eventName });
if (!sent) {
return {
reply: `I found your registration, but couldn’t send the verification email right now. Please try again in a moment.`,
verified: false,
};
}
return {
reply: `I sent a 6-digit code to ${em}. Please reply with that code to continue.`,
verified: false,
};
}
async function verifyOtpAndLink(session, { code, eventId }) {
const em = normalizeEmail(session.authEmail);
const digits = String(code || '').replace(/\D/g, '').slice(0, 6);
if (!em || digits.length !== 6) {
return {
reply: 'Please reply with the 6-digit verification code from your email.',
verified: false,
};
}
if (!session.otpHash || !session.otpExpiresAt || new Date(session.otpExpiresAt).getTime() < Date.now()) {
session.authState = 'awaiting_email';
clearAuthChallengeFieldsKeepPending(session);
return {
reply: 'That code has expired. Please send your registration email again so I can send a new code.',
verified: false,
};
}
const expected = hashOtp(digits, String(eventId), em);
if (expected !== session.otpHash) {
return {
reply: 'That code is incorrect. Please try again, or send your email again to get a new code.',
verified: false,
};
}
const participant = await Participant.findOne({
tenantOrganization: session.organization,
email: em,
deletedAt: null,
});
if (!participant || !membershipForEvent(participant, eventId)) {
session.authState = 'awaiting_email';
clearAuthChallengeFieldsKeepPending(session);
return {
reply: 'I couldn’t verify that registration anymore. Please send your registration email again.',
verified: false,
};
}
session.participant = participant._id;
session.identityVerifiedAt = new Date();
session.identityMethod = 'email';
clearAuthChallenge(session);
return { reply: null, verified: true, participant };
}
/**
* Handle an in-progress auth challenge turn.
* @returns {{ reply: string|null, verified: boolean, shouldResume: boolean }}
*/
async function handleAuthTurn(session, { message, eventId, eventName }) {
const text = String(message || '').trim();
const state = session.authState;
const cancelAuth = () => ({
reply: null,
verified: false,
shouldResume: false,
cancelled: true,
cancelOnly: isAuthCancelOnly(text),
});
if (state === 'awaiting_email') {
if (isAuthCancelMessage(text) || looksLikeNewQuestionDuringAuth(text)) {
return cancelAuth();
}
const email = extractEmail(text);
if (!email) {
return {
reply: 'Send the email you registered with, or say cancel.',
verified: false,
shouldResume: false,
};
}
const result = await sendOtpForEmail(session, { email, eventId, eventName });
return { ...result, shouldResume: false };
}
if (state === 'awaiting_otp') {
if (isAuthCancelMessage(text) || looksLikeNewQuestionDuringAuth(text)) {
return cancelAuth();
}
// Allow restarting with a new email
const maybeEmail = extractEmail(text);
if (maybeEmail && !/^\d{4,8}$/.test(text.replace(/\s/g, ''))) {
const result = await sendOtpForEmail(session, { email: maybeEmail, eventId, eventName });
return { ...result, shouldResume: false };
}
const result = await verifyOtpAndLink(session, { code: text, eventId });
if (!result.verified) {
return { ...result, shouldResume: false };
}
return { reply: null, verified: true, shouldResume: true };
}
return { reply: null, verified: isVerified(session), shouldResume: false };
}
module.exports = {
extractEmail,
isVerified,
isAuthCancelMessage,
beginEmailAuth,
handleAuthTurn,
clearIdentity,
clearAuthChallenge,
clearPending,
};

View File

@ -0,0 +1,245 @@
const mongoose = require('mongoose');
const Event = require('../models/Event');
const { filterPlacesForQuestion } = require('../utils/placeCategoryFilter');
const { faqAnswerText } = require('../utils/faqAnswer');
const LOCAL_INTENTS = new Set(['tourist_places', 'accommodation']);
const MAX_CACHE_LOAD = 40;
const MAX_ITEMS = 8;
const TEXT_MAX = 200;
const HELPDESK = 'Please ask at the venue help desk if you need more local tips.';
const FAQ_ANSWER_MAX = 600;
function accommodationFaqsFromBot(bot) {
const rows = Array.isArray(bot?.faqs) ? bot.faqs : [];
return rows
.filter((f) => {
const key = String(f?.key || '').trim();
const cat = String(f?.category || '').trim();
return (key === 'accommodation' || cat === 'accommodation') && faqAnswerText(f);
})
.slice(0, 2)
.map((f) => {
const q = String(f?.question || '').trim();
const a = faqAnswerText(f).trim();
return {
q: q.length > 200 ? `${q.slice(0, 200)}…` : q,
a: a.length > FAQ_ANSWER_MAX ? `${a.slice(0, FAQ_ANSWER_MAX)}…` : a,
};
})
.filter((f) => f.a);
}
function accommodationProvidedFromFaq(text) {
const t = String(text || '').toLowerCase();
if (!t) return null;
if (/\bno, book your own\b|\bbook your own stay\b|\bnot (provided|arranged)\b/.test(t)) {
return false;
}
if (/\bpartner hotel\b/.test(t)) return false;
if (
/\byes, arranged\b|\barranged by the organizer\b|\bselected attendees\b|\bprovided\b/.test(t)
) {
return true;
}
return null;
}
function isLocalIntent(intent) {
return LOCAL_INTENTS.has(String(intent || ''));
}
function trimText(value, max = TEXT_MAX) {
const s = String(value || '').trim();
if (!s) return null;
return s.length > max ? `${s.slice(0, max)}…` : s;
}
function mapsUrlFor(placeId, raw) {
const fromRaw =
trimText(raw?.google_maps_uri || raw?.googleMapsUri || raw?.mapsUrl || raw?.url, 400);
if (fromRaw) return fromRaw;
const id = String(placeId || '');
if (id.startsWith('ChIJ')) {
return `https://www.google.com/maps/place/?q=place_id:${encodeURIComponent(id)}`;
}
return null;
}
function normalizeTags(raw) {
const fromTags = Array.isArray(raw?.tags) ? raw.tags : [];
const fromTypes = Array.isArray(raw?.types) ? raw.types : [];
return [...fromTags, ...fromTypes]
.map((t) => String(t || '').replace(/_/g, ' ').trim())
.filter(Boolean)
.slice(0, 8);
}
function normalizePlace(raw, i) {
const id = String(raw?.place_id || raw?.id || raw?.placeId || `place-${i}`).trim();
if (!id) return null;
return {
id,
name: trimText(raw?.name, 120) || 'Place',
address: trimText(raw?.address || raw?.formatted_address || raw?.vicinity, TEXT_MAX),
rating: raw?.rating != null ? Number(raw.rating) : null,
distance:
raw?.distance_meters != null
? Number(raw.distance_meters)
: raw?.distance != null
? Number(raw.distance)
: null,
mapsUrl: mapsUrlFor(id, raw),
category: trimText(raw?.category || raw?.primaryType || raw?.type, 80),
tags: normalizeTags(raw),
};
}
function normalizeHotel(raw, i) {
const id = String(raw?.place_id || raw?.placeId || raw?.id || `hotel-${i}`).trim();
if (!id) return null;
return {
id,
name: trimText(raw?.name, 120) || 'Stay',
address: trimText(raw?.address || raw?.formatted_address || raw?.vicinity, TEXT_MAX),
rating: raw?.rating != null ? Number(raw.rating) : null,
distance:
raw?.distance_meters != null
? Number(raw.distance_meters)
: raw?.distance != null
? Number(raw.distance)
: null,
mapsUrl: mapsUrlFor(id, raw),
website: trimText(raw?.website, 400),
priceLevel: raw?.price_level != null ? Number(raw.price_level) : null,
tags: normalizeTags(raw),
};
}
async function loadTouristPlaces(eventId) {
try {
const coll = mongoose.connection.collection('concierge_places');
const doc = await coll.findOne({ eventId: String(eventId) });
if (!doc) return [];
const list = Array.isArray(doc.places)
? doc.places
: Array.isArray(doc.recommendations)
? doc.recommendations
: [];
return list.map(normalizePlace).filter(Boolean).slice(0, MAX_CACHE_LOAD);
} catch (err) {
console.warn('[localData] places cache read failed:', err?.message || err);
return [];
}
}
async function loadHotels(eventId) {
try {
const coll = mongoose.connection.collection('concierge_accommodation');
const doc = await coll.findOne({ eventId: String(eventId) });
if (!doc) return [];
const hotels = Array.isArray(doc.hotels) ? doc.hotels : [];
return hotels.map(normalizeHotel).filter(Boolean).slice(0, MAX_ITEMS);
} catch (err) {
console.warn('[localData] accommodation cache read failed:', err?.message || err);
return [];
}
}
/**
* @param {{ intent: string, eventId: object|string, message?: string, bot?: object }} opts
*/
async function fetchLocalData({ intent, eventId, message = '', bot = null } = {}) {
const intentKey = String(intent || '');
const event = await Event.findById(eventId)
.select('name location participantLogistics')
.lean();
const eventName = String(event?.name || '').trim() || null;
const location = String(event?.location || '').trim() || null;
const faqs = intentKey === 'accommodation' ? accommodationFaqsFromBot(bot) : [];
const faqProvided = faqs.length ? accommodationProvidedFromFaq(faqs.map((f) => f.a).join('\n')) : null;
const hostManaged =
faqProvided != null
? faqProvided
: Boolean(event?.participantLogistics?.hostManagedAccommodation);
const base = {
intent: intentKey,
eventName,
location,
hostManagedAccommodation: hostManaged,
faqs,
places: [],
hotels: [],
requestedCategory: null,
categoryNoMatch: null,
note: null,
};
if (intentKey === 'tourist_places') {
const allPlaces = await loadTouristPlaces(eventId);
const { requestedCategory, places: filtered } = filterPlacesForQuestion(allPlaces, message);
const places = filtered.slice(0, MAX_ITEMS);
base.places = places;
base.requestedCategory = requestedCategory
? { label: requestedCategory.label, broad: Boolean(requestedCategory.broad) }
: null;
if (!allPlaces.length) {
base.note = `No tourist places are cached for this event yet. Suggest the help desk or exploring near ${location || 'the venue'}. Do not invent attractions. ${HELPDESK}`;
return base;
}
if (requestedCategory && !places.length) {
base.categoryNoMatch =
requestedCategory.noMatch ||
`I couldn't find any ${requestedCategory.label} in the available recommendations for this event.`;
base.note = `The attendee asked for ${requestedCategory.label}, but none match in DATA.places (empty). Say so honestly. place_ids []. Do not invent or substitute other place types (e.g. churches for temples). ${HELPDESK}`;
return base;
}
if (requestedCategory && !requestedCategory.broad) {
base.note = `DATA.places are already filtered to ${requestedCategory.label}. Suggest only these. Set place_ids (up to 6). Do not substitute other categories.`;
} else {
base.note =
'Suggest sightseeing from DATA.places only. Set place_ids for useful cards (up to 6). Mention DATA.location when helpful.';
}
return base;
}
if (intentKey === 'accommodation') {
if (faqs.length) {
base.note =
'Answer from DATA.faqs only. Do not invent hotels or say accommodation is provided unless the FAQ says so.';
if (hostManaged) return base;
const hotels = await loadHotels(eventId);
base.hotels = hotels;
if (hotels.length) {
base.note +=
' You may also set hotel_ids from DATA.hotels if they asked for nearby stays.';
}
return base;
}
if (hostManaged) {
base.note =
'The organizer arranges accommodation. Tell the attendee to contact the event team for hotel, check-in dates, and room confirmation. Do not invent hotels. hotel_ids [].';
return base;
}
const hotels = await loadHotels(eventId);
base.hotels = hotels;
base.note = hotels.length
? 'Suggest stays from DATA.hotels only. Set hotel_ids for useful cards (up to 6). Remind them to verify rates/availability themselves.'
: `Accommodation is not arranged by the organizer, and no hotel cache is available. Suggest booking a stay near ${location || 'the venue'} and asking the help desk. Do not invent hotels. ${HELPDESK}`;
return base;
}
return base;
}
module.exports = {
LOCAL_INTENTS,
isLocalIntent,
fetchLocalData,
loadTouristPlaces,
loadHotels,
};

Some files were not shown because too many files have changed in this diff Show More