+ Step 1 only: messages are classified via Bedrock. The chat bubble shows a temporary intent summary;
+ the full JSON is in the /chat API response as classification.
+
+
+ Set a real ConciergeBot _id in window.EB_CONCIERGE.botId below.
+ Optional resume: ?session=<sessionId or mongo id>
+
+
+
+
+
+
+
diff --git a/public/embed/concierge-widget.css b/public/embed/concierge-widget.css
new file mode 100644
index 0000000..1fb6baf
--- /dev/null
+++ b/public/embed/concierge-widget.css
@@ -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%;
+}
diff --git a/public/embed/concierge-widget.js b/public/embed/concierge-widget.js
new file mode 100644
index 0000000..4df93ec
--- /dev/null
+++ b/public/embed/concierge-widget.js
@@ -0,0 +1,1216 @@
+(function () {
+ var cfg = window.EB_CONCIERGE || {};
+ var botId = cfg.botId;
+ var apiBase = String(cfg.apiBase || '').replace(/\/$/, '');
+ if (!botId || !apiBase) {
+ console.error('[EventBuzz Concierge] Missing window.EB_CONCIERGE.botId or apiBase');
+ return;
+ }
+
+ var sessionId = null;
+ var busy = false;
+ var quickReplies = [];
+ var quickRepliesEl = null;
+ var typingEl = null;
+ var voiceEnabled = false;
+ var voiceState = 'idle';
+ var voiceRoom = null;
+ var voiceConnecting = false;
+ var seenTranscriptIds = {};
+ var remoteAudioEls = [];
+
+ var FEEDBACK_THANKS_UP =
+ 'Thanks — glad that was helpful!';
+ var FEEDBACK_THANKS_DOWN =
+ 'Thanks for the feedback. We’ll use it to improve.';
+ var DEFAULT_ACCENT = 'rgb(13, 148, 136)';
+
+ function el(tag, attrs, children) {
+ var n = document.createElement(tag);
+ if (attrs) {
+ Object.keys(attrs).forEach(function (k) {
+ n.setAttribute(k, attrs[k]);
+ });
+ }
+ (children || []).forEach(function (c) {
+ n.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
+ });
+ return n;
+ }
+
+ var embedOrigin = apiBase.replace(/\/api$/, '');
+
+ function addCssOnce() {
+ if (document.getElementById('ebc-css')) return;
+ document.head.appendChild(
+ el('link', {
+ id: 'ebc-css',
+ rel: 'stylesheet',
+ href: embedOrigin + '/embed/concierge-widget.css?v=22',
+ }),
+ );
+ }
+
+ function parseAccentRgb(input) {
+ var s = String(input || '').trim();
+ if (!s) return null;
+ var hex = s.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
+ if (hex) {
+ var h = hex[1];
+ if (h.length === 3) {
+ h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
+ }
+ return {
+ r: parseInt(h.slice(0, 2), 16),
+ g: parseInt(h.slice(2, 4), 16),
+ b: parseInt(h.slice(4, 6), 16),
+ };
+ }
+ var rgb = s.match(/^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})(?:\s*,\s*[\d.]+\s*)?\)$/i);
+ if (rgb) {
+ var r = Number(rgb[1]);
+ var g = Number(rgb[2]);
+ var b = Number(rgb[3]);
+ if (r <= 255 && g <= 255 && b <= 255) return { r: r, g: g, b: b };
+ }
+ return null;
+ }
+
+ function mixWithWhite(channel, amount) {
+ return Math.round(channel + (255 - channel) * amount);
+ }
+
+ function applyAccentColor(color) {
+ var parsed = parseAccentRgb(color) || parseAccentRgb(DEFAULT_ACCENT);
+ if (!parsed || !root) return;
+ var r = parsed.r;
+ var g = parsed.g;
+ var b = parsed.b;
+ root.style.setProperty('--ebc-accent', 'rgb(' + r + ', ' + g + ', ' + b + ')');
+ root.style.setProperty('--ebc-accent-rgb', r + ', ' + g + ', ' + b);
+ root.style.setProperty(
+ '--ebc-user',
+ 'rgb(' + mixWithWhite(r, 0.88) + ', ' + mixWithWhite(g, 0.88) + ', ' + mixWithWhite(b, 0.88) + ')',
+ );
+ }
+
+ addCssOnce();
+
+ var root = el('div', { id: 'eventbuzz-concierge-root' });
+ applyAccentColor(cfg.accentColor || cfg.accent || DEFAULT_ACCENT);
+ var launcherLogo = el('img', {
+ class: 'ebc-launcher-logo',
+ src: embedOrigin + '/embed/logo.png',
+ alt: '',
+ width: '36',
+ height: '36',
+ });
+ var launcherLabel = el('span', { class: 'ebc-launcher-label' }, ['ask me']);
+ var launcher = el(
+ 'button',
+ { class: 'ebc-launcher', type: 'button', 'aria-label': 'Ask me' },
+ [launcherLogo, launcherLabel],
+ );
+ var panel = el('div', { class: 'ebc-panel', role: 'dialog', 'aria-label': 'Your virtual concierge' });
+ var header = el('div', { class: 'ebc-header' });
+ var headerBrand = el('div', { class: 'ebc-header-brand' });
+ var headerLogo = el('img', {
+ class: 'ebc-header-logo',
+ src: embedOrigin + '/embed/logo.png',
+ alt: '',
+ width: '32',
+ height: '32',
+ });
+ var title = el('div', { class: 'ebc-title' }, ['Your virtual concierge']);
+ headerBrand.appendChild(headerLogo);
+ headerBrand.appendChild(title);
+ var headerActions = el('div', { class: 'ebc-header-actions' });
+ var endVoiceBtn = el(
+ 'button',
+ {
+ class: 'ebc-end-voice ebc-hidden',
+ type: 'button',
+ 'aria-label': 'End voice',
+ title: 'End voice',
+ },
+ ['End voice'],
+ );
+ var closeBtn = el('button', { class: 'ebc-close', type: 'button', 'aria-label': 'Close chat' }, ['×']);
+ headerActions.appendChild(endVoiceBtn);
+ headerActions.appendChild(closeBtn);
+ header.appendChild(headerBrand);
+ header.appendChild(headerActions);
+
+ var messages = el('div', { class: 'ebc-messages' });
+ var errorEl = el('div', { class: 'ebc-error' });
+ var inputBar = el('div', { class: 'ebc-inputbar' });
+ var input = el('input', {
+ class: 'ebc-input',
+ placeholder: 'Ask a question…',
+ maxlength: '800',
+ });
+ // WhatsApp-style paper-plane send icon
+ var sendIcon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
+ sendIcon.setAttribute('class', 'ebc-send-icon');
+ sendIcon.setAttribute('viewBox', '0 0 24 24');
+ sendIcon.setAttribute('width', '22');
+ sendIcon.setAttribute('height', '22');
+ sendIcon.setAttribute('aria-hidden', 'true');
+ var sendPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
+ sendPath.setAttribute('fill', 'currentColor');
+ sendPath.setAttribute(
+ 'd',
+ 'M1.101 21.757L23.8 12.028 1.101 2.3l.011 7.912 13.623 1.816-13.623 1.817-.011 7.912z',
+ );
+ sendIcon.appendChild(sendPath);
+ var sendBtn = el('button', {
+ class: 'ebc-send',
+ type: 'button',
+ 'aria-label': 'Send',
+ title: 'Send',
+ });
+ sendBtn.appendChild(sendIcon);
+ var micIcon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
+ micIcon.setAttribute('viewBox', '0 0 24 24');
+ micIcon.setAttribute('width', '20');
+ micIcon.setAttribute('height', '20');
+ micIcon.setAttribute('aria-hidden', 'true');
+ var micPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
+ micPath.setAttribute('fill', 'currentColor');
+ micPath.setAttribute(
+ 'd',
+ 'M12 14a3 3 0 0 0 3-3V6a3 3 0 1 0-6 0v5a3 3 0 0 0 3 3zm5-3a5 5 0 0 1-10 0H5a7 7 0 0 0 6 6.93V21h2v-3.07A7 7 0 0 0 19 11h-2z',
+ );
+ micIcon.appendChild(micPath);
+ var micBtn = el('button', {
+ class: 'ebc-mic ebc-hidden',
+ type: 'button',
+ 'aria-label': 'Start voice',
+ title: 'Start voice',
+ });
+ micBtn.appendChild(micIcon);
+ inputBar.appendChild(input);
+ inputBar.appendChild(micBtn);
+ inputBar.appendChild(sendBtn);
+
+ panel.appendChild(header);
+ panel.appendChild(messages);
+ panel.appendChild(errorEl);
+ panel.appendChild(inputBar);
+ root.appendChild(panel);
+ root.appendChild(launcher);
+ document.body.appendChild(root);
+
+ function setError(text) {
+ errorEl.textContent = text || '';
+ }
+
+ var EVENT_TZ = 'Asia/Kolkata';
+
+ function formatWhen(iso, timeZone) {
+ if (!iso) return '';
+ try {
+ return new Date(iso).toLocaleString('en-IN', {
+ timeZone: timeZone || EVENT_TZ,
+ weekday: 'short',
+ month: 'short',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+ hour12: true,
+ });
+ } catch (e) {
+ return String(iso);
+ }
+ }
+
+ function formatTimeOnly(iso, timeZone) {
+ if (!iso) return '';
+ try {
+ return new Date(iso).toLocaleTimeString('en-IN', {
+ timeZone: timeZone || EVENT_TZ,
+ hour: 'numeric',
+ minute: '2-digit',
+ hour12: true,
+ });
+ } catch (e) {
+ return '';
+ }
+ }
+
+ function sameDayInTz(startIso, endIso, timeZone) {
+ try {
+ var opts = { timeZone: timeZone || EVENT_TZ, year: 'numeric', month: '2-digit', day: '2-digit' };
+ return (
+ new Date(startIso).toLocaleDateString('en-CA', opts) ===
+ new Date(endIso).toLocaleDateString('en-CA', opts)
+ );
+ } catch (e) {
+ return false;
+ }
+ }
+
+ function formatTimeRange(startIso, endIso, timeZone) {
+ var tz = timeZone || EVENT_TZ;
+ if (!startIso && !endIso) return '';
+ if (startIso && endIso) {
+ try {
+ if (sameDayInTz(startIso, endIso, tz)) {
+ return formatWhen(startIso, tz) + ' – ' + formatTimeOnly(endIso, tz);
+ }
+ return formatWhen(startIso, tz) + ' – ' + formatWhen(endIso, tz);
+ } catch (e) {
+ return formatWhen(startIso, tz);
+ }
+ }
+ return formatWhen(startIso || endIso, tz);
+ }
+
+ /** Format topic duration for cards: "5" / "5 min" → "5m"; leave other values as-is. */
+ function formatTopicDuration(duration) {
+ var raw = String(duration == null ? '' : duration).trim();
+ if (!raw) return '';
+ var match = raw.match(/^(\d+(?:\s*\+\s*\d+)?)\s*(m|min|mins|minutes)?$/i);
+ if (match) return match[1].replace(/\s+/g, '') + 'm';
+ return raw;
+ }
+
+ /** Topic line: "Title (5m)" and optional " · Role". */
+ function formatTopicLine(t) {
+ if (!t) return '';
+ var title = String(t.title || '').trim();
+ if (!title) return '';
+ var dur = formatTopicDuration(t.duration);
+ var role = String(t.role || '').trim();
+ var line = title;
+ if (dur) line += ' (' + dur + ')';
+ if (role) line += ' · ' + role;
+ return line;
+ }
+
+ function buildCtaCard(data) {
+ var title = String((data && data.title) || 'Register').trim() || 'Register';
+ var action = title;
+ var url = String((data && data.url) || '').trim();
+ var card = el('article', { class: 'ebc-cta-card' });
+ if (!url) return card;
+ card.appendChild(
+ el(
+ 'a',
+ {
+ class: 'ebc-cta-btn',
+ href: url,
+ target: '_blank',
+ rel: 'noopener noreferrer',
+ },
+ [action],
+ ),
+ );
+ return card;
+ }
+
+ function buildFileCard(data) {
+ var card = el('article', { class: 'ebc-file-card' });
+ var title = String((data && data.title) || 'Download').trim() || 'Download';
+ var url = String((data && data.url) || '').trim();
+ var mediaType = String((data && data.mediaType) || '').toLowerCase();
+ card.appendChild(el('div', { class: 'ebc-file-title' }, [title]));
+
+ if (url && mediaType === 'image') {
+ var img = el('img', {
+ class: 'ebc-file-preview',
+ src: url,
+ alt: title,
+ loading: 'lazy',
+ });
+ card.appendChild(img);
+ } else if (url && mediaType === 'pdf') {
+ card.appendChild(
+ el('div', { class: 'ebc-file-meta' }, ['PDF document']),
+ );
+ }
+
+ if (url) {
+ var actions = el('div', { class: 'ebc-file-actions' });
+ if (mediaType === 'image' || mediaType === 'pdf') {
+ actions.appendChild(
+ el(
+ 'a',
+ {
+ class: 'ebc-file-btn ebc-file-view',
+ href: url,
+ target: '_blank',
+ rel: 'noopener noreferrer',
+ },
+ [mediaType === 'pdf' ? 'Open PDF' : 'View'],
+ ),
+ );
+ }
+ actions.appendChild(
+ el(
+ 'a',
+ {
+ class: 'ebc-file-btn ebc-file-download',
+ href: url,
+ target: '_blank',
+ rel: 'noopener noreferrer',
+ download: title,
+ },
+ ['Download'],
+ ),
+ );
+ card.appendChild(actions);
+ }
+ return card;
+ }
+
+ function buildSessionCard(data) {
+ var card = el('article', { class: 'ebc-session-card' });
+ card.appendChild(el('div', { class: 'ebc-session-title' }, [data.title || 'Session']));
+
+ var range =
+ data.timeRangeLocal ||
+ formatTimeRange(data.startTime, data.endTime, data.timeZone || EVENT_TZ);
+ if (range) {
+ card.appendChild(el('div', { class: 'ebc-session-meta ebc-session-time' }, [range]));
+ }
+
+ var trackBits = [];
+ if (data.track) trackBits.push(data.track);
+ if (data.trackHall) trackBits.push(data.trackHall);
+ if (trackBits.length) {
+ card.appendChild(
+ el('div', { class: 'ebc-session-meta ebc-session-track' }, ['Track: ' + trackBits.join(' · ')]),
+ );
+ }
+
+ if (data.location) {
+ card.appendChild(el('div', { class: 'ebc-session-meta' }, ['Location: ' + data.location]));
+ }
+
+ if (Array.isArray(data.speakers) && data.speakers.length) {
+ card.appendChild(
+ el('div', { class: 'ebc-session-speakers' }, ['Speakers: ' + data.speakers.slice(0, 4).join(', ')]),
+ );
+ }
+
+ if (Array.isArray(data.topics) && data.topics.length) {
+ var topicsWrap = el('div', { class: 'ebc-session-topics' });
+ topicsWrap.appendChild(el('div', { class: 'ebc-session-topics-label' }, ['Topics']));
+ var ul = el('ul', { class: 'ebc-session-topics-list' });
+ data.topics.slice(0, 8).forEach(function (t) {
+ var line = formatTopicLine(t);
+ if (line) ul.appendChild(el('li', {}, [line]));
+ });
+ topicsWrap.appendChild(ul);
+ card.appendChild(topicsWrap);
+ }
+
+ if (Array.isArray(data.resources) && data.resources.length) {
+ var links = el('div', { class: 'ebc-session-resources' });
+ data.resources.slice(0, 4).forEach(function (r) {
+ if (!r || !r.url) return;
+ links.appendChild(
+ el(
+ 'a',
+ {
+ class: 'ebc-session-resource',
+ href: String(r.url),
+ target: '_blank',
+ rel: 'noopener noreferrer',
+ },
+ [String(r.name || 'Download')],
+ ),
+ );
+ });
+ if (links.childNodes.length) card.appendChild(links);
+ }
+ return card;
+ }
+
+ function buildPlaceCard(data, opts) {
+ opts = opts || {};
+ var isHotel = opts.kind === 'accommodation';
+ var card = el('article', {
+ class: 'ebc-place-card' + (isHotel ? ' ebc-accommodation-card' : ''),
+ });
+ if (isHotel) {
+ card.appendChild(el('div', { class: 'ebc-place-badge' }, ['Hotel']));
+ }
+ card.appendChild(el('div', { class: 'ebc-place-title' }, [data.name || (isHotel ? 'Stay' : 'Place')]));
+ if (data.address) {
+ card.appendChild(el('div', { class: 'ebc-place-meta' }, [String(data.address)]));
+ }
+ var bits = [];
+ if (data.rating != null && data.rating !== '') bits.push('★ ' + String(data.rating));
+ if (data.distance != null && data.distance !== '') {
+ var m = Number(data.distance);
+ bits.push(Number.isFinite(m) ? (m >= 1000 ? (m / 1000).toFixed(1) + ' km' : Math.round(m) + ' m') : String(data.distance));
+ }
+ if (bits.length) card.appendChild(el('div', { class: 'ebc-place-meta' }, [bits.join(' · ')]));
+ if (data.mapsUrl) {
+ card.appendChild(
+ el(
+ 'a',
+ {
+ class: 'ebc-place-link',
+ href: String(data.mapsUrl),
+ target: '_blank',
+ rel: 'noopener noreferrer',
+ },
+ ['Open map'],
+ ),
+ );
+ }
+ if (isHotel && data.website) {
+ card.appendChild(
+ el(
+ 'a',
+ {
+ class: 'ebc-place-link',
+ href: String(data.website),
+ target: '_blank',
+ rel: 'noopener noreferrer',
+ },
+ ['Website'],
+ ),
+ );
+ }
+ return card;
+ }
+
+ function buildSpeakerCard(data) {
+ var card = el('article', { class: 'ebc-speaker-card' });
+ var head = el('div', { class: 'ebc-speaker-head' });
+ if (data.photo) {
+ head.appendChild(
+ el('img', {
+ class: 'ebc-speaker-photo',
+ src: String(data.photo),
+ alt: String(data.name || 'Speaker'),
+ width: '48',
+ height: '48',
+ }),
+ );
+ }
+ var info = el('div', { class: 'ebc-speaker-info' });
+ info.appendChild(el('div', { class: 'ebc-speaker-name' }, [data.name || 'Speaker']));
+ var roleBits = [];
+ if (data.speakerRole) roleBits.push(String(data.speakerRole));
+ if (data.title) roleBits.push(String(data.title));
+ if (data.organizationName) roleBits.push(String(data.organizationName));
+ if (roleBits.length) {
+ info.appendChild(el('div', { class: 'ebc-speaker-meta' }, [roleBits.join(' · ')]));
+ }
+ head.appendChild(info);
+ card.appendChild(head);
+
+ if (data.bio) {
+ var bio = String(data.bio);
+ if (bio.length > 280) bio = bio.slice(0, 277) + '…';
+ card.appendChild(el('div', { class: 'ebc-speaker-bio' }, [bio]));
+ }
+
+ var links = el('div', { class: 'ebc-speaker-links' });
+ [
+ { key: 'linkedin', label: 'LinkedIn' },
+ { key: 'twitter', label: 'Twitter/X' },
+ { key: 'website', label: 'Website' },
+ ].forEach(function (item) {
+ var href = data[item.key];
+ if (!href) return;
+ links.appendChild(
+ el(
+ 'a',
+ {
+ class: 'ebc-speaker-link',
+ href: String(href),
+ target: '_blank',
+ rel: 'noopener noreferrer',
+ },
+ [item.label],
+ ),
+ );
+ });
+ if (links.childNodes.length) card.appendChild(links);
+ return card;
+ }
+
+ function buildExhibitorCard(data) {
+ var card = el('article', { class: 'ebc-exhibitor-card' });
+ var head = el('div', { class: 'ebc-exhibitor-head' });
+ if (data.logo) {
+ head.appendChild(
+ el('img', {
+ class: 'ebc-exhibitor-logo',
+ src: String(data.logo),
+ alt: String(data.name || 'Exhibitor'),
+ width: '48',
+ height: '48',
+ }),
+ );
+ }
+ var info = el('div', { class: 'ebc-exhibitor-info' });
+ info.appendChild(el('div', { class: 'ebc-exhibitor-name' }, [data.name || 'Exhibitor']));
+ var metaBits = [];
+ if (data.boothNumber) metaBits.push('Booth ' + String(data.boothNumber));
+ if (data.hall) metaBits.push(String(data.hall));
+ if (data.category) metaBits.push(String(data.category));
+ if (metaBits.length) {
+ info.appendChild(el('div', { class: 'ebc-exhibitor-meta' }, [metaBits.join(' · ')]));
+ }
+ head.appendChild(info);
+ card.appendChild(head);
+
+ if (data.shortDescription) {
+ var desc = String(data.shortDescription);
+ if (desc.length > 220) desc = desc.slice(0, 217) + '…';
+ card.appendChild(el('div', { class: 'ebc-exhibitor-desc' }, [desc]));
+ }
+
+ var links = el('div', { class: 'ebc-exhibitor-links' });
+ [
+ { key: 'website', label: 'Website' },
+ { key: 'linkedin', label: 'LinkedIn' },
+ { key: 'twitter', label: 'Twitter/X' },
+ { key: 'instagram', label: 'Instagram' },
+ ].forEach(function (item) {
+ var href = data[item.key];
+ if (!href) return;
+ links.appendChild(
+ el(
+ 'a',
+ {
+ class: 'ebc-exhibitor-link',
+ href: String(href),
+ target: '_blank',
+ rel: 'noopener noreferrer',
+ },
+ [item.label],
+ ),
+ );
+ });
+ if (links.childNodes.length) card.appendChild(links);
+ return card;
+ }
+
+ function buildTicketQrCard(data) {
+ var card = el('article', { class: 'ebc-ticket-qr-card' });
+ card.appendChild(el('div', { class: 'ebc-ticket-qr-title' }, [data.name || 'Your ticket']));
+ var metaBits = [];
+ if (data.ticketName) metaBits.push(String(data.ticketName));
+ if (data.status) metaBits.push(String(data.status));
+ if (metaBits.length) {
+ card.appendChild(el('div', { class: 'ebc-ticket-qr-meta' }, [metaBits.join(' · ')]));
+ }
+ if (data.imageUrl) {
+ card.appendChild(
+ el('img', {
+ class: 'ebc-ticket-qr-img',
+ src: String(data.imageUrl),
+ alt: 'Check-in QR code',
+ width: '200',
+ height: '200',
+ }),
+ );
+ card.appendChild(
+ el('div', { class: 'ebc-ticket-qr-hint' }, ['Show this QR at check-in']),
+ );
+
+ var downloadHref =
+ data.participantId
+ ? embedOrigin +
+ '/embed/participant-check-in-qr/' +
+ encodeURIComponent(String(data.participantId)) +
+ '.png?download=1'
+ : String(data.imageUrl);
+ var actions = el('div', { class: 'ebc-ticket-qr-actions' });
+ var downloadBtn = el(
+ 'a',
+ {
+ class: 'ebc-ticket-qr-download',
+ href: downloadHref,
+ download: 'check-in-qr.png',
+ target: '_blank',
+ rel: 'noopener noreferrer',
+ },
+ ['Download QR'],
+ );
+ var emailBtn = el(
+ 'button',
+ { class: 'ebc-ticket-qr-email', type: 'button' },
+ ['Send me Email'],
+ );
+ emailBtn.addEventListener('click', function () {
+ if (busy) return;
+ clearQuickReplies();
+ input.value = 'Email me my ticket QR code';
+ sendMessage();
+ });
+ actions.appendChild(downloadBtn);
+ actions.appendChild(emailBtn);
+ card.appendChild(actions);
+ }
+ return card;
+ }
+
+ function scrollToElementTop(targetEl) {
+ if (!targetEl || !messages.contains(targetEl)) return;
+ var pad = 8;
+ var delta = targetEl.getBoundingClientRect().top - messages.getBoundingClientRect().top - pad;
+ messages.scrollTop += delta;
+ }
+
+ function showTyping() {
+ hideTyping();
+ typingEl = el('div', {
+ class: 'ebc-msg bot ebc-typing',
+ 'aria-live': 'polite',
+ 'aria-label': 'Concierge is typing',
+ });
+ var dots = el('div', { class: 'ebc-typing-dots' });
+ dots.appendChild(el('span', { class: 'ebc-typing-dot' }));
+ dots.appendChild(el('span', { class: 'ebc-typing-dot' }));
+ dots.appendChild(el('span', { class: 'ebc-typing-dot' }));
+ typingEl.appendChild(dots);
+ messages.appendChild(typingEl);
+ messages.scrollTop = messages.scrollHeight;
+ }
+
+ function hideTyping() {
+ if (typingEl && typingEl.parentNode) {
+ typingEl.parentNode.removeChild(typingEl);
+ }
+ typingEl = null;
+ }
+
+ function addEphemeralBotMessage(text) {
+ var bubble = el('div', { class: 'ebc-msg bot ebc-ephemeral' });
+ bubble.appendChild(document.createTextNode(String(text || '')));
+ messages.appendChild(bubble);
+ }
+
+ function buildFeedbackFooter(messageId) {
+ var footer = el('div', { class: 'ebc-feedback' });
+ footer.appendChild(
+ el('span', { class: 'ebc-feedback-label' }, ['Was this reply useful?']),
+ );
+ var actions = el('div', { class: 'ebc-feedback-actions' });
+ var upBtn = el(
+ 'button',
+ {
+ class: 'ebc-feedback-btn',
+ type: 'button',
+ 'aria-label': 'Yes, useful',
+ title: 'Useful',
+ },
+ ['👍'],
+ );
+ var downBtn = el(
+ 'button',
+ {
+ class: 'ebc-feedback-btn',
+ type: 'button',
+ 'aria-label': 'Not useful',
+ title: 'Not useful',
+ },
+ ['👎'],
+ );
+
+ function lockButtons() {
+ upBtn.disabled = true;
+ downBtn.disabled = true;
+ }
+
+ async function submitFeedback(satisfied) {
+ if (!sessionId || !messageId) return;
+ lockButtons();
+ if (footer.parentNode) footer.parentNode.removeChild(footer);
+ addEphemeralBotMessage(satisfied ? FEEDBACK_THANKS_UP : FEEDBACK_THANKS_DOWN);
+ try {
+ await api('/public/concierge/' + encodeURIComponent(botId) + '/feedback', {
+ method: 'POST',
+ body: { sessionId: sessionId, messageId: messageId, satisfied: satisfied },
+ });
+ } catch (err) {
+ // UI already updated; feedback persist is best-effort
+ console.warn('[EventBuzz Concierge] feedback failed:', err && err.message);
+ }
+ }
+
+ upBtn.addEventListener('click', function () {
+ submitFeedback(true);
+ });
+ downBtn.addEventListener('click', function () {
+ submitFeedback(false);
+ });
+ actions.appendChild(upBtn);
+ actions.appendChild(downBtn);
+ footer.appendChild(actions);
+ return footer;
+ }
+
+ function addMessage(role, text, opts) {
+ opts = opts || {};
+ var cards = Array.isArray(opts.cards) ? opts.cards : [];
+ var scrollMode = opts.scroll || 'bottom';
+ var bubble = el('div', {
+ class:
+ 'ebc-msg ' +
+ (role === 'user' ? 'user' : 'bot') +
+ (cards.length ? ' ebc-has-cards' : '') +
+ (opts.ephemeral ? ' ebc-ephemeral' : ''),
+ });
+ if (role === 'bot' || role === 'assistant') {
+ if (text) {
+ bubble.appendChild(
+ el('div', { class: cards.length || opts.messageId ? 'ebc-intro' : '' }, [String(text)]),
+ );
+ }
+ if (cards.length) {
+ var list = el('div', { class: 'ebc-cards-list' });
+ cards.forEach(function (card) {
+ if (!card) return;
+ var data = card.data || {};
+ if (card.type === 'session') list.appendChild(buildSessionCard(data));
+ else if (card.type === 'speaker') list.appendChild(buildSpeakerCard(data));
+ else if (card.type === 'exhibitor') list.appendChild(buildExhibitorCard(data));
+ else if (card.type === 'file') list.appendChild(buildFileCard(data));
+ else if (card.type === 'cta') list.appendChild(buildCtaCard(data));
+ else if (card.type === 'ticket_qr') {
+ list.appendChild(buildTicketQrCard(data));
+ } else if (card.type === 'accommodation') {
+ list.appendChild(buildPlaceCard(data, { kind: 'accommodation' }));
+ } else if (card.type === 'place' || data.placeId || (data.name && data.address && !data.sessionId)) {
+ list.appendChild(buildPlaceCard(data));
+ }
+ });
+ if (list.childNodes.length) bubble.appendChild(list);
+ } else if (!text) {
+ bubble.appendChild(document.createTextNode(''));
+ }
+ if (opts.messageId) {
+ bubble.appendChild(buildFeedbackFooter(String(opts.messageId)));
+ }
+ } else {
+ bubble.appendChild(document.createTextNode(String(text || '')));
+ }
+ messages.appendChild(bubble);
+ if (scrollMode === 'bottom') {
+ messages.scrollTop = messages.scrollHeight;
+ }
+ return bubble;
+ }
+
+ function clearQuickReplies() {
+ if (quickRepliesEl && quickRepliesEl.parentNode) {
+ quickRepliesEl.parentNode.removeChild(quickRepliesEl);
+ }
+ quickRepliesEl = null;
+ }
+
+ function renderQuickReplies(items) {
+ clearQuickReplies();
+ if (!items || !items.length) return;
+ quickRepliesEl = el('div', {
+ class: 'ebc-quick-replies',
+ role: 'group',
+ 'aria-label': 'Suggested questions',
+ });
+ items.forEach(function (item) {
+ var label = String((item && item.label) || '').trim();
+ var message = String((item && item.message) || label).trim();
+ if (!label || !message) return;
+ var chip = el('button', { class: 'ebc-chip', type: 'button' }, [label]);
+ chip.addEventListener('click', function () {
+ if (busy) return;
+ clearQuickReplies();
+ input.value = message;
+ sendMessage();
+ });
+ quickRepliesEl.appendChild(chip);
+ });
+ if (!quickRepliesEl.childNodes.length) {
+ quickRepliesEl = null;
+ return;
+ }
+ messages.appendChild(quickRepliesEl);
+ messages.scrollTop = messages.scrollHeight;
+ }
+
+ function setOpen(open) {
+ if (open) root.classList.add('ebc-open');
+ else root.classList.remove('ebc-open');
+ }
+
+ function setVoiceState(next) {
+ voiceState = next;
+ root.classList.remove(
+ 'ebc-voice-idle',
+ 'ebc-voice-connecting',
+ 'ebc-voice-listening',
+ 'ebc-voice-speaking',
+ 'ebc-voice-error',
+ );
+ root.classList.add('ebc-voice-' + next);
+ micBtn.classList.toggle('ebc-hidden', !voiceEnabled);
+ micBtn.classList.toggle('is-active', next === 'listening' || next === 'speaking' || next === 'connecting');
+ micBtn.classList.toggle('is-listening', next === 'listening');
+ micBtn.classList.toggle('is-speaking', next === 'speaking');
+ micBtn.classList.toggle('is-connecting', next === 'connecting');
+ micBtn.disabled = next === 'connecting' || busy;
+ var live = next === 'listening' || next === 'speaking' || next === 'connecting';
+ micBtn.setAttribute('aria-label', live ? 'End voice' : 'Start voice');
+ micBtn.title = live ? 'End voice' : 'Start voice';
+ endVoiceBtn.classList.toggle('ebc-hidden', !live);
+ if (next === 'listening') input.setAttribute('placeholder', 'Listening… you can still type');
+ else if (next === 'speaking') input.setAttribute('placeholder', 'Concierge is speaking…');
+ else if (next === 'connecting') input.setAttribute('placeholder', 'Starting voice…');
+ else input.setAttribute('placeholder', 'Ask a question…');
+ }
+
+ function rememberTranscript(id) {
+ if (!id) return false;
+ if (seenTranscriptIds[id]) return true;
+ seenTranscriptIds[id] = true;
+ return false;
+ }
+
+ function appendVoiceTranscript(role, text, id) {
+ var cleaned = String(text || '').trim();
+ if (!cleaned) return;
+ if (rememberTranscript(id || role + ':' + cleaned)) return;
+ addMessage(role === 'user' ? 'user' : 'bot', cleaned);
+ }
+
+ function loadLivekitClient() {
+ if (window.LivekitClient) return Promise.resolve(window.LivekitClient);
+ if (window.livekit) return Promise.resolve(window.livekit);
+ if (window.__ebcLivekitPromise) return window.__ebcLivekitPromise;
+ window.__ebcLivekitPromise = new Promise(function (resolve, reject) {
+ var script = document.createElement('script');
+ script.src = 'https://cdn.jsdelivr.net/npm/livekit-client@2.15.14/dist/livekit-client.umd.js';
+ script.async = true;
+ script.onload = function () {
+ var lib = window.LivekitClient || window.livekit;
+ if (!lib || !lib.Room) {
+ reject(new Error('Voice client failed to load'));
+ return;
+ }
+ resolve(lib);
+ };
+ script.onerror = function () {
+ window.__ebcLivekitPromise = null;
+ reject(new Error('Could not load voice client'));
+ };
+ document.head.appendChild(script);
+ });
+ return window.__ebcLivekitPromise;
+ }
+
+ function clearRemoteAudio() {
+ remoteAudioEls.forEach(function (el) {
+ try {
+ if (el && el.srcObject) el.srcObject = null;
+ if (el && el.parentNode) el.parentNode.removeChild(el);
+ } catch (e) {
+ /* ignore */
+ }
+ });
+ remoteAudioEls = [];
+ }
+
+ function attachRemoteAudio(track) {
+ if (!track) return;
+ var kind = track.kind || (track.source && String(track.source));
+ if (kind && String(kind).toLowerCase() !== 'audio') return;
+ var el = track.attach ? track.attach() : null;
+ if (!el) return;
+ el.autoplay = true;
+ el.playsInline = true;
+ el.setAttribute('playsinline', 'true');
+ el.style.display = 'none';
+ document.body.appendChild(el);
+ remoteAudioEls.push(el);
+ var play = el.play && el.play();
+ if (play && play.catch) play.catch(function () {});
+ }
+
+ async function stopVoice() {
+ var room = voiceRoom;
+ voiceRoom = null;
+ voiceConnecting = false;
+ clearRemoteAudio();
+ if (room) {
+ try {
+ await room.disconnect();
+ } catch (e) {
+ /* ignore */
+ }
+ }
+ setVoiceState('idle');
+ }
+
+ async function startVoice() {
+ if (!voiceEnabled || voiceConnecting || voiceState === 'connecting') return;
+ if (voiceRoom) {
+ await stopVoice();
+ return;
+ }
+ setError('');
+ voiceConnecting = true;
+ setVoiceState('connecting');
+ try {
+ var Livekit = await loadLivekitClient();
+ var granted = await api('/public/concierge/' + encodeURIComponent(botId) + '/voice/token', {
+ method: 'POST',
+ body: { sessionId: sessionId || undefined },
+ });
+ sessionId = granted.sessionId || sessionId;
+ if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
+ throw new Error('This browser cannot access the microphone');
+ }
+ await navigator.mediaDevices.getUserMedia({ audio: true }).then(function (preview) {
+ preview.getTracks().forEach(function (track) {
+ track.stop();
+ });
+ });
+
+ var MicSource =
+ Livekit.Track && Livekit.Track.Source && Livekit.Track.Source.Microphone;
+ var room = new Livekit.Room({
+ adaptiveStream: false,
+ dynacast: false,
+ audioCaptureDefaults: {
+ echoCancellation: true,
+ noiseSuppression: true,
+ autoGainControl: true,
+ },
+ });
+ room.on(Livekit.RoomEvent.Disconnected, function () {
+ if (voiceRoom === room) {
+ voiceRoom = null;
+ voiceConnecting = false;
+ setVoiceState('idle');
+ }
+ });
+ if (Livekit.RoomEvent.MediaDevicesError) {
+ room.on(Livekit.RoomEvent.MediaDevicesError, function (err) {
+ setError((err && err.message) || 'Microphone error');
+ });
+ }
+ room.on(Livekit.RoomEvent.ActiveSpeakersChanged, function (speakers) {
+ if (voiceRoom !== room || voiceState === 'connecting') return;
+ var localId = room.localParticipant && room.localParticipant.identity;
+ var agentSpeaking = (speakers || []).some(function (p) {
+ return p && p.identity && p.identity !== localId;
+ });
+ setVoiceState(agentSpeaking ? 'speaking' : 'listening');
+ });
+ room.on(Livekit.RoomEvent.TranscriptionReceived, function (segments, participant) {
+ if (voiceRoom !== room) return;
+ var localId = room.localParticipant && room.localParticipant.identity;
+ var isUser = participant && participant.identity === localId;
+ (segments || []).forEach(function (seg) {
+ if (!seg || !seg.final) return;
+ appendVoiceTranscript(isUser ? 'user' : 'bot', seg.text, seg.id);
+ });
+ });
+ room.on(Livekit.RoomEvent.TrackSubscribed, function (track, publication, participant) {
+ console.info('[ebc-voice] subscribed', track && track.kind, participant && participant.identity);
+ attachRemoteAudio(track);
+ });
+ if (Livekit.RoomEvent.LocalTrackPublished) {
+ room.on(Livekit.RoomEvent.LocalTrackPublished, function (pub) {
+ console.info('[ebc-voice] mic published', pub && pub.trackSid, pub && pub.source);
+ });
+ }
+ await room.connect(granted.url, granted.token);
+ if (typeof room.startAudio === 'function') {
+ await room.startAudio().catch(function () {});
+ }
+ room.remoteParticipants.forEach(function (participant) {
+ participant.trackPublications.forEach(function (pub) {
+ if (pub && pub.track) attachRemoteAudio(pub.track);
+ });
+ });
+ var micPub = null;
+ if (typeof room.localParticipant.setMicrophoneEnabled === 'function') {
+ micPub = await room.localParticipant.setMicrophoneEnabled(true);
+ }
+ if (!micPub && typeof Livekit.createLocalAudioTrack === 'function') {
+ var micTrack = await Livekit.createLocalAudioTrack({
+ echoCancellation: true,
+ noiseSuppression: true,
+ autoGainControl: true,
+ });
+ micPub = await room.localParticipant.publishTrack(micTrack);
+ }
+ var published =
+ micPub ||
+ (MicSource != null &&
+ room.localParticipant.getTrackPublication &&
+ room.localParticipant.getTrackPublication(MicSource));
+ if (!published) {
+ throw new Error('Microphone did not publish. Allow mic access and try again.');
+ }
+ voiceRoom = room;
+ voiceConnecting = false;
+ setVoiceState('listening');
+ console.info('[ebc-voice] connected', granted.roomName, 'mic', published.trackSid || true);
+ } catch (err) {
+ voiceConnecting = false;
+ voiceRoom = null;
+ setVoiceState('error');
+ setError(err.message || 'Could not start voice');
+ }
+ }
+
+ function toggleVoice() {
+ if (voiceState === 'connecting') return;
+ if (voiceRoom || voiceState === 'listening' || voiceState === 'speaking') {
+ stopVoice();
+ return;
+ }
+ startVoice();
+ }
+
+ function setBusy(next) {
+ busy = next;
+ input.disabled = next;
+ sendBtn.disabled = next;
+ micBtn.disabled = next || voiceState === 'connecting';
+ if (quickRepliesEl) {
+ Array.prototype.forEach.call(quickRepliesEl.querySelectorAll('.ebc-chip'), function (chip) {
+ chip.disabled = next;
+ });
+ }
+ Array.prototype.forEach.call(
+ root.querySelectorAll('.ebc-ticket-qr-email'),
+ function (btn) {
+ btn.disabled = next;
+ },
+ );
+ }
+
+ async function api(path, options) {
+ var res = await fetch(apiBase + path, {
+ method: (options && options.method) || 'GET',
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
+ body: options && options.body ? JSON.stringify(options.body) : undefined,
+ });
+ var json = await res.json().catch(function () {
+ return null;
+ });
+ if (!res.ok || !json || json.success === false) {
+ var msg =
+ (json && json.error && json.error.message) ||
+ (json && json.message) ||
+ 'Request failed (' + res.status + ')';
+ throw new Error(msg);
+ }
+ return json.data;
+ }
+
+ async function loadConfig() {
+ var data = await api('/public/concierge/' + encodeURIComponent(botId) + '/config');
+ // Embed accentColor wins; otherwise use bot config, then default
+ applyAccentColor(cfg.accentColor || cfg.accent || data.accentColor || DEFAULT_ACCENT);
+ if (data.greeting) addMessage('bot', data.greeting);
+ quickReplies = Array.isArray(data.quickReplies) ? data.quickReplies : [];
+ renderQuickReplies(quickReplies);
+ if (data.maxMessageLength) input.setAttribute('maxlength', String(data.maxMessageLength));
+ voiceEnabled = Boolean(data.voiceEnabled);
+ setVoiceState('idle');
+ }
+
+ async function sendMessage() {
+ var text = String(input.value || '').trim();
+ if (!text || busy) return;
+ setError('');
+ input.value = '';
+ clearQuickReplies();
+ var userEl = addMessage('user', text, { scroll: 'bottom' });
+ setBusy(true);
+ showTyping();
+ try {
+ var data = await api('/public/concierge/' + encodeURIComponent(botId) + '/chat', {
+ method: 'POST',
+ body: { message: text, sessionId: sessionId || undefined },
+ });
+ sessionId = data.sessionId || sessionId;
+ hideTyping();
+ addMessage('bot', data.reply || 'No reply', {
+ cards: data.cards || [],
+ messageId: data.messageId || null,
+ scroll: 'none',
+ });
+ if (Array.isArray(data.quickReplies) && data.quickReplies.length) {
+ quickReplies = data.quickReplies;
+ renderQuickReplies(quickReplies);
+ }
+ // Keep the question at the top of the viewport so the reply can be read from the start
+ scrollToElementTop(userEl);
+ } catch (err) {
+ hideTyping();
+ setError(err.message || 'Failed to send');
+ } finally {
+ setBusy(false);
+ input.focus();
+ }
+ }
+
+ async function maybeResume() {
+ try {
+ var params = new URLSearchParams(window.location.search || '');
+ var ref = String(params.get('session') || params.get('sessionId') || cfg.sessionId || '').trim();
+ if (!ref) return false;
+ var data = await api(
+ '/public/concierge/' + encodeURIComponent(botId) + '/session/' + encodeURIComponent(ref),
+ );
+ sessionId = data.sessionId || null;
+ messages.innerHTML = '';
+ clearQuickReplies();
+ (data.history || []).forEach(function (m) {
+ addMessage(m.role === 'user' ? 'user' : 'bot', m.text);
+ });
+ return true;
+ } catch (err) {
+ // Fresh chat if resume fails
+ return false;
+ }
+ }
+
+ launcher.addEventListener('click', function () {
+ setOpen(true);
+ input.focus();
+ });
+ closeBtn.addEventListener('click', function () {
+ setOpen(false);
+ });
+ sendBtn.addEventListener('click', sendMessage);
+ micBtn.addEventListener('click', toggleVoice);
+ endVoiceBtn.addEventListener('click', function () {
+ stopVoice();
+ });
+ input.addEventListener('keydown', function (e) {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ sendMessage();
+ }
+ });
+
+ (async function boot() {
+ try {
+ await loadConfig();
+ var resumed = await maybeResume();
+ if (!resumed && quickReplies.length && !quickRepliesEl) {
+ renderQuickReplies(quickReplies);
+ }
+ } catch (err) {
+ setError(err.message || 'Failed to load chatbot');
+ setOpen(true);
+ }
+ })();
+})();
+
diff --git a/public/embed/logo.png b/public/embed/logo.png
new file mode 100644
index 0000000000000000000000000000000000000000..157541e1044432e117d77acd9a25527ed64d84b8
GIT binary patch
literal 2931
zcmV-(3yk!MP)lsldDn><+Z9
zz`6>gRA65P+^ayo3dmGob?^-#khiPV5(9t$iQUELJwE*)NP^@57z}2HK%r1578P8j|=7(TsEH(pO3g;
zE?uDhiTG$c)(tL{OBbkAFaBZ$>Tg()L4j4npIyJC(P3H6!gfSs15+^f^V~njAGj0-
zMffX^Pt{v4xTL{&=7K|NB!;^sqIRMV-}>zq=M0S?xsrTIgZ|4bu2e`!fBuhjZwy+n
z6wV2n*7QVwk}hfV=YC`wQr7v7{(??aH4?x$M%dF|Z)BVoruBs+!wBOD{78#LFy0bTg;RxbUsi=gFpe&zg>?hH)o1Kar5s
zLtf0GAXuRAx`$abN`=Puqi29$5zd@@KjaUbDm2sGSPQTUf%M024$f6!0r<9O3GS)*
zx|f|pjZ@~x%M4+I8St%OFJdv~YoqQgqbK7m6D2dejwBC7r)iZ>2%n{CKdE<^$sm;b
z!`+9ekaF()wDfk3-K?mCnP_y<%#Nlf*f%2nDoqF^X#CYbA)I$B_?*RuW*w@fKP@9!
zpZV6>ygAtSnPw5&XNYcSTym--*K7Ch1Ce0(M5o3%K_gT?E-VcrFJt`@A{WS0f_yQzKfQ^7@?wjiPE3tcK}U>_2lE+JnCpB10p%w%$tjv|8{Mb#q=V
zozL))^^$@b{-t%3ZOSp8CK}}l%cAe;0}?R^%=jE%e9B=`4qc94`5MsVFG%G>Zlhs|
zCO$9d=gXdgmKP#aXPv>0W;oDO_Yjwj4#v{Izg{<*5GwdHy10~~=v1Nq46dP>G>D2U
z%?$CLe&!H8PGDJ%uuhq>9ap+N{+kLxL%DN9(&70eJZx`F5xx^I2fbbFh24cX2a
z8faqrHV#F%Fc$0%5ugdC(ASoPvxD)Y3UYtpZf5s>_&N4^YOmOfMIU?56lV5!dM-EI
zMYT2zr=@Ys_ZlMT9vzY-l7{Z90$$z+G|%*w=9$i!t?Jc)8m9o`TAufLa(xJtU#5N;
z6%s=Cz}Cv~Tz8Cb@@R+iD&)p+(+^BfYkL56b3|K2DW;oHwZb`;u${HcsmJ0v2E)6F
zy0pS^NrpMgv$+nb6V0&Ma9LWe*;~;(tThdW?fwIx+&d+WN3%Sq8=+o556XY!Iu`wx
zx^Ru&is8vGV$*UED%yL3zsjuvW$%D$D0jy^2zHb9HXG2q813<>JPy-S)$iC%U!SqF
zK<_8Z(iZLa@IUva-|j7CW;?gdfo7Vo^`$9u!gkm;@M8<(BmQI9bkt5ayrX@q{(Jaa
z_c#5yz91#^@0l6~%&2IFiATmEx!bD?jN=OZaz6|(#M0NIl;l3jy=pPET!2-}tsN#$~
zln1@rR!Ln*S;yO84wSp-
z)n-t1(?@D#2IU5-_baRb<%T*h(FSERfx?PVZc#NdgBkYt2laEZ>W7?I8t+%P?@;bR
zPi@FseO5|2!HM;~+tT?|u#?s-@4%3oz~+IdkP(!X+K`ltQ5ifsf3p<1wd}JB)W0qJ
zk_KdYV!L^TypZg4kH`bty6ClS*s}?g?dE?+KG=NEV`d?aL1o#OO(Pw8sja2Rq-=x=
z)E4=$wd}LDrWLRW_@BrNTYV-!TzOQWD9qk+3RWCiQ)ek+9rKrhyXc=Ova;&K-t()e
z9l!V*9eW3+-guR=7NjAo1JtGv1|GkaX4a5`J-m^_U-a^@-E=!@JTcBsl1-v+EmW3pb
zh^!m{_o039-QOERco-Ejh3;;)NgVL$7q6I&d-5T)I|kf`oUpN+aDz;tpI2ndNFE9F
z&q0ty@Wd3fdqvdng(jKS6j^_}R;c?iS_Wi#8Lp)TSocW2(Y^|qLOI%b@;O~uPFlgP
zP=3gCcH$CPX`_R&690kSmlKW!rRwE}TZCQXOaJUP$P}91v!2);_GJss^ov;=E|-(s
zmuwYr4dv*f@b^x6=%|n}loj+R#`$Xdh|927BV#DXUsec(vVxntE+y5!rRj;axxx24
zFC#yPWA(M9UUBK9`(98s7tQZoDYx>p@$WOC9LoOnp!_Hp_rtyGZS2YP@85=fy3Ebq
zanewsQOFd!g{Re+3*DySY2x1_K(~;qS&f2j!j!wt`5@Fkp6*9E20nS-$~n+2GFDIh
zy=QDRiBv_4IP>nETnw=_V!;y*tQ?`6@~z8geabcQeM2|0d@gj)jq2RHIwu_KPpWM$
z-yDi+v;1$leL0e{U2UJyr+Q50HKMSyKPk8o<%^`Tq0#^J`&2^F4#wu#AweZvhK|2l
zIx>UHCqiXz&w>8AQ58}`zXV_{%@CrH0dya_W08#_&afq-8lD7wnxntaWB$X6Iw?%M2zLlWVcrtavUy4AzW}=g4-|Ss
z`lSP!!ljjqy%Xin<*vc3)Y7v)9>ir^yDM6u)~)ONQ~}BpjIBNgOe*azX*TDbNd^Fxm~`FP^e%m7>FLnG8z
z^7*MIj;n>^r0=>t##RFju2A&BMw`uqvUx_rAwczBlQb56pvxnR|=8f@VoVn;6#m
zZi(izP)D23*UgdgpM>kVDB^1>;Vn&ruaJmEK4MMtaw-@F_aE3z|Mk|vpU$z|?>6Op
zsGAnL!;c}pv2N4tlO&>LhIk4
zP|)zTi%xrFzvy_zOk))bD?y{6AB`;L2_}avtPG8UetqQsv&;mA!Yg>fZoP%fpls33
z+O4;c5p?*fK {
+ 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();
diff --git a/src/app.js b/src/app.js
new file mode 100644
index 0000000..03a1861
--- /dev/null
+++ b/src/app.js
@@ -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;
+ },
+};
diff --git a/src/config/database.js b/src/config/database.js
new file mode 100644
index 0000000..60b8c2c
--- /dev/null
+++ b/src/config/database.js
@@ -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 };
diff --git a/src/controllers/publicConcierge.controller.js b/src/controllers/publicConcierge.controller.js
new file mode 100644
index 0000000..f92e843
--- /dev/null
+++ b/src/controllers/publicConcierge.controller.js
@@ -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,
+};
diff --git a/src/controllers/publicParticipantQr.controller.js b/src/controllers/publicParticipantQr.controller.js
new file mode 100644
index 0000000..40dd7eb
--- /dev/null
+++ b/src/controllers/publicParticipantQr.controller.js
@@ -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 };
diff --git a/src/models/ConciergeBot.js b/src/models/ConciergeBot.js
new file mode 100644
index 0000000..8336be2
--- /dev/null
+++ b/src/models/ConciergeBot.js
@@ -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);
diff --git a/src/models/Event.js b/src/models/Event.js
new file mode 100644
index 0000000..5422bae
--- /dev/null
+++ b/src/models/Event.js
@@ -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);
diff --git a/src/models/EventMicrosite.js b/src/models/EventMicrosite.js
new file mode 100644
index 0000000..2c1a7ff
--- /dev/null
+++ b/src/models/EventMicrosite.js
@@ -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);
diff --git a/src/models/EventTicket.js b/src/models/EventTicket.js
new file mode 100644
index 0000000..6eee0cb
--- /dev/null
+++ b/src/models/EventTicket.js
@@ -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);
diff --git a/src/models/Exhibitor.js b/src/models/Exhibitor.js
new file mode 100644
index 0000000..ba3926d
--- /dev/null
+++ b/src/models/Exhibitor.js
@@ -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);
diff --git a/src/models/Form.js b/src/models/Form.js
new file mode 100644
index 0000000..1bb7ee0
--- /dev/null
+++ b/src/models/Form.js
@@ -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);
diff --git a/src/models/Organization.js b/src/models/Organization.js
new file mode 100644
index 0000000..cc5d709
--- /dev/null
+++ b/src/models/Organization.js
@@ -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);
diff --git a/src/models/Participant.js b/src/models/Participant.js
new file mode 100644
index 0000000..9b0a3a4
--- /dev/null
+++ b/src/models/Participant.js
@@ -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);
diff --git a/src/models/ParticipantList.js b/src/models/ParticipantList.js
new file mode 100644
index 0000000..36ccce9
--- /dev/null
+++ b/src/models/ParticipantList.js
@@ -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);
diff --git a/src/models/PaymentTransaction.js b/src/models/PaymentTransaction.js
new file mode 100644
index 0000000..9d25eef
--- /dev/null
+++ b/src/models/PaymentTransaction.js
@@ -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);
diff --git a/src/models/Session.js b/src/models/Session.js
new file mode 100644
index 0000000..4ec1cdf
--- /dev/null
+++ b/src/models/Session.js
@@ -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);
diff --git a/src/models/SessionBookmark.js b/src/models/SessionBookmark.js
new file mode 100644
index 0000000..4d88e6c
--- /dev/null
+++ b/src/models/SessionBookmark.js
@@ -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);
diff --git a/src/models/Speaker.js b/src/models/Speaker.js
new file mode 100644
index 0000000..a2a9b54
--- /dev/null
+++ b/src/models/Speaker.js
@@ -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);
diff --git a/src/models/TicketManualOrder.js b/src/models/TicketManualOrder.js
new file mode 100644
index 0000000..0688ba8
--- /dev/null
+++ b/src/models/TicketManualOrder.js
@@ -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);
diff --git a/src/models/Track.js b/src/models/Track.js
new file mode 100644
index 0000000..ec2f30b
--- /dev/null
+++ b/src/models/Track.js
@@ -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);
diff --git a/src/models/WebChatSession.js b/src/models/WebChatSession.js
new file mode 100644
index 0000000..e2a9590
--- /dev/null
+++ b/src/models/WebChatSession.js
@@ -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);
diff --git a/src/models/WhatsAppBlast.js b/src/models/WhatsAppBlast.js
new file mode 100644
index 0000000..f1f759f
--- /dev/null
+++ b/src/models/WhatsAppBlast.js
@@ -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);
diff --git a/src/models/WhatsAppChatSession.js b/src/models/WhatsAppChatSession.js
new file mode 100644
index 0000000..d3c948f
--- /dev/null
+++ b/src/models/WhatsAppChatSession.js
@@ -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);
diff --git a/src/models/WhatsAppDripJob.js b/src/models/WhatsAppDripJob.js
new file mode 100644
index 0000000..61dd0dd
--- /dev/null
+++ b/src/models/WhatsAppDripJob.js
@@ -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);
diff --git a/src/models/WhatsAppMediaCache.js b/src/models/WhatsAppMediaCache.js
new file mode 100644
index 0000000..4699c65
--- /dev/null
+++ b/src/models/WhatsAppMediaCache.js
@@ -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 };
diff --git a/src/models/WhatsAppTemplate.js b/src/models/WhatsAppTemplate.js
new file mode 100644
index 0000000..a6ddcdc
--- /dev/null
+++ b/src/models/WhatsAppTemplate.js
@@ -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);
diff --git a/src/prompts/call2/agenda/agenda_by_speaker.js b/src/prompts/call2/agenda/agenda_by_speaker.js
new file mode 100644
index 0000000..c235546
--- /dev/null
+++ b/src/prompts/call2/agenda/agenda_by_speaker.js
@@ -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(),
+};
diff --git a/src/prompts/call2/agenda/agenda_by_track.js b/src/prompts/call2/agenda/agenda_by_track.js
new file mode 100644
index 0000000..009eb72
--- /dev/null
+++ b/src/prompts/call2/agenda/agenda_by_track.js
@@ -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(),
+};
diff --git a/src/prompts/call2/agenda/agenda_conflict_check.js b/src/prompts/call2/agenda/agenda_conflict_check.js
new file mode 100644
index 0000000..7e01712
--- /dev/null
+++ b/src/prompts/call2/agenda/agenda_conflict_check.js
@@ -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(),
+};
diff --git a/src/prompts/call2/agenda/agenda_current_now.js b/src/prompts/call2/agenda/agenda_current_now.js
new file mode 100644
index 0000000..549de98
--- /dev/null
+++ b/src/prompts/call2/agenda/agenda_current_now.js
@@ -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(),
+};
diff --git a/src/prompts/call2/agenda/agenda_download_materials.js b/src/prompts/call2/agenda/agenda_download_materials.js
new file mode 100644
index 0000000..5049529
--- /dev/null
+++ b/src/prompts/call2/agenda/agenda_download_materials.js
@@ -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(),
+};
diff --git a/src/prompts/call2/agenda/agenda_personal_schedule.js b/src/prompts/call2/agenda/agenda_personal_schedule.js
new file mode 100644
index 0000000..4b2c364
--- /dev/null
+++ b/src/prompts/call2/agenda/agenda_personal_schedule.js
@@ -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(),
+};
diff --git a/src/prompts/call2/agenda/agenda_session_details.js b/src/prompts/call2/agenda/agenda_session_details.js
new file mode 100644
index 0000000..35e5ce3
--- /dev/null
+++ b/src/prompts/call2/agenda/agenda_session_details.js
@@ -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(),
+};
diff --git a/src/prompts/call2/agenda/agenda_upcoming_next.js b/src/prompts/call2/agenda/agenda_upcoming_next.js
new file mode 100644
index 0000000..31f3c58
--- /dev/null
+++ b/src/prompts/call2/agenda/agenda_upcoming_next.js
@@ -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(),
+};
diff --git a/src/prompts/call2/agenda/index.js b/src/prompts/call2/agenda/index.js
new file mode 100644
index 0000000..d488324
--- /dev/null
+++ b/src/prompts/call2/agenda/index.js
@@ -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,
+};
diff --git a/src/prompts/call2/conversation.js b/src/prompts/call2/conversation.js
new file mode 100644
index 0000000..9a117f8
--- /dev/null
+++ b/src/prompts/call2/conversation.js
@@ -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 };
diff --git a/src/prompts/call2/exhibitor/exhibitor_book_meeting.js b/src/prompts/call2/exhibitor/exhibitor_book_meeting.js
new file mode 100644
index 0000000..a83fc76
--- /dev/null
+++ b/src/prompts/call2/exhibitor/exhibitor_book_meeting.js
@@ -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(),
+};
diff --git a/src/prompts/call2/exhibitor/exhibitor_by_category.js b/src/prompts/call2/exhibitor/exhibitor_by_category.js
new file mode 100644
index 0000000..ccd8ef8
--- /dev/null
+++ b/src/prompts/call2/exhibitor/exhibitor_by_category.js
@@ -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(),
+};
diff --git a/src/prompts/call2/exhibitor/exhibitor_demo_schedule.js b/src/prompts/call2/exhibitor/exhibitor_demo_schedule.js
new file mode 100644
index 0000000..6bac9ee
--- /dev/null
+++ b/src/prompts/call2/exhibitor/exhibitor_demo_schedule.js
@@ -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(),
+};
diff --git a/src/prompts/call2/exhibitor/exhibitor_location.js b/src/prompts/call2/exhibitor/exhibitor_location.js
new file mode 100644
index 0000000..6572908
--- /dev/null
+++ b/src/prompts/call2/exhibitor/exhibitor_location.js
@@ -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(),
+};
diff --git a/src/prompts/call2/exhibitor/index.js b/src/prompts/call2/exhibitor/index.js
new file mode 100644
index 0000000..a89a2bf
--- /dev/null
+++ b/src/prompts/call2/exhibitor/index.js
@@ -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,
+};
diff --git a/src/prompts/call2/exhibitor/main.js b/src/prompts/call2/exhibitor/main.js
new file mode 100644
index 0000000..2ee50ca
--- /dev/null
+++ b/src/prompts/call2/exhibitor/main.js
@@ -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 };
diff --git a/src/prompts/call2/local/accommodation.js b/src/prompts/call2/local/accommodation.js
new file mode 100644
index 0000000..83c08d2
--- /dev/null
+++ b/src/prompts/call2/local/accommodation.js
@@ -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(),
+};
diff --git a/src/prompts/call2/local/index.js b/src/prompts/call2/local/index.js
new file mode 100644
index 0000000..55e58e4
--- /dev/null
+++ b/src/prompts/call2/local/index.js
@@ -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,
+};
diff --git a/src/prompts/call2/local/main.js b/src/prompts/call2/local/main.js
new file mode 100644
index 0000000..f240c55
--- /dev/null
+++ b/src/prompts/call2/local/main.js
@@ -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 };
diff --git a/src/prompts/call2/local/tourist_places.js b/src/prompts/call2/local/tourist_places.js
new file mode 100644
index 0000000..25eeb27
--- /dev/null
+++ b/src/prompts/call2/local/tourist_places.js
@@ -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(),
+};
diff --git a/src/prompts/call2/main.js b/src/prompts/call2/main.js
new file mode 100644
index 0000000..371e749
--- /dev/null
+++ b/src/prompts/call2/main.js
@@ -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 };
diff --git a/src/prompts/call2/speaker/index.js b/src/prompts/call2/speaker/index.js
new file mode 100644
index 0000000..99e7e81
--- /dev/null
+++ b/src/prompts/call2/speaker/index.js
@@ -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,
+};
diff --git a/src/prompts/call2/speaker/main.js b/src/prompts/call2/speaker/main.js
new file mode 100644
index 0000000..bd870fa
--- /dev/null
+++ b/src/prompts/call2/speaker/main.js
@@ -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 };
diff --git a/src/prompts/call2/speaker/speaker_bio_lookup.js b/src/prompts/call2/speaker/speaker_bio_lookup.js
new file mode 100644
index 0000000..99b12f4
--- /dev/null
+++ b/src/prompts/call2/speaker/speaker_bio_lookup.js
@@ -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(),
+};
diff --git a/src/prompts/call2/speaker/speaker_contact_info.js b/src/prompts/call2/speaker/speaker_contact_info.js
new file mode 100644
index 0000000..51f2da6
--- /dev/null
+++ b/src/prompts/call2/speaker/speaker_contact_info.js
@@ -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(),
+};
diff --git a/src/prompts/call2/speaker/speaker_meet_and_greet.js b/src/prompts/call2/speaker/speaker_meet_and_greet.js
new file mode 100644
index 0000000..f578ad5
--- /dev/null
+++ b/src/prompts/call2/speaker/speaker_meet_and_greet.js
@@ -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(),
+};
diff --git a/src/prompts/call2/system/chitchat_greeting.js b/src/prompts/call2/system/chitchat_greeting.js
new file mode 100644
index 0000000..34f5ca7
--- /dev/null
+++ b/src/prompts/call2/system/chitchat_greeting.js
@@ -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(),
+};
diff --git a/src/prompts/call2/system/emergency_medical.js b/src/prompts/call2/system/emergency_medical.js
new file mode 100644
index 0000000..0eee614
--- /dev/null
+++ b/src/prompts/call2/system/emergency_medical.js
@@ -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(),
+};
diff --git a/src/prompts/call2/system/human_agent_escalation.js b/src/prompts/call2/system/human_agent_escalation.js
new file mode 100644
index 0000000..fe4fb04
--- /dev/null
+++ b/src/prompts/call2/system/human_agent_escalation.js
@@ -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(),
+};
diff --git a/src/prompts/call2/system/index.js b/src/prompts/call2/system/index.js
new file mode 100644
index 0000000..0acf2bf
--- /dev/null
+++ b/src/prompts/call2/system/index.js
@@ -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,
+};
diff --git a/src/prompts/call2/system/lost_and_found.js b/src/prompts/call2/system/lost_and_found.js
new file mode 100644
index 0000000..6495549
--- /dev/null
+++ b/src/prompts/call2/system/lost_and_found.js
@@ -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(),
+};
diff --git a/src/prompts/call2/system/main.js b/src/prompts/call2/system/main.js
new file mode 100644
index 0000000..c9b0e0b
--- /dev/null
+++ b/src/prompts/call2/system/main.js
@@ -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 };
diff --git a/src/prompts/call2/system/out_of_scope.js b/src/prompts/call2/system/out_of_scope.js
new file mode 100644
index 0000000..1278cbf
--- /dev/null
+++ b/src/prompts/call2/system/out_of_scope.js
@@ -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(),
+};
diff --git a/src/prompts/call2/tech/app_feature_guidance.js b/src/prompts/call2/tech/app_feature_guidance.js
new file mode 100644
index 0000000..a7dca70
--- /dev/null
+++ b/src/prompts/call2/tech/app_feature_guidance.js
@@ -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(),
+};
diff --git a/src/prompts/call2/tech/app_issue_account.js b/src/prompts/call2/tech/app_issue_account.js
new file mode 100644
index 0000000..984bbc3
--- /dev/null
+++ b/src/prompts/call2/tech/app_issue_account.js
@@ -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(),
+};
diff --git a/src/prompts/call2/tech/app_issue_streaming.js b/src/prompts/call2/tech/app_issue_streaming.js
new file mode 100644
index 0000000..e5fffb9
--- /dev/null
+++ b/src/prompts/call2/tech/app_issue_streaming.js
@@ -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(),
+};
diff --git a/src/prompts/call2/tech/index.js b/src/prompts/call2/tech/index.js
new file mode 100644
index 0000000..b9eed7b
--- /dev/null
+++ b/src/prompts/call2/tech/index.js
@@ -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,
+};
diff --git a/src/prompts/call2/tech/main.js b/src/prompts/call2/tech/main.js
new file mode 100644
index 0000000..63ae357
--- /dev/null
+++ b/src/prompts/call2/tech/main.js
@@ -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 };
diff --git a/src/prompts/call2/tech/wifi_credentials.js b/src/prompts/call2/tech/wifi_credentials.js
new file mode 100644
index 0000000..4b38ef0
--- /dev/null
+++ b/src/prompts/call2/tech/wifi_credentials.js
@@ -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(),
+};
diff --git a/src/prompts/call2/ticket/index.js b/src/prompts/call2/ticket/index.js
new file mode 100644
index 0000000..81e9e8c
--- /dev/null
+++ b/src/prompts/call2/ticket/index.js
@@ -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,
+};
diff --git a/src/prompts/call2/ticket/main.js b/src/prompts/call2/ticket/main.js
new file mode 100644
index 0000000..26a042c
--- /dev/null
+++ b/src/prompts/call2/ticket/main.js
@@ -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 };
diff --git a/src/prompts/call2/ticket/ticket_document_receipt.js b/src/prompts/call2/ticket/ticket_document_receipt.js
new file mode 100644
index 0000000..9e21467
--- /dev/null
+++ b/src/prompts/call2/ticket/ticket_document_receipt.js
@@ -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(),
+};
diff --git a/src/prompts/call2/ticket/ticket_email_qr.js b/src/prompts/call2/ticket/ticket_email_qr.js
new file mode 100644
index 0000000..11c80da
--- /dev/null
+++ b/src/prompts/call2/ticket/ticket_email_qr.js
@@ -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(),
+};
diff --git a/src/prompts/call2/ticket/ticket_how_to_register.js b/src/prompts/call2/ticket/ticket_how_to_register.js
new file mode 100644
index 0000000..3461242
--- /dev/null
+++ b/src/prompts/call2/ticket/ticket_how_to_register.js
@@ -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(),
+};
diff --git a/src/prompts/call2/ticket/ticket_qr_display.js b/src/prompts/call2/ticket/ticket_qr_display.js
new file mode 100644
index 0000000..1196455
--- /dev/null
+++ b/src/prompts/call2/ticket/ticket_qr_display.js
@@ -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(),
+};
diff --git a/src/prompts/call2/ticket/ticket_status_lookup.js b/src/prompts/call2/ticket/ticket_status_lookup.js
new file mode 100644
index 0000000..d4b06be
--- /dev/null
+++ b/src/prompts/call2/ticket/ticket_status_lookup.js
@@ -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(),
+};
diff --git a/src/prompts/call2/ticket/ticket_transfer_request.js b/src/prompts/call2/ticket/ticket_transfer_request.js
new file mode 100644
index 0000000..27c50c7
--- /dev/null
+++ b/src/prompts/call2/ticket/ticket_transfer_request.js
@@ -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(),
+};
diff --git a/src/prompts/call2/ticket/ticket_upgrade.js b/src/prompts/call2/ticket/ticket_upgrade.js
new file mode 100644
index 0000000..e75a6a9
--- /dev/null
+++ b/src/prompts/call2/ticket/ticket_upgrade.js
@@ -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(),
+};
diff --git a/src/prompts/call2/venue/index.js b/src/prompts/call2/venue/index.js
new file mode 100644
index 0000000..f735b0e
--- /dev/null
+++ b/src/prompts/call2/venue/index.js
@@ -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,
+};
diff --git a/src/prompts/call2/venue/main.js b/src/prompts/call2/venue/main.js
new file mode 100644
index 0000000..f4dcfcb
--- /dev/null
+++ b/src/prompts/call2/venue/main.js
@@ -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 };
diff --git a/src/prompts/call2/venue/venue_accessibility.js b/src/prompts/call2/venue/venue_accessibility.js
new file mode 100644
index 0000000..884242a
--- /dev/null
+++ b/src/prompts/call2/venue/venue_accessibility.js
@@ -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(),
+};
diff --git a/src/prompts/call2/venue/venue_food_beverage.js b/src/prompts/call2/venue/venue_food_beverage.js
new file mode 100644
index 0000000..3465b6e
--- /dev/null
+++ b/src/prompts/call2/venue/venue_food_beverage.js
@@ -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(),
+};
diff --git a/src/prompts/call2/venue/venue_map_request.js b/src/prompts/call2/venue/venue_map_request.js
new file mode 100644
index 0000000..80d2d3f
--- /dev/null
+++ b/src/prompts/call2/venue/venue_map_request.js
@@ -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(),
+};
diff --git a/src/prompts/call2/venue/venue_nearest_amenity.js b/src/prompts/call2/venue/venue_nearest_amenity.js
new file mode 100644
index 0000000..58f858e
--- /dev/null
+++ b/src/prompts/call2/venue/venue_nearest_amenity.js
@@ -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(),
+};
diff --git a/src/prompts/call2/venue/venue_parking_transport.js b/src/prompts/call2/venue/venue_parking_transport.js
new file mode 100644
index 0000000..ad9fb50
--- /dev/null
+++ b/src/prompts/call2/venue/venue_parking_transport.js
@@ -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(),
+};
diff --git a/src/prompts/call2/venue/venue_travel_directions.js b/src/prompts/call2/venue/venue_travel_directions.js
new file mode 100644
index 0000000..fdfb584
--- /dev/null
+++ b/src/prompts/call2/venue/venue_travel_directions.js
@@ -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(),
+};
\ No newline at end of file
diff --git a/src/prompts/call2/venue/venue_wayfinding.js b/src/prompts/call2/venue/venue_wayfinding.js
new file mode 100644
index 0000000..70de71b
--- /dev/null
+++ b/src/prompts/call2/venue/venue_wayfinding.js
@@ -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(),
+};
diff --git a/src/prompts/step1.js b/src/prompts/step1.js
new file mode 100644
index 0000000..56a8b74
--- /dev/null
+++ b/src/prompts/step1.js
@@ -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,
+};
diff --git a/src/routes/publicConcierge.routes.js b/src/routes/publicConcierge.routes.js
new file mode 100644
index 0000000..ba48668
--- /dev/null
+++ b/src/routes/publicConcierge.routes.js
@@ -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,
+ },
+];
diff --git a/src/routes/whatsappWebhook.routes.js b/src/routes/whatsappWebhook.routes.js
new file mode 100644
index 0000000..a337182
--- /dev/null
+++ b/src/routes/whatsappWebhook.routes.js
@@ -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);
+ },
+ },
+];
diff --git a/src/services/agendaData.service.js b/src/services/agendaData.service.js
new file mode 100644
index 0000000..2abd6ce
--- /dev/null
+++ b/src/services/agendaData.service.js
@@ -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,
+};
diff --git a/src/services/bookmark.service.js b/src/services/bookmark.service.js
new file mode 100644
index 0000000..1475df4
--- /dev/null
+++ b/src/services/bookmark.service.js
@@ -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,
+};
diff --git a/src/services/exhibitorData.service.js b/src/services/exhibitorData.service.js
new file mode 100644
index 0000000..c902d85
--- /dev/null
+++ b/src/services/exhibitorData.service.js
@@ -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,
+};
diff --git a/src/services/identity.service.js b/src/services/identity.service.js
new file mode 100644
index 0000000..93886d5
--- /dev/null
+++ b/src/services/identity.service.js
@@ -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,
+};
diff --git a/src/services/localData.service.js b/src/services/localData.service.js
new file mode 100644
index 0000000..6d2ad2e
--- /dev/null
+++ b/src/services/localData.service.js
@@ -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,
+};
diff --git a/src/services/orchestrator.service.js b/src/services/orchestrator.service.js
new file mode 100644
index 0000000..8e7fcc0
--- /dev/null
+++ b/src/services/orchestrator.service.js
@@ -0,0 +1,700 @@
+const { classifyIntent } = require('./step1.service');
+const {
+ generateAgendaReply,
+ generateVenueReply,
+ generateTicketReply,
+ generateSpeakerReply,
+ generateExhibitorReply,
+ generateTechReply,
+ generateSystemReply,
+ generateLocalReply,
+} = require('./step2.service');
+const {
+ fetchAgendaData,
+ isAgendaIntent,
+ isAuthRequiredAgendaIntent,
+} = require('./agendaData.service');
+const { fetchVenueData, isVenueIntent } = require('./venueData.service');
+const {
+ fetchTicketData,
+ isTicketIntent,
+ isAuthRequiredTicketIntent,
+} = require('./ticketData.service');
+const { fetchSpeakerData, isSpeakerIntent } = require('./speakerData.service');
+const { fetchExhibitorData, isExhibitorIntent } = require('./exhibitorData.service');
+const { fetchTechData, isTechIntent } = require('./techData.service');
+const { fetchSystemData, isSystemIntent } = require('./systemData.service');
+const { fetchLocalData, isLocalIntent } = require('./localData.service');
+const {
+ addBookmark,
+ removeBookmark,
+ isBookmarkMutationIntent,
+} = require('./bookmark.service');
+const {
+ isVerified,
+ beginEmailAuth,
+ handleAuthTurn,
+ clearPending,
+ clearAuthChallenge,
+} = require('./identity.service');
+const { sendTicketPassEmail } = require('../utils/mail');
+const { withOrganizerContact } = require('../utils/organizerContact');
+
+const TICKET_EMAIL_COOLDOWN_MS = 60 * 1000;
+
+function publicClassification(classification) {
+ if (!classification) return null;
+ const { _fallback, _reason, ...rest } = classification;
+ return rest;
+}
+
+function sumUsage(a, b) {
+ return {
+ inputTokens: Number(a?.inputTokens || 0) + Number(b?.inputTokens || 0),
+ outputTokens: Number(a?.outputTokens || 0) + Number(b?.outputTokens || 0),
+ };
+}
+
+async function maybeSendTicketEmail({ session, data, intent }) {
+ if (intent !== 'ticket_email_qr') return data;
+
+ const to = String(data?.attendee?.email || '').trim().toLowerCase();
+ if (!to) {
+ return {
+ ...data,
+ emailSent: false,
+ emailError: 'missing_email',
+ note: 'No registered email on file for this attendee.',
+ };
+ }
+
+ const last = session.lastTicketEmailAt ? new Date(session.lastTicketEmailAt).getTime() : 0;
+ if (last && Date.now() - last < TICKET_EMAIL_COOLDOWN_MS) {
+ return {
+ ...data,
+ emailSent: false,
+ emailTo: to,
+ emailError: 'rate_limited',
+ note: 'A ticket email was sent recently for this chat session.',
+ };
+ }
+
+ const payment = data.payment;
+ const paymentLabel =
+ payment?.status && payment?.amount != null
+ ? `${payment.status} (${payment.currency || 'INR'} ${payment.amount})`
+ : payment?.status || null;
+
+ const result = await sendTicketPassEmail({
+ to,
+ eventName: data.eventName,
+ attendeeName: data.attendee?.name,
+ ticketName: data.ticket?.name || data.receipt?.ticketName || null,
+ statusLabel: data.membership?.statusLabel || data.membership?.status || null,
+ paymentLabel,
+ qrImageUrl: data.qr?.imageUrl || null,
+ });
+
+ if (result.ok) {
+ session.lastTicketEmailAt = new Date();
+ return {
+ ...data,
+ emailSent: true,
+ emailTo: to,
+ emailError: null,
+ };
+ }
+
+ return {
+ ...data,
+ emailSent: false,
+ emailTo: to,
+ emailError: result.error || 'send_failed',
+ };
+}
+
+async function runVenueCall2({ bot, event, session, message, history, classification }) {
+ const intent = classification.primary_intent;
+ const data = await fetchVenueData({
+ intent,
+ eventId: bot.event,
+ bot,
+ entities: classification.entities || {},
+ message,
+ });
+
+ const step2 = await generateVenueReply({
+ intent,
+ message,
+ history,
+ entities: classification.entities || {},
+ data,
+ });
+
+ return {
+ reply: step2.reply,
+ cards: Array.isArray(step2.cards) ? step2.cards : [],
+ sessionIds: [],
+ placeIds: Array.isArray(step2.placeIds) ? step2.placeIds : [],
+ classification: publicClassification(classification),
+ intent,
+ modelId: step2.modelId,
+ usage: step2.usage,
+ };
+}
+
+async function runLocalCall2({ bot, event, session, message, history, classification }) {
+ const intent = classification.primary_intent;
+ const data = await fetchLocalData({
+ intent,
+ eventId: bot.event,
+ message,
+ bot,
+ });
+
+ const step2 = await generateLocalReply({
+ intent,
+ message,
+ history,
+ entities: classification.entities || {},
+ data,
+ });
+
+ return {
+ reply: step2.reply,
+ cards: Array.isArray(step2.cards) ? step2.cards : [],
+ sessionIds: [],
+ placeIds: Array.isArray(step2.placeIds) ? step2.placeIds : [],
+ hotelIds: Array.isArray(step2.hotelIds) ? step2.hotelIds : [],
+ classification: publicClassification(classification),
+ intent,
+ modelId: step2.modelId,
+ usage: step2.usage,
+ };
+}
+
+async function runAgendaCall2({ bot, event, session, message, history, classification }) {
+ const intent = classification.primary_intent;
+ const data = await fetchAgendaData({
+ intent,
+ eventId: bot.event,
+ entities: classification.entities || {},
+ participantId: session.participant || null,
+ });
+
+ const step2 = await generateAgendaReply({
+ intent,
+ message,
+ history,
+ entities: classification.entities || {},
+ data,
+ });
+
+ return {
+ reply: step2.reply,
+ cards: Array.isArray(step2.cards) ? step2.cards : [],
+ sessionIds: Array.isArray(step2.sessionIds) ? step2.sessionIds : [],
+ classification: publicClassification(classification),
+ intent,
+ modelId: step2.modelId,
+ usage: step2.usage,
+ };
+}
+
+async function runTicketCall2({ bot, event, session, message, history, classification }) {
+ const intent = classification.primary_intent;
+ const eventName = event?.name || null;
+
+ let data = await fetchTicketData({
+ intent,
+ eventId: bot.event,
+ participantId: session.participant || null,
+ eventName,
+ bot,
+ event,
+ });
+
+ data = await maybeSendTicketEmail({ session, data, intent });
+
+ const step2 = await generateTicketReply({
+ intent,
+ message,
+ history,
+ entities: classification.entities || {},
+ data,
+ });
+
+ return {
+ reply: step2.reply,
+ cards: Array.isArray(step2.cards) ? step2.cards : [],
+ sessionIds: [],
+ classification: publicClassification(classification),
+ intent,
+ modelId: step2.modelId,
+ usage: step2.usage,
+ };
+}
+
+async function runSpeakerCall2({ bot, event, session, message, history, classification }) {
+ const intent = classification.primary_intent;
+ const data = await fetchSpeakerData({
+ intent,
+ eventId: bot.event,
+ entities: classification.entities || {},
+ });
+
+ const step2 = await generateSpeakerReply({
+ intent,
+ message,
+ history,
+ entities: classification.entities || {},
+ data,
+ });
+
+ return {
+ reply: step2.reply,
+ cards: Array.isArray(step2.cards) ? step2.cards : [],
+ sessionIds: Array.isArray(step2.sessionIds) ? step2.sessionIds : [],
+ speakerIds: Array.isArray(step2.speakerIds) ? step2.speakerIds : [],
+ classification: publicClassification(classification),
+ intent,
+ modelId: step2.modelId,
+ usage: step2.usage,
+ };
+}
+
+async function runExhibitorCall2({ bot, event, session, message, history, classification }) {
+ const intent = classification.primary_intent;
+ const data = await fetchExhibitorData({
+ intent,
+ eventId: bot.event,
+ bot,
+ entities: classification.entities || {},
+ });
+
+ const step2 = await generateExhibitorReply({
+ intent,
+ message,
+ history,
+ entities: classification.entities || {},
+ data,
+ });
+
+ return {
+ reply: step2.reply,
+ cards: Array.isArray(step2.cards) ? step2.cards : [],
+ sessionIds: [],
+ exhibitorIds: Array.isArray(step2.exhibitorIds) ? step2.exhibitorIds : [],
+ classification: publicClassification(classification),
+ intent,
+ modelId: step2.modelId,
+ usage: step2.usage,
+ };
+}
+
+async function runTechCall2({ bot, event, session, message, history, classification }) {
+ const intent = classification.primary_intent;
+ const data = await fetchTechData({
+ intent,
+ bot,
+ });
+
+ const step2 = await generateTechReply({
+ intent,
+ message,
+ history,
+ entities: classification.entities || {},
+ data,
+ });
+
+ return {
+ reply: step2.reply,
+ cards: [],
+ sessionIds: [],
+ classification: publicClassification(classification),
+ intent,
+ modelId: step2.modelId,
+ usage: step2.usage,
+ };
+}
+
+async function runSystemCall2({ bot, event, session, message, history, classification }) {
+ const intent = classification.primary_intent;
+ const data = await fetchSystemData({
+ intent,
+ bot,
+ event,
+ session,
+ message,
+ });
+
+ const step2 = await generateSystemReply({
+ intent,
+ message,
+ history,
+ entities: classification.entities || {},
+ data,
+ });
+
+ return {
+ reply: step2.reply,
+ cards: [],
+ sessionIds: [],
+ quickReplies: Array.isArray(step2.quickReplies) ? step2.quickReplies : null,
+ classification: publicClassification(classification),
+ intent,
+ modelId: step2.modelId,
+ usage: step2.usage,
+ };
+}
+
+async function runBookmarkMutation({ bot, session, classification }) {
+ const intent = classification.primary_intent;
+ const entities = classification.entities || {};
+ const fallbackSessionIds = Array.isArray(session.lastSessionIds) ? session.lastSessionIds : [];
+ const opts = {
+ eventId: bot.event,
+ participantId: session.participant || null,
+ entities,
+ fallbackSessionIds,
+ };
+
+ const result =
+ intent === 'agenda_bookmark_remove'
+ ? await removeBookmark(opts)
+ : await addBookmark(opts);
+
+ return {
+ reply: result.reply,
+ cards: Array.isArray(result.cards) ? result.cards : [],
+ sessionIds: Array.isArray(result.sessionIds) ? result.sessionIds : [],
+ classification: publicClassification(classification),
+ intent,
+ modelId: null,
+ usage: { inputTokens: 0, outputTokens: 0 },
+ };
+}
+
+/**
+ * Full turn orchestration after the user message has been appended to session.history.
+ */
+function finalizeTurn(result, bot, message) {
+ const intent = String(result?.intent || '');
+ const force = intent === 'human_agent_escalation' || intent === 'emergency_medical';
+ return {
+ ...result,
+ reply: withOrganizerContact(result?.reply, bot, { message, force }),
+ };
+}
+
+async function handleChatTurnCore({ bot, event, session, message }) {
+ const eventName = event?.name || 'the event';
+ const history = Array.isArray(session.history) ? session.history.slice(0, -1) : [];
+
+ // 1) In-progress identity challenge — skip Read Call 1
+ if (session.authState === 'awaiting_email' || session.authState === 'awaiting_otp') {
+ const auth = await handleAuthTurn(session, {
+ message,
+ eventId: bot.event,
+ eventName,
+ });
+
+ if (auth.cancelled) {
+ clearAuthChallenge(session);
+ clearPending(session);
+ if (auth.cancelOnly) {
+ return {
+ reply: 'OK. What else can I help with?',
+ cards: [],
+ sessionIds: [],
+ classification: null,
+ intent: null,
+ modelId: null,
+ usage: { inputTokens: 0, outputTokens: 0 },
+ };
+ }
+ // New question while verifying — drop auth and answer it below.
+ } else if (!auth.shouldResume) {
+ return {
+ reply: auth.reply,
+ cards: [],
+ sessionIds: [],
+ classification: null,
+ intent: session.pendingIntent || null,
+ modelId: null,
+ usage: { inputTokens: 0, outputTokens: 0 },
+ };
+ } else {
+
+ // Verified — answer the original pending query
+ const pendingMessage = session.pendingMessage || message;
+ const pendingIntent = session.pendingIntent;
+ const pendingEntities =
+ session.pendingEntities && typeof session.pendingEntities === 'object'
+ ? session.pendingEntities
+ : {};
+ clearPending(session);
+
+ if (pendingIntent && isBookmarkMutationIntent(pendingIntent)) {
+ const classification = {
+ primary_intent: pendingIntent,
+ confidence: 1,
+ action_category: 'mutation',
+ entities: pendingEntities,
+ context_resolution: { is_followup: true, resolved_from_history: true },
+ requires_data_fetch: false,
+ };
+ const result = await runBookmarkMutation({ bot, session, classification });
+ result.reply = `You're verified. ${result.reply}`;
+ return result;
+ }
+
+ if (pendingIntent && isAuthRequiredTicketIntent(pendingIntent)) {
+ const classification = {
+ primary_intent: pendingIntent,
+ confidence: 1,
+ action_category: pendingIntent === 'ticket_email_qr' ? 'mutation' : 'read',
+ entities: pendingEntities,
+ context_resolution: { is_followup: true, resolved_from_history: true },
+ requires_data_fetch: true,
+ };
+ const result = await runTicketCall2({
+ bot,
+ event,
+ session,
+ message: pendingMessage,
+ history,
+ classification,
+ });
+ result.reply = `You're verified. ${result.reply}`;
+ return result;
+ }
+
+ if (pendingIntent && isAgendaIntent(pendingIntent)) {
+ const classification = {
+ primary_intent: pendingIntent,
+ confidence: 1,
+ action_category: 'read',
+ entities: pendingEntities,
+ context_resolution: { is_followup: true, resolved_from_history: true },
+ requires_data_fetch: true,
+ };
+ const result = await runAgendaCall2({
+ bot,
+ event,
+ session,
+ message: pendingMessage,
+ history,
+ classification,
+ });
+ result.reply = `You're verified. ${result.reply}`;
+ return result;
+ }
+
+ return {
+ reply: "You're verified. How can I help with the agenda or your ticket?",
+ cards: [],
+ sessionIds: [],
+ classification: null,
+ intent: pendingIntent,
+ modelId: null,
+ usage: { inputTokens: 0, outputTokens: 0 },
+ };
+ }
+ }
+
+ // 2) Call 1
+ const step1 = await classifyIntent({ message, history });
+ const classification = step1.classification;
+ const intent = classification.primary_intent;
+
+ // 3) Auth-required intents (personal agenda + ticket reads/email)
+ if (
+ (isAuthRequiredAgendaIntent(intent) || isAuthRequiredTicketIntent(intent))
+ && !isVerified(session)
+ ) {
+ const started = await beginEmailAuth(session, {
+ intent,
+ message,
+ eventName,
+ entities: classification.entities || {},
+ });
+ return {
+ reply: started.reply,
+ cards: [],
+ sessionIds: [],
+ classification: publicClassification(classification),
+ intent,
+ modelId: step1.modelId,
+ usage: step1.usage,
+ };
+ }
+
+ // 4) Bookmark mutations — write to SessionBookmark (no Call 2)
+ if (isBookmarkMutationIntent(intent)) {
+ const result = await runBookmarkMutation({ bot, session, classification });
+ return {
+ ...result,
+ usage: sumUsage(step1.usage, result.usage),
+ modelId: result.modelId || step1.modelId,
+ };
+ }
+
+ // 5) Agenda Call 2
+ if (isAgendaIntent(intent)) {
+ const result = await runAgendaCall2({
+ bot,
+ event,
+ session,
+ message,
+ history,
+ classification,
+ });
+ return {
+ ...result,
+ usage: sumUsage(step1.usage, result.usage),
+ modelId: result.modelId || step1.modelId,
+ };
+ }
+
+ // 6) Venue Call 2 (guest — no auth)
+ if (isVenueIntent(intent)) {
+ const result = await runVenueCall2({
+ bot,
+ event,
+ session,
+ message,
+ history,
+ classification,
+ });
+ return {
+ ...result,
+ usage: sumUsage(step1.usage, result.usage),
+ modelId: result.modelId || step1.modelId,
+ };
+ }
+
+ // 6b) Local places / accommodation Call 2 (guest — cached recommendations)
+ if (isLocalIntent(intent)) {
+ const result = await runLocalCall2({
+ bot,
+ event,
+ session,
+ message,
+ history,
+ classification,
+ });
+ return {
+ ...result,
+ usage: sumUsage(step1.usage, result.usage),
+ modelId: result.modelId || step1.modelId,
+ };
+ }
+
+ // 7) Ticket Call 2 (auth already gated for personal intents; transfer/upgrade refuse)
+ if (isTicketIntent(intent)) {
+ const result = await runTicketCall2({
+ bot,
+ event,
+ session,
+ message,
+ history,
+ classification,
+ });
+ return {
+ ...result,
+ usage: sumUsage(step1.usage, result.usage),
+ modelId: result.modelId || step1.modelId,
+ };
+ }
+
+ // 8) Speaker Call 2 (guest — public programme data only)
+ if (isSpeakerIntent(intent)) {
+ const result = await runSpeakerCall2({
+ bot,
+ event,
+ session,
+ message,
+ history,
+ classification,
+ });
+ return {
+ ...result,
+ usage: sumUsage(step1.usage, result.usage),
+ modelId: result.modelId || step1.modelId,
+ };
+ }
+
+ // 9) Exhibitor Call 2 (guest — published booth data + FAQ demos)
+ if (isExhibitorIntent(intent)) {
+ const result = await runExhibitorCall2({
+ bot,
+ event,
+ session,
+ message,
+ history,
+ classification,
+ });
+ return {
+ ...result,
+ usage: sumUsage(step1.usage, result.usage),
+ modelId: result.modelId || step1.modelId,
+ };
+ }
+
+ // 10) Tech Call 2 (guest — FAQ + static tips)
+ if (isTechIntent(intent)) {
+ const result = await runTechCall2({
+ bot,
+ event,
+ session,
+ message,
+ history,
+ classification,
+ });
+ return {
+ ...result,
+ usage: sumUsage(step1.usage, result.usage),
+ modelId: result.modelId || step1.modelId,
+ };
+ }
+
+ // 11) System Call 2 (greeting / safety / escalation — may email organizers)
+ if (isSystemIntent(intent)) {
+ const result = await runSystemCall2({
+ bot,
+ event,
+ session,
+ message,
+ history,
+ classification,
+ });
+ return {
+ ...result,
+ usage: sumUsage(step1.usage, result.usage),
+ modelId: result.modelId || step1.modelId,
+ };
+ }
+
+ // 12) Unimplemented intent groups
+ return {
+ reply:
+ `I understood your request (${intent.replace(/_/g, ' ')}), but that topic isn't available in this concierge yet. `
+ + 'I can help with the agenda, speakers, exhibitors, venue, Wi‑Fi / app help, or your ticket (QR, status, receipt).',
+ cards: [],
+ sessionIds: [],
+ classification: publicClassification(classification),
+ intent,
+ modelId: step1.modelId,
+ usage: step1.usage,
+ };
+}
+
+async function handleChatTurn(opts) {
+ const result = await handleChatTurnCore(opts);
+ return finalizeTurn(result, opts.bot, opts.message);
+}
+
+module.exports = {
+ handleChatTurn,
+};
diff --git a/src/services/speakerData.service.js b/src/services/speakerData.service.js
new file mode 100644
index 0000000..9305117
--- /dev/null
+++ b/src/services/speakerData.service.js
@@ -0,0 +1,277 @@
+const Speaker = require('../models/Speaker');
+const {
+ resolveEventSpeakers,
+ listEventSpeakerDocs,
+ fullName,
+} = require('../utils/resolveSpeaker');
+const { fetchAgendaData } = require('./agendaData.service');
+const { stripNameSalutations } = require('../utils/nameSalutations');
+
+const SPEAKER_INTENTS = new Set([
+ 'speaker_bio_lookup',
+ 'speaker_meet_and_greet',
+ 'speaker_contact_info',
+]);
+
+const MEET_RE = /meet|greeting|networking|m\s*&\s*g|coffee with|meet[- ]and[- ]greet/i;
+const BIO_MAX = 1200;
+const LIST_BIO_MAX = 220;
+const MAX_CANDIDATES = 8;
+const MAX_LIST_SPEAKERS = 24;
+
+function isSpeakerIntent(intent) {
+ return SPEAKER_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 = BIO_MAX) {
+ const s = String(value || '').trim();
+ if (!s) return null;
+ return s.length > max ? `${s.slice(0, max)}…` : s;
+}
+
+function isPublicProfile(sp) {
+ const v = String(sp?.profileVisibility || 'public').toLowerCase();
+ return v !== 'private';
+}
+
+function publicSocials(sp) {
+ const links = sp?.socialLinks && typeof sp.socialLinks === 'object' ? sp.socialLinks : {};
+ return {
+ linkedin: trimText(links.linkedin, 400),
+ twitter: trimText(links.twitter, 400),
+ website: trimText(links.website, 400),
+ };
+}
+
+function hasAnySocial(socials) {
+ return Boolean(socials?.linkedin || socials?.twitter || socials?.website);
+}
+
+function serializePublicSpeaker(sp, { bioMax = BIO_MAX } = {}) {
+ if (!sp) return null;
+ const publicProfile = isPublicProfile(sp);
+ const socials = publicProfile ? publicSocials(sp) : { linkedin: null, twitter: null, website: null };
+ return {
+ id: String(sp._id),
+ name: fullName(sp) || 'Speaker',
+ title: trimText(sp.title, 120),
+ organizationName: trimText(sp.organizationName, 120),
+ speakerRole: trimText(sp.speakerRole, 80),
+ bio: publicProfile ? trimText(sp.bio, bioMax) : null,
+ photo: publicProfile ? trimText(sp.photo, 500) : null,
+ expertise: publicProfile && Array.isArray(sp.expertise)
+ ? sp.expertise.map((e) => String(e || '').trim()).filter(Boolean).slice(0, 12)
+ : [],
+ socialLinks: socials,
+ profileVisibility: publicProfile ? 'public' : 'private',
+ isPublic: publicProfile,
+ };
+}
+
+function sortSpeakersByName(a, b) {
+ return String(a?.name || '').localeCompare(String(b?.name || ''), undefined, { sensitivity: 'base' });
+}
+
+async function fetchEventSpeakerList(eventId) {
+ const docs = await listEventSpeakerDocs(eventId, { limit: MAX_LIST_SPEAKERS });
+ const speakers = docs
+ .map((d) => serializePublicSpeaker(d, { bioMax: LIST_BIO_MAX }))
+ .filter(Boolean)
+ .sort(sortSpeakersByName);
+
+ if (!speakers.length) {
+ return {
+ intent: 'speaker_bio_lookup',
+ speakerQuery: null,
+ isSpeakerList: true,
+ speakers: [],
+ candidates: [],
+ sessions: [],
+ meetSessions: [],
+ needsClarification: false,
+ note: 'No speakers are linked on the programme yet. Suggest asking for a name or checking the agenda.',
+ };
+ }
+
+ return {
+ intent: 'speaker_bio_lookup',
+ speakerQuery: null,
+ isSpeakerList: true,
+ speakers,
+ candidates: [],
+ sessions: [],
+ meetSessions: [],
+ needsClarification: false,
+ note: 'List the speakers from DATA.speakers only. Invite the user to ask about a specific person for a fuller bio. Do not invent names.',
+ };
+}
+
+function sessionLooksLikeMeetAndGreet(session) {
+ if (!session) return false;
+ const bits = [
+ session.type,
+ session.title,
+ session.description,
+ ...(Array.isArray(session.topics)
+ ? session.topics.flatMap((t) => [t?.title, t?.type, t?.role])
+ : []),
+ ]
+ .map((x) => String(x || ''))
+ .join(' ');
+ return MEET_RE.test(bits);
+}
+
+/**
+ * @param {{ intent: string, eventId: object|string, entities?: object }} opts
+ */
+async function fetchSpeakerData({ intent, eventId, entities = {} } = {}) {
+ const intentKey = String(intent || '');
+ const speakerHint = stripNameSalutations(entityStr(entities, 'speaker_name'));
+
+ if (!speakerHint) {
+ // "Who are the speakers?" → programme list; contact/meet still need a name
+ if (intentKey === 'speaker_bio_lookup') {
+ return fetchEventSpeakerList(eventId);
+ }
+ return {
+ intent: intentKey,
+ speakerQuery: null,
+ isSpeakerList: false,
+ speakers: [],
+ candidates: [],
+ sessions: [],
+ meetSessions: [],
+ note: 'No speaker name was provided. Ask which speaker they mean. Do not invent names.',
+ needsClarification: true,
+ };
+ }
+
+ const resolved = await resolveEventSpeakers(eventId, speakerHint);
+ const candidates = (resolved.candidates || []).slice(0, MAX_CANDIDATES);
+
+ if (!candidates.length) {
+ return {
+ intent: intentKey,
+ speakerQuery: speakerHint,
+ speakers: [],
+ candidates: [],
+ sessions: [],
+ meetSessions: [],
+ note: `No speaker matched "${speakerHint}". Suggest checking the spelling or browsing the agenda.`,
+ needsClarification: false,
+ };
+ }
+
+ if (candidates.length > 1) {
+ return {
+ intent: intentKey,
+ speakerQuery: speakerHint,
+ speakers: [],
+ candidates,
+ sessions: [],
+ meetSessions: [],
+ note: `Several speakers matched "${speakerHint}". Ask the attendee to pick one by full name.`,
+ needsClarification: true,
+ };
+ }
+
+ const chosenId = candidates[0].id;
+ const doc = await Speaker.findById(chosenId)
+ .select(
+ 'firstName lastName title organizationName bio photo speakerRole expertise socialLinks profileVisibility isFeatured',
+ )
+ .lean();
+
+ if (!doc) {
+ return {
+ intent: intentKey,
+ speakerQuery: speakerHint,
+ speakers: [],
+ candidates: [],
+ sessions: [],
+ meetSessions: [],
+ note: `No speaker matched "${speakerHint}".`,
+ needsClarification: false,
+ };
+ }
+
+ const speaker = serializePublicSpeaker(doc);
+ const agenda = await fetchAgendaData({
+ intent: 'agenda_by_speaker',
+ eventId,
+ entities: { speaker_name: speakerHint },
+ });
+ const sessions = Array.isArray(agenda.sessions) ? agenda.sessions : [];
+ const meetSessions = sessions.filter(sessionLooksLikeMeetAndGreet);
+
+ const base = {
+ intent: intentKey,
+ speakerQuery: speakerHint,
+ speakers: [speaker],
+ candidates: [],
+ sessions,
+ meetSessions,
+ needsClarification: false,
+ note: null,
+ };
+
+ if (intentKey === 'speaker_bio_lookup') {
+ if (!speaker.isPublic) {
+ base.note =
+ 'This speaker profile is not publicly listed. You can still share their session times from the agenda if available.';
+ } else if (!speaker.bio && !sessions.length) {
+ base.note = 'No biography is on file for this speaker. Share title/org if present, or point to the help desk.';
+ } else if (!speaker.bio) {
+ base.note = 'No biography text is on file; share title, organization, role, and their sessions.';
+ }
+ return base;
+ }
+
+ if (intentKey === 'speaker_contact_info') {
+ if (!speaker.isPublic) {
+ base.note =
+ 'This speaker profile is private. Do not share contact details. Suggest contacting the organizers or help desk.';
+ base.speakers = [
+ {
+ ...speaker,
+ bio: null,
+ photo: null,
+ expertise: [],
+ socialLinks: { linkedin: null, twitter: null, website: null },
+ },
+ ];
+ return base;
+ }
+ if (!hasAnySocial(speaker.socialLinks)) {
+ base.note =
+ 'No public social links are listed for this speaker. Do not invent email or phone. Suggest the organizers or help desk.';
+ }
+ // Never put email/phone in DATA
+ return base;
+ }
+
+ if (intentKey === 'speaker_meet_and_greet') {
+ if (meetSessions.length) {
+ base.note = null;
+ base.sessions = meetSessions;
+ } else {
+ base.note =
+ 'No dedicated meet-and-greet is listed for this speaker. Share their scheduled sessions instead, or suggest the help desk / networking areas.';
+ }
+ return base;
+ }
+
+ return base;
+}
+
+module.exports = {
+ SPEAKER_INTENTS,
+ isSpeakerIntent,
+ fetchSpeakerData,
+};
diff --git a/src/services/step1.service.js b/src/services/step1.service.js
new file mode 100644
index 0000000..93fd310
--- /dev/null
+++ b/src/services/step1.service.js
@@ -0,0 +1,309 @@
+const {
+ STEP_1_SYSTEM_PROMPT,
+ resolveIntent,
+} = require('../prompts/step1');
+const {
+ createClient,
+ converse,
+ responseText,
+ getRegion,
+ getRouterModelId,
+} = require('../utils/bedrock');
+const { looksLikeTouristPlacesQuestion } = require('../utils/placeCategoryFilter');
+const { looksLikeWhoToContact, looksLikeHotelCheckIn } = require('../utils/organizerContact');
+const { stripNameSalutations } = require('../utils/nameSalutations');
+
+const RESCUE_FROM_INTENTS = new Set(['out_of_scope', 'chitchat_greeting']);
+const REGISTER_RESCUE_FROM = new Set([
+ 'out_of_scope',
+ 'chitchat_greeting',
+ 'ticket_status_lookup',
+ 'venue_wayfinding',
+]);
+
+/**
+ * "How can I register / buy a ticket" — not desk wayfinding and not existing-status lookup.
+ * @param {string} message
+ */
+function looksLikeHowToRegisterQuestion(message) {
+ const t = String(message || '')
+ .toLowerCase()
+ .replace(/\s+/g, ' ')
+ .trim();
+ if (!t) return false;
+
+ const askingHowToJoin = /\bhow (can|do|to) (i |we )?(register|sign ?up|get (a )?(ticket|pass)|buy (a )?(ticket|pass))\b/.test(
+ t,
+ );
+ if (
+ !askingHowToJoin
+ && /\b(where|find|locate|direction|way to|desk|counter|booth|hall)\b/.test(t)
+ && /\bregistration\b/.test(t)
+ ) {
+ return false;
+ }
+ if (/\b(my (registration|ticket|status)|already registered|check[- ]?in status|payment status)\b/.test(t)) {
+ return false;
+ }
+
+ return (
+ askingHowToJoin
+ || /\b(want to|need to|like to|trying to) (register|sign ?up|buy (a )?(ticket|pass))\b/.test(t)
+ || /\b(buy|purchase) (a |an )?(ticket|pass|registration)\b/.test(t)
+ || /\bregistration (link|form|page|url|website|online)\b/.test(t)
+ || /^(how can i register|how do i register|how to register|can i register|register|sign up|sign-up)( for (the |this )?event)?\??$/.test(t)
+ );
+}
+
+const ACTION_CATEGORIES = new Set(['read', 'mutation', 'escalation', 'direct_reply']);
+
+function emptyEntities() {
+ return {
+ speaker_name: null,
+ session_name: null,
+ session_id: null,
+ location: null,
+ company_name: null,
+ time_reference: null,
+ topic: null,
+ };
+}
+
+function fallbackClassification(reason) {
+ return {
+ primary_intent: 'out_of_scope',
+ confidence: 0,
+ action_category: 'direct_reply',
+ entities: emptyEntities(),
+ context_resolution: {
+ is_followup: false,
+ resolved_from_history: false,
+ },
+ requires_data_fetch: false,
+ _fallback: true,
+ _reason: reason || 'unknown',
+ };
+}
+
+function stripJsonFences(raw) {
+ let text = String(raw || '').trim();
+ if (text.startsWith('```')) {
+ text = text.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '').trim();
+ }
+ const start = text.indexOf('{');
+ const end = text.lastIndexOf('}');
+ if (start >= 0 && end > start) {
+ text = text.slice(start, end + 1);
+ }
+ return text;
+}
+
+function normalizeEntities(raw) {
+ const base = emptyEntities();
+ if (!raw || typeof raw !== 'object') return base;
+ for (const key of Object.keys(base)) {
+ if (raw[key] == null || raw[key] === '') {
+ base[key] = null;
+ } else {
+ const value = typeof raw[key] === 'string' ? raw[key].trim() : raw[key];
+ base[key] = key === 'speaker_name' && typeof value === 'string'
+ ? stripNameSalutations(value) || null
+ : value;
+ }
+ }
+ return base;
+}
+
+function normalizeClassification(parsed) {
+ if (!parsed || typeof parsed !== 'object') {
+ return fallbackClassification('invalid_json_object');
+ }
+
+ const rawPrimary = String(parsed.primary_intent || '').trim();
+ const primary = resolveIntent(rawPrimary);
+ if (!primary) {
+ return fallbackClassification(`unknown_intent:${rawPrimary || '(empty)'}`);
+ }
+
+ let confidence = Number(parsed.confidence);
+ if (!Number.isFinite(confidence)) confidence = 0;
+ confidence = Math.max(0, Math.min(1, confidence));
+
+ let action = String(parsed.action_category || '').trim();
+ if (primary === 'agenda_bookmark_add' || primary === 'agenda_bookmark_remove') {
+ action = 'mutation';
+ } else if (!ACTION_CATEGORIES.has(action)) {
+ action = primary.startsWith('agenda_') ? 'read' : 'direct_reply';
+ }
+
+ const ctx = parsed.context_resolution && typeof parsed.context_resolution === 'object'
+ ? parsed.context_resolution
+ : {};
+
+ const entities = normalizeEntities(parsed.entities);
+ // Aliased topic intents should still fetch agenda data for Call 2
+ const requiresData =
+ parsed.requires_data_fetch != null
+ ? Boolean(parsed.requires_data_fetch)
+ : primary.startsWith('agenda_');
+
+ return {
+ primary_intent: primary,
+ confidence,
+ action_category: action,
+ entities,
+ context_resolution: {
+ is_followup: Boolean(ctx.is_followup),
+ resolved_from_history: Boolean(ctx.resolved_from_history),
+ },
+ requires_data_fetch: requiresData,
+ ...(rawPrimary !== primary ? { _aliased_from: rawPrimary } : {}),
+ };
+}
+
+function formatHistory(history) {
+ return (Array.isArray(history) ? history.slice(-6) : [])
+ .map((m) => {
+ const role = m?.role === 'assistant' ? 'Assistant' : 'User';
+ const text = String(m?.text || '').trim();
+ return text ? `${role}: ${text}` : null;
+ })
+ .filter(Boolean)
+ .join('\n');
+}
+
+/**
+ * Run Call 1 intent classification.
+ * @param {{ message: string, history?: { role: string, text: string }[] }} opts
+ * @returns {Promise<{ classification: object, modelId: string|null, usage: { inputTokens: number, outputTokens: number }, raw: string|null }>}
+ */
+async function classifyIntent({ message, history }) {
+ const modelId = getRouterModelId();
+ const client = createClient(getRegion());
+ const recentContext = formatHistory(history);
+
+ const userText = [
+ 'RECENT CONVERSATION:',
+ recentContext || '(none)',
+ '',
+ 'CURRENT USER MESSAGE:',
+ String(message || '').trim(),
+ '',
+ 'Respond with JSON only.',
+ ].join('\n');
+
+ try {
+ const response = await converse(client, modelId, {
+ system: STEP_1_SYSTEM_PROMPT,
+ messages: [{ role: 'user', content: [{ text: userText }] }],
+ inferenceConfig: { temperature: 0, topP: 0.1, maxTokens: 400 },
+ logLabel: 'call1',
+ });
+
+ const raw = responseText(response);
+ let parsed;
+ try {
+ parsed = JSON.parse(stripJsonFences(raw));
+ } catch {
+ parsed = null;
+ }
+
+ let classification = parsed
+ ? normalizeClassification(parsed)
+ : fallbackClassification('json_parse_failed');
+
+ // Local landmark questions (church/temple/fort/beach/…) must not fall through as trivia.
+ if (
+ RESCUE_FROM_INTENTS.has(classification.primary_intent) &&
+ looksLikeTouristPlacesQuestion(message)
+ ) {
+ classification = {
+ ...classification,
+ primary_intent: 'tourist_places',
+ action_category: 'read',
+ requires_data_fetch: true,
+ _rescued_from: classification.primary_intent,
+ _rescue: 'tourist_places_keyword',
+ };
+ }
+
+ // How-to-register is in-scope; do not treat as OOS or existing-status lookup.
+ if (
+ REGISTER_RESCUE_FROM.has(classification.primary_intent) &&
+ looksLikeHowToRegisterQuestion(message)
+ ) {
+ classification = {
+ ...classification,
+ primary_intent: 'ticket_how_to_register',
+ action_category: 'read',
+ requires_data_fetch: true,
+ _rescued_from: classification.primary_intent,
+ _rescue: 'how_to_register_keyword',
+ };
+ }
+
+ // Explicit "who to contact" — always share organizer contact, even as a follow-up.
+ if (looksLikeWhoToContact(message)) {
+ classification = {
+ ...classification,
+ primary_intent: 'human_agent_escalation',
+ action_category: 'escalation',
+ requires_data_fetch: true,
+ _rescued_from: classification.primary_intent,
+ _rescue: 'who_to_contact_keyword',
+ };
+ } else if (looksLikeHotelCheckIn(message)) {
+ classification = {
+ ...classification,
+ primary_intent: 'accommodation',
+ action_category: 'read',
+ requires_data_fetch: true,
+ _rescued_from: classification.primary_intent,
+ _rescue: 'hotel_checkin_keyword',
+ };
+ }
+
+ console.log('[step1][call1][classification]', {
+ modelId,
+ parsedOk: Boolean(parsed),
+ classification,
+ usage: {
+ inputTokens: Number(response?.usage?.inputTokens || 0),
+ outputTokens: Number(response?.usage?.outputTokens || 0),
+ },
+ });
+
+ return {
+ classification,
+ modelId,
+ usage: {
+ inputTokens: Number(response?.usage?.inputTokens || 0),
+ outputTokens: Number(response?.usage?.outputTokens || 0),
+ },
+ raw,
+ };
+ } catch (err) {
+ console.warn('[step1][call1] failed:', err?.message || err);
+ return {
+ classification: fallbackClassification(`llm_error:${err?.message || 'unknown'}`),
+ modelId,
+ usage: { inputTokens: 0, outputTokens: 0 },
+ raw: null,
+ };
+ }
+}
+
+function formatClassificationReply(classification) {
+ const intent = classification?.primary_intent || 'out_of_scope';
+ const action = classification?.action_category || 'direct_reply';
+ const conf = Number(classification?.confidence);
+ const confLabel = Number.isFinite(conf) ? conf.toFixed(2) : '0.00';
+ return `Intent: ${intent} · ${action} · ${confLabel}`;
+}
+
+module.exports = {
+ classifyIntent,
+ formatClassificationReply,
+ normalizeClassification,
+ fallbackClassification,
+};
diff --git a/src/services/step2.service.js b/src/services/step2.service.js
new file mode 100644
index 0000000..db99e0d
--- /dev/null
+++ b/src/services/step2.service.js
@@ -0,0 +1,1753 @@
+const { buildCall2SystemPrompt, usesSessionCards } = require('../prompts/call2/agenda');
+const { buildVenueCall2SystemPrompt, usesPlaceCards } = require('../prompts/call2/venue');
+const { buildTicketCall2SystemPrompt, usesQrCard } = require('../prompts/call2/ticket');
+const {
+ buildSpeakerCall2SystemPrompt,
+ usesSpeakerCards,
+ usesSpeakerSessionCards,
+} = require('../prompts/call2/speaker');
+const {
+ buildExhibitorCall2SystemPrompt,
+ usesExhibitorCards,
+} = require('../prompts/call2/exhibitor');
+const { buildTechCall2SystemPrompt } = require('../prompts/call2/tech');
+const { buildSystemCall2SystemPrompt } = require('../prompts/call2/system');
+const {
+ buildLocalCall2SystemPrompt,
+ usesLocalPlaceCards,
+ usesLocalHotelCards,
+} = require('../prompts/call2/local');
+const {
+ createClient,
+ converse,
+ responseText,
+ getRegion,
+} = require('../utils/bedrock');
+const { buildSessionCards, resolveSessionIds } = require('../utils/sessionCards');
+const { buildPlaceCards, resolvePlaceIds } = require('../utils/placeCards');
+const { buildMapFileCard } = require('../utils/faqAnswer');
+const {
+ buildAccommodationCards,
+ resolveHotelIds,
+} = require('../utils/accommodationCards');
+const { buildTicketQrCard } = require('../utils/ticketCards');
+const { buildRegistrationCtaCards } = require('../utils/registrationLinks');
+const { buildSpeakerCards, resolveSpeakerIdsFromModel } = require('../utils/speakerCards');
+const { buildExhibitorCards, resolveExhibitorIdsFromModel } = require('../utils/exhibitorCards');
+const {
+ compactEntities,
+ slimDataForLlm,
+ slimVenueDataForLlm,
+ slimTicketDataForLlm,
+ slimSpeakerDataForLlm,
+ slimExhibitorDataForLlm,
+ slimTechDataForLlm,
+ slimSystemDataForLlm,
+ slimLocalDataForLlm,
+} = require('../utils/call2Context');
+const {
+ STREAMING_STATIC_TIPS,
+ ACCOUNT_STATIC_TIPS,
+} = require('./techData.service');
+const { DEFAULT_QUICK_REPLIES } = require('../utils/quickReplies');
+
+const DEFAULT_CALL2_MODEL_ID = 'anthropic.claude-3-haiku-20240307-v1:0';
+const CALL2_INFERENCE = { temperature: 0.45, topP: 0.9, maxTokens: 1000 };
+const FALLBACK_REPLY =
+ "I couldn't load the agenda details just now. Please try again in a moment, or ask about a specific session or speaker.";
+const VENUE_FALLBACK_REPLY =
+ 'I could not find venue details for that. Please ask at the venue help desk.';
+const TICKET_FALLBACK_REPLY =
+ 'I could not load your ticket details just now. Please try again, or ask at the help desk.';
+const TICKET_REFUSE_REPLY =
+ 'I can’t transfer or upgrade tickets in this chat. Please contact the event organizer or on-site help desk. I can show or email your QR, check your registration status, or share a receipt.';
+const TICKET_REGISTER_NONE_REPLY =
+ 'I don’t have a registration link on file. Please check the event website or ask at the help desk.';
+const SPEAKER_FALLBACK_REPLY =
+ 'I could not find that speaker just now. Try another name spelling, or ask about a session on the agenda.';
+const EXHIBITOR_FALLBACK_REPLY =
+ 'I could not find that exhibitor just now. Try another company name, or ask by category.';
+const EXHIBITOR_BOOKING_REFUSE_REPLY =
+ 'I can’t book meetings with exhibitors in this chat. Please visit their booth or ask exhibitor staff / the help desk. I can help you find a booth location.';
+const EXHIBITOR_DEMO_NONE_REPLY =
+ 'I don’t have a demo schedule listed for that. Please check at the exhibitor booth or ask at the help desk.';
+const TECH_FALLBACK_REPLY =
+ 'I couldn’t load tech help just now. Please try again, or ask at the venue help desk.';
+const WIFI_NONE_REPLY =
+ 'Wi‑Fi details aren’t listed here yet. Please check venue signage or ask at the help desk.';
+const FEATURE_NONE_REPLY =
+ 'I don’t have specific app how‑to notes on file. Which feature do you need help with? You can also check in‑app help or ask the organizers.';
+const SYSTEM_FALLBACK_REPLY =
+ 'Thanks for your message. Please ask at the venue help desk if you need on-site assistance.';
+const EMERGENCY_SAFETY_LINE =
+ 'If this is an emergency, contact venue security or medical staff immediately.';
+const LOCAL_FALLBACK_REPLY =
+ 'I couldn’t load local recommendations just now. Please ask at the venue help desk.';
+const HOST_MANAGED_ACCOMMODATION_REPLY =
+ 'Your organizer is arranging accommodation for this event. For your hotel, check-in dates, and how to confirm your room, please reach out to the event team.';
+
+function formatStaticTipsReply(intro, tips) {
+ const list = (Array.isArray(tips) ? tips : []).filter(Boolean);
+ if (!list.length) return intro;
+ const body = list.map((t, i) => `${i + 1}. ${t}`).join('\n');
+ return `${intro}\n${body}\n\nIf it still doesn’t work, contact event support or the help desk.`;
+}
+
+function getCall2ModelId() {
+ return process.env.BEDROCK_CALL2_MODEL_ID || DEFAULT_CALL2_MODEL_ID;
+}
+
+function formatHistory(history) {
+ return (Array.isArray(history) ? history.slice(-8) : [])
+ .map((m) => {
+ const role = m?.role === 'assistant' ? 'Assistant' : 'User';
+ const text = String(m?.text || '').trim().slice(0, 800);
+ return text ? `${role}: ${text}` : null;
+ })
+ .filter(Boolean)
+ .join('\n');
+}
+
+function stripFences(raw) {
+ let text = String(raw || '').trim();
+ if (text.startsWith('```')) {
+ text = text.replace(/^```(?:\w+)?\s*/i, '').replace(/\s*```$/i, '').trim();
+ }
+ const start = text.indexOf('{');
+ const end = text.lastIndexOf('}');
+ if (start >= 0 && end > start) {
+ text = text.slice(start, end + 1);
+ }
+ return text;
+}
+
+/** Escape raw control chars inside JSON strings (Haiku often emits literal newlines). */
+function repairJsonControlChars(text) {
+ let out = '';
+ let inString = false;
+ let escaped = false;
+ for (let i = 0; i < text.length; i += 1) {
+ const ch = text[i];
+ if (inString) {
+ if (escaped) {
+ out += ch;
+ escaped = false;
+ continue;
+ }
+ if (ch === '\\') {
+ out += ch;
+ escaped = true;
+ continue;
+ }
+ if (ch === '"') {
+ inString = false;
+ out += ch;
+ continue;
+ }
+ if (ch === '\n') {
+ out += '\\n';
+ continue;
+ }
+ if (ch === '\r') {
+ out += '\\r';
+ continue;
+ }
+ if (ch === '\t') {
+ out += '\\t';
+ continue;
+ }
+ out += ch;
+ } else {
+ if (ch === '"') inString = true;
+ out += ch;
+ }
+ }
+ return out;
+}
+
+/** Insert missing commas between JSON properties (e.g. `"..."\n "place_ids"`). */
+function repairMissingCommas(text) {
+ return String(text || '').replace(
+ /(")\s*\n(\s*")((?:message|reply|session_ids|sessionIds|place_ids|placeIds|speaker_ids|speakerIds|exhibitor_ids|exhibitorIds|show_qr|showQr)\s*")/g,
+ '$1,\n$2$3',
+ );
+}
+
+/**
+ * Extract a JSON string field even when the blob is not valid JSON
+ * (literal newlines, missing commas).
+ */
+function extractJsonStringField(text, field) {
+ const key = `"${field}"`;
+ const keyIdx = text.indexOf(key);
+ if (keyIdx < 0) return null;
+ let i = text.indexOf(':', keyIdx + key.length);
+ if (i < 0) return null;
+ i += 1;
+ while (i < text.length && /\s/.test(text[i])) i += 1;
+ if (text[i] !== '"') return null;
+ i += 1;
+ let out = '';
+ let escaped = false;
+ for (; i < text.length; i += 1) {
+ const ch = text[i];
+ if (escaped) {
+ if (ch === 'n') out += '\n';
+ else if (ch === 'r') out += '\r';
+ else if (ch === 't') out += '\t';
+ else out += ch;
+ escaped = false;
+ continue;
+ }
+ if (ch === '\\') {
+ escaped = true;
+ continue;
+ }
+ if (ch === '"') {
+ const after = text.slice(i + 1);
+ if (/^\s*(,|}|"(?:message|reply|session_ids|sessionIds|place_ids|placeIds|hotel_ids|hotelIds)\b)/.test(after)) {
+ return out;
+ }
+ // quote mid-content — keep it
+ out += ch;
+ continue;
+ }
+ out += ch;
+ }
+ return out || null;
+}
+
+function extractJsonArrayField(text, field) {
+ const re = new RegExp(`"${field}"\\s*:\\s*(\\[[\\s\\S]*?\\])`);
+ const m = String(text || '').match(re);
+ if (!m) return null;
+ try {
+ const arr = JSON.parse(m[1]);
+ return Array.isArray(arr) ? arr : null;
+ } catch {
+ return null;
+ }
+}
+
+function normalizeCall2Parsed(parsed) {
+ if (!parsed || typeof parsed !== 'object') return null;
+ const message = String(parsed.message || parsed.reply || '').trim();
+ const sessionIds = Array.isArray(parsed.session_ids)
+ ? parsed.session_ids
+ : Array.isArray(parsed.sessionIds)
+ ? parsed.sessionIds
+ : null;
+ const placeIds = Array.isArray(parsed.place_ids)
+ ? parsed.place_ids
+ : Array.isArray(parsed.placeIds)
+ ? parsed.placeIds
+ : null;
+ const speakerIds = Array.isArray(parsed.speaker_ids)
+ ? parsed.speaker_ids
+ : Array.isArray(parsed.speakerIds)
+ ? parsed.speakerIds
+ : null;
+ const exhibitorIds = Array.isArray(parsed.exhibitor_ids)
+ ? parsed.exhibitor_ids
+ : Array.isArray(parsed.exhibitorIds)
+ ? parsed.exhibitorIds
+ : null;
+ const hotelIds = Array.isArray(parsed.hotel_ids)
+ ? parsed.hotel_ids
+ : Array.isArray(parsed.hotelIds)
+ ? parsed.hotelIds
+ : null;
+ const showQr =
+ parsed.show_qr === true ||
+ parsed.showQr === true ||
+ parsed.show_qr === 'true' ||
+ parsed.showQr === 'true';
+ return { message, sessionIds, placeIds, speakerIds, exhibitorIds, hotelIds, showQr };
+}
+
+function emptyCall2Fields() {
+ return {
+ sessionIds: null,
+ placeIds: null,
+ speakerIds: null,
+ exhibitorIds: null,
+ hotelIds: null,
+ showQr: false,
+ };
+}
+
+/** When Haiku talks instead of wrapping JSON, keep the prose if it is attendee-facing. */
+function proseAsCall2Message(raw) {
+ let text = String(raw || '').trim();
+ if (!text) return null;
+ if (text.startsWith('```')) {
+ text = text.replace(/^```(?:\w+)?\s*/i, '').replace(/\s*```$/i, '').trim();
+ }
+ if (!text || text.startsWith('{')) return null;
+ if (looksLikeInternalNote(text)) return null;
+ if (text.length < 12) return null;
+ return text;
+}
+
+function jsonOnlyHint(schema, extra = '') {
+ const extraBit = extra ? ` ${extra}` : '';
+ return [
+ `Reply with JSON only${extraBit}: ${schema}`,
+ 'Put the full attendee answer inside "message". No markdown. No prose outside the JSON object.',
+ ].join('\n');
+}
+
+function parseCall2Json(raw) {
+ const stripped = stripFences(raw);
+ const attempts = [
+ stripped,
+ repairJsonControlChars(stripped),
+ repairMissingCommas(repairJsonControlChars(stripped)),
+ ];
+ for (const candidate of attempts) {
+ try {
+ const parsed = normalizeCall2Parsed(JSON.parse(candidate));
+ if (parsed?.message) return parsed;
+ } catch {
+ // try next
+ }
+ }
+
+ // Last resort: pull fields without full JSON parse
+ const message =
+ extractJsonStringField(stripped, 'message') ||
+ extractJsonStringField(stripped, 'reply');
+ if (message && String(message).trim()) {
+ const showQrMatch = String(stripped).match(/"show_qr"\s*:\s*(true|false)/i)
+ || String(stripped).match(/"showQr"\s*:\s*(true|false)/i);
+ return {
+ message: String(message).trim(),
+ sessionIds:
+ extractJsonArrayField(stripped, 'session_ids') ||
+ extractJsonArrayField(stripped, 'sessionIds'),
+ placeIds:
+ extractJsonArrayField(stripped, 'place_ids') ||
+ extractJsonArrayField(stripped, 'placeIds'),
+ speakerIds:
+ extractJsonArrayField(stripped, 'speaker_ids') ||
+ extractJsonArrayField(stripped, 'speakerIds'),
+ exhibitorIds:
+ extractJsonArrayField(stripped, 'exhibitor_ids') ||
+ extractJsonArrayField(stripped, 'exhibitorIds'),
+ hotelIds:
+ extractJsonArrayField(stripped, 'hotel_ids') ||
+ extractJsonArrayField(stripped, 'hotelIds'),
+ showQr: showQrMatch ? String(showQrMatch[1]).toLowerCase() === 'true' : false,
+ };
+ }
+
+ const prose = proseAsCall2Message(raw);
+ if (prose) return { message: prose, ...emptyCall2Fields() };
+ return null;
+}
+
+/** True when text looks like an internal DATA.note, not an attendee reply. */
+function looksLikeInternalNote(text) {
+ const s = String(text || '');
+ return (
+ /DATA\.(origin|location|faqs|note|sessions)/i.test(s) ||
+ /answer with general transport knowledge/i.test(s) ||
+ /direct the attendee/i.test(s) ||
+ /do not invent/i.test(s) ||
+ /do not refuse/i.test(s) ||
+ /no .*faq is configured/i.test(s) ||
+ /share the registration url/i.test(s) ||
+ /do not name the event/i.test(s)
+ );
+}
+
+/**
+ * @param {{ intent: string, message: string, history?: object[], entities?: object, data?: object }} opts
+ */
+async function generateAgendaReply({ intent, message, history, entities, data }) {
+ const system = buildCall2SystemPrompt(intent);
+ if (!system) {
+ return {
+ reply: FALLBACK_REPLY,
+ cards: [],
+ sessionIds: [],
+ modelId: null,
+ usage: { inputTokens: 0, outputTokens: 0 },
+ };
+ }
+
+ const modelId = getCall2ModelId();
+ const client = createClient(getRegion());
+ const recent = formatHistory(history);
+ const cardMode = usesSessionCards(intent);
+ const slimEntities = compactEntities(entities);
+ const slimData = slimDataForLlm(data, intent);
+
+ const userParts = [
+ 'USER:',
+ String(message || '').trim(),
+ ];
+ if (recent) {
+ userParts.push('', 'HISTORY:', recent);
+ }
+ if (Object.keys(slimEntities).length) {
+ userParts.push('', 'ENTITIES:', JSON.stringify(slimEntities));
+ }
+ userParts.push(
+ '',
+ 'DATA:',
+ JSON.stringify(slimData),
+ '',
+ cardMode
+ ? jsonOnlyHint('{"message":string,"session_ids":string[]}', 'using DATA.sessions[].id')
+ : jsonOnlyHint('{"message":string,"session_ids":[]}'),
+ );
+ const userText = userParts.join('\n');
+
+ console.log('[step2][call2][context]', {
+ intent,
+ sessionsIn: Array.isArray(data?.sessions) ? data.sessions.length : 0,
+ sessionsOut: Array.isArray(slimData.sessions) ? slimData.sessions.length : 0,
+ contextChars: userText.length,
+ });
+
+ try {
+ const response = await converse(client, modelId, {
+ system,
+ messages: [{ role: 'user', content: [{ text: userText }] }],
+ inferenceConfig: CALL2_INFERENCE,
+ logLabel: 'call2',
+ });
+
+ const raw = responseText(response);
+ const parsed = parseCall2Json(raw);
+
+ let reply = parsed?.message || '';
+ let sessionIds = [];
+
+ if (cardMode) {
+ // Prefer model ids when valid; if empty/invalid, fall back to DATA.sessions order.
+ // (Models often return session_ids:[] with a "Here are…" intro — cards would then be blank.)
+ sessionIds = resolveSessionIds(parsed?.sessionIds, data);
+ if (!parsed && !reply) reply = FALLBACK_REPLY;
+ } else if (parsed && Array.isArray(parsed.sessionIds)) {
+ const allowed = new Set(
+ (Array.isArray(data?.sessions) ? data.sessions : []).map((s) => String(s.id)),
+ );
+ sessionIds = parsed.sessionIds
+ .map((id) => String(id || '').trim())
+ .filter((id) => allowed.has(id));
+ }
+
+ if (!sessionIds.length) {
+ const note = String(data?.note || '').trim();
+ const looksLikeListing = /here are|following|your (personal )?schedule|in your schedule/i.test(
+ reply,
+ );
+ if (note && (!reply || looksLikeListing)) {
+ reply =
+ intent === 'agenda_personal_schedule'
+ ? "You don't have any saved sessions yet. Bookmark sessions in the event agenda to see them here."
+ : note;
+ } else if (!reply) {
+ reply = note || 'I could not find matching sessions for that.';
+ }
+ } else if (!reply) {
+ reply = 'Here are the relevant sessions:';
+ }
+ if (/no matching sessions found/i.test(reply)) {
+ reply = "I don't have the programme listed yet. Try asking about a day or speaker.";
+ }
+
+ const cards = cardMode
+ ? buildSessionCards(sessionIds, data, {
+ includeResources: intent === 'agenda_download_materials' || Boolean(data?.includeResources),
+ })
+ : [];
+
+ console.log('[step2][call2][parsed]', {
+ intent,
+ messagePreview: reply.slice(0, 160),
+ sessionIds,
+ cardCount: cards.length,
+ });
+
+ return {
+ reply: reply.slice(0, 1200),
+ cards,
+ sessionIds,
+ modelId,
+ usage: {
+ inputTokens: Number(response?.usage?.inputTokens || 0),
+ outputTokens: Number(response?.usage?.outputTokens || 0),
+ },
+ };
+ } catch (err) {
+ console.warn('[step2] Call 2 failed:', err?.message || err);
+ const sessionIds = cardMode ? resolveSessionIds(null, data) : [];
+ const cards = cardMode
+ ? buildSessionCards(sessionIds, data, {
+ includeResources: intent === 'agenda_download_materials',
+ })
+ : [];
+ const emptyNote =
+ intent === 'agenda_personal_schedule'
+ ? "You don't have any saved sessions yet. Bookmark sessions in the event agenda to see them here."
+ : String(data?.note || '').trim();
+ return {
+ reply: cards.length
+ ? 'Here are the relevant sessions:'
+ : emptyNote || FALLBACK_REPLY,
+ cards,
+ sessionIds,
+ modelId,
+ usage: { inputTokens: 0, outputTokens: 0 },
+ };
+ }
+}
+
+/**
+ * @param {{ intent: string, message: string, history?: object[], entities?: object, data?: object }} opts
+ */
+async function generateVenueReply({ intent, message, history, entities, data }) {
+ const system = buildVenueCall2SystemPrompt(intent);
+ if (!system) {
+ return {
+ reply: VENUE_FALLBACK_REPLY,
+ cards: [],
+ placeIds: [],
+ modelId: null,
+ usage: { inputTokens: 0, outputTokens: 0 },
+ };
+ }
+
+ const modelId = getCall2ModelId();
+ const client = createClient(getRegion());
+ const recent = formatHistory(history);
+ const placeMode = usesPlaceCards(intent) && Array.isArray(data?.places) && data.places.length;
+ const slimEntities = compactEntities(entities);
+ const slimData = slimVenueDataForLlm(data);
+
+ const userParts = ['USER:', String(message || '').trim()];
+ if (recent) userParts.push('', 'HISTORY:', recent);
+ if (Object.keys(slimEntities).length) {
+ userParts.push('', 'ENTITIES:', JSON.stringify(slimEntities));
+ }
+ userParts.push(
+ '',
+ 'DATA:',
+ JSON.stringify(slimData),
+ '',
+ placeMode
+ ? jsonOnlyHint('{"message":string,"place_ids":string[]}', 'using DATA.places[].id when useful')
+ : jsonOnlyHint('{"message":string,"place_ids":[]}'),
+ );
+ const userText = userParts.join('\n');
+
+ console.log('[step2][venue][context]', {
+ intent,
+ faqCount: Array.isArray(data?.faqs) ? data.faqs.length : 0,
+ placesIn: Array.isArray(data?.places) ? data.places.length : 0,
+ contextChars: userText.length,
+ });
+
+ try {
+ const response = await converse(client, modelId, {
+ system,
+ messages: [{ role: 'user', content: [{ text: userText }] }],
+ inferenceConfig: CALL2_INFERENCE,
+ logLabel: 'call2-venue',
+ });
+
+ const raw = responseText(response);
+ const parsed = parseCall2Json(raw);
+ let reply = parsed?.message || '';
+ if (looksLikeInternalNote(reply)) reply = '';
+ let placeIds = [];
+
+ if (placeMode) {
+ placeIds = resolvePlaceIds(parsed?.placeIds, data);
+ }
+
+ if (!reply) {
+ // Never surface DATA.note to attendees — it is model instruction text.
+ if (intent === 'venue_map_request' && (data?.location || data?.eventName)) {
+ const bits = [data.eventName, data.location].filter(Boolean);
+ reply = bits.length
+ ? `I don't have a venue map on file. The event venue is: ${bits.join(' — ')}.`
+ : VENUE_FALLBACK_REPLY;
+ } else if (intent === 'venue_wayfinding') {
+ const hallNames = (data?.halls || []).map((h) => h.hall).filter(Boolean);
+ const roomNames = (data?.rooms || []).map((r) => r.room).filter(Boolean);
+ const listed = [...new Set([...hallNames, ...roomNames])].slice(0, 12);
+ reply = listed.length
+ ? `Here are the halls/rooms I know: ${listed.join(', ')}. For more directions, please ask at the venue help desk.`
+ : 'For directions inside the venue, please ask at the venue help desk.';
+ } else if (intent === 'venue_travel_directions' && (data?.location || data?.eventName)) {
+ const dest = data.location || 'the event venue';
+ const from = data.origin ? ` from ${data.origin}` : '';
+ reply = `I couldn't format travel details just now. The venue is ${dest}. Please check train/flight options${from}, then local transport to the venue, and verify live schedules.`;
+ } else {
+ reply = VENUE_FALLBACK_REPLY;
+ }
+ }
+
+ const cards = placeMode ? buildPlaceCards(placeIds, data) : [];
+ if (intent === 'venue_map_request' && data?.mapMedia?.url) {
+ const mapCard = buildMapFileCard(data.mapMedia);
+ if (mapCard) cards.push(mapCard);
+ if (!reply || looksLikeInternalNote(reply)) {
+ reply = 'Here is the venue floor plan / map. You can view or download it below.';
+ }
+ }
+
+ console.log('[step2][venue][parsed]', {
+ intent,
+ messagePreview: reply.slice(0, 160),
+ placeIds,
+ cardCount: cards.length,
+ });
+
+ return {
+ reply: reply.slice(0, 1600),
+ cards,
+ placeIds,
+ modelId,
+ usage: {
+ inputTokens: Number(response?.usage?.inputTokens || 0),
+ outputTokens: Number(response?.usage?.outputTokens || 0),
+ },
+ };
+ } catch (err) {
+ console.warn('[step2] Venue Call 2 failed:', err?.message || err);
+ return {
+ reply: VENUE_FALLBACK_REPLY,
+ cards: [],
+ placeIds: [],
+ modelId,
+ usage: { inputTokens: 0, outputTokens: 0 },
+ };
+ }
+}
+
+function ticketEmailOutcomeReply(data) {
+ if (data?.emailSent && data?.emailTo) {
+ return `I've emailed your ticket QR and details to ${data.emailTo}.`;
+ }
+ if (data?.emailError === 'rate_limited') {
+ return 'I emailed your ticket recently. Please check your inbox (and spam), or ask again in a minute.';
+ }
+ if (data?.emailError === 'smtp_not_configured') {
+ return 'I can’t send email right now. I can still show your QR in chat if you ask.';
+ }
+ if (data?.emailError === 'missing_email') {
+ return 'I couldn’t find a registered email on your account. Please contact the organizers.';
+ }
+ return 'I couldn’t send the email just now. Please try again, or ask me to show your QR in chat.';
+}
+
+function contactReachLine(data) {
+ const email = String(data?.contact?.email || '').trim();
+ const phone = String(data?.contact?.phone || '').trim();
+ return [email && `email ${email}`, phone && `call ${phone}`].filter(Boolean).join(' or ');
+}
+
+function ticketHowToRegisterFallback(data) {
+ const links = Array.isArray(data?.registrationLinks) ? data.registrationLinks : [];
+ const reach = contactReachLine(data);
+ const faq = Array.isArray(data?.faqs)
+ ? data.faqs.map((f) => String(f?.a || '').trim()).filter(Boolean)[0] || ''
+ : '';
+ if (faq && !/https?:\/\//i.test(faq) && !/i can only (assist|help)/i.test(faq)) {
+ return reach ? `${faq} Questions? ${reach}.` : faq;
+ }
+ if (links.length > 1) {
+ return reach
+ ? `Here are the registration options — tap a button to open one. Questions? ${reach}.`
+ : 'Here are the registration options — tap a button to open one.';
+ }
+ if (links.length === 1) {
+ return reach
+ ? `Tap below to register. Questions? ${reach}.`
+ : 'Tap below to register.';
+ }
+ if (reach) return `You can register with the organizers — ${reach}.`;
+ return TICKET_REGISTER_NONE_REPLY;
+}
+
+function ticketHowToRegisterResult(data) {
+ return {
+ reply: ticketHowToRegisterFallback(data),
+ cards: buildRegistrationCtaCards(data?.registrationLinks),
+ modelId: null,
+ usage: { inputTokens: 0, outputTokens: 0 },
+ };
+}
+
+function sanitizeOutOfScopeReply(reply, eventName) {
+ let text = String(reply || '').trim();
+ text = text.replace(
+ /^(i('m| am) afraid[, ]+)?i can only (assist|help) with (the )?[^.!?]{0,100}(event details)?[.!]+\s*/i,
+ '',
+ );
+ const name = String(eventName || '').trim();
+ if (name) {
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ text = text.replace(new RegExp(escaped, 'gi'), '').replace(/\s{2,}/g, ' ').trim();
+ text = text.replace(/\s+,/g, ',').replace(/^[,.\s]+/, '').trim();
+ }
+ if (!text || text.length < 8) {
+ return systemDeterministicFallback('out_of_scope');
+ }
+ return text;
+}
+
+/**
+ * @param {{ intent: string, message: string, history?: object[], entities?: object, data?: object }} opts
+ */
+async function generateTicketReply({ intent, message, history, entities, data }) {
+ // Real event links only — never let the model invent a registration URL.
+ if (intent === 'ticket_how_to_register') {
+ return ticketHowToRegisterResult(data);
+ }
+
+ const system = buildTicketCall2SystemPrompt(intent);
+ if (!system) {
+ return {
+ reply: TICKET_FALLBACK_REPLY,
+ cards: [],
+ modelId: null,
+ usage: { inputTokens: 0, outputTokens: 0 },
+ };
+ }
+
+ const refuse = data?.policy === 'refuse_contact_organizer'
+ || intent === 'ticket_transfer_request'
+ || intent === 'ticket_upgrade';
+
+ if (refuse) {
+ return {
+ reply: TICKET_REFUSE_REPLY,
+ cards: [],
+ modelId: null,
+ usage: { inputTokens: 0, outputTokens: 0 },
+ };
+ }
+
+ const qrMode = usesQrCard(intent) && Boolean(data?.qr?.imageUrl);
+
+ // Email send outcome is authoritative — never let Call 2 invent SMTP failure/success.
+ if (intent === 'ticket_email_qr') {
+ const reply = ticketEmailOutcomeReply(data);
+ const cards = qrMode ? buildTicketQrCard(data) : [];
+ console.log('[step2][ticket][email]', {
+ emailSent: data?.emailSent,
+ emailError: data?.emailError,
+ emailTo: data?.emailTo,
+ cardCount: cards.length,
+ });
+ return {
+ reply,
+ cards,
+ modelId: null,
+ usage: { inputTokens: 0, outputTokens: 0 },
+ };
+ }
+
+ const modelId = getCall2ModelId();
+ const client = createClient(getRegion());
+ const recent = formatHistory(history);
+ const slimEntities = compactEntities(entities);
+ const slimData = slimTicketDataForLlm(data);
+
+ const userParts = ['USER:', String(message || '').trim()];
+ if (recent) userParts.push('', 'HISTORY:', recent);
+ if (Object.keys(slimEntities).length) {
+ userParts.push('', 'ENTITIES:', JSON.stringify(slimEntities));
+ }
+ userParts.push(
+ '',
+ 'DATA:',
+ JSON.stringify(slimData),
+ '',
+ jsonOnlyHint('{"message":string,"show_qr":boolean}'),
+ );
+ const userText = userParts.join('\n');
+
+ console.log('[step2][ticket][context]', {
+ intent,
+ found: data?.found,
+ emailSent: data?.emailSent,
+ contextChars: userText.length,
+ });
+
+ try {
+ const response = await converse(client, modelId, {
+ system,
+ messages: [{ role: 'user', content: [{ text: userText }] }],
+ inferenceConfig: CALL2_INFERENCE,
+ logLabel: 'call2-ticket',
+ });
+
+ const raw = responseText(response);
+ const parsed = parseCall2Json(raw);
+ let reply = parsed?.message || '';
+ if (looksLikeInternalNote(reply)) reply = '';
+
+ let showQr = Boolean(parsed?.showQr);
+ if (intent === 'ticket_qr_display' && qrMode) showQr = true;
+ if (
+ intent === 'ticket_status_lookup'
+ || intent === 'ticket_document_receipt'
+ || intent === 'ticket_how_to_register'
+ ) {
+ showQr = false;
+ }
+
+ if (!reply) {
+ if (intent === 'ticket_document_receipt' && data?.receipt) {
+ const r = data.receipt;
+ const bits = [
+ r.ticketName ? `Ticket: ${r.ticketName}` : null,
+ r.amountLabel ? `Amount: ${r.amountLabel}` : null,
+ r.status ? `Status: ${r.status}` : null,
+ r.orderId ? `Order: ${r.orderId}` : null,
+ ].filter(Boolean);
+ reply = bits.length
+ ? `Here is your receipt:\n${bits.join('\n')}`
+ : 'I found a purchase record but could not format the receipt.';
+ } else if (intent === 'ticket_status_lookup' && data?.found) {
+ const status = data.membership?.statusLabel || data.membership?.status || 'registered';
+ const ticket = data.ticket?.name ? ` (${data.ticket.name})` : '';
+ reply = `${data.attendee?.name || 'Your registration'} is ${status}${ticket}.`;
+ } else if (intent === 'ticket_qr_display' && qrMode) {
+ reply = 'Here is your check-in QR code.';
+ } else if (intent === 'ticket_how_to_register') {
+ reply = ticketHowToRegisterFallback(data);
+ } else {
+ reply = String(data?.note || '').trim() || TICKET_FALLBACK_REPLY;
+ }
+ }
+
+ if (
+ intent === 'ticket_how_to_register'
+ && (/i can only (assist|help) with/i.test(reply) || /i('m| am) afraid/i.test(reply))
+ ) {
+ reply = ticketHowToRegisterFallback(data);
+ }
+
+ const cards = showQr && qrMode ? buildTicketQrCard(data) : [];
+
+ console.log('[step2][ticket][parsed]', {
+ intent,
+ messagePreview: reply.slice(0, 160),
+ showQr,
+ cardCount: cards.length,
+ });
+
+ return {
+ reply: reply.slice(0, 1600),
+ cards,
+ modelId,
+ usage: {
+ inputTokens: Number(response?.usage?.inputTokens || 0),
+ outputTokens: Number(response?.usage?.outputTokens || 0),
+ },
+ };
+ } catch (err) {
+ console.warn('[step2] Ticket Call 2 failed:', err?.message || err);
+ const cards = intent === 'ticket_qr_display' && qrMode ? buildTicketQrCard(data) : [];
+ let reply = TICKET_FALLBACK_REPLY;
+ if (intent === 'ticket_how_to_register') {
+ reply = ticketHowToRegisterFallback(data);
+ } else if (cards.length) {
+ reply = 'Here is your check-in QR code.';
+ }
+ return {
+ reply,
+ cards,
+ modelId,
+ usage: { inputTokens: 0, outputTokens: 0 },
+ };
+ }
+}
+
+/**
+ * @param {{ intent: string, message: string, history?: object[], entities?: object, data?: object }} opts
+ */
+async function generateSpeakerReply({ intent, message, history, entities, data }) {
+ const system = buildSpeakerCall2SystemPrompt(intent);
+ if (!system) {
+ return {
+ reply: SPEAKER_FALLBACK_REPLY,
+ cards: [],
+ sessionIds: [],
+ speakerIds: [],
+ modelId: null,
+ usage: { inputTokens: 0, outputTokens: 0 },
+ };
+ }
+
+ // For session cards, meet-and-greet prefers meetSessions when present
+ const sessionData =
+ intent === 'speaker_meet_and_greet' && Array.isArray(data?.meetSessions) && data.meetSessions.length
+ ? { ...data, sessions: data.meetSessions }
+ : data;
+
+ const modelId = getCall2ModelId();
+ const client = createClient(getRegion());
+ const recent = formatHistory(history);
+ const slimEntities = compactEntities(entities);
+ const slimData = slimSpeakerDataForLlm(data);
+ const speakerMode = usesSpeakerCards(intent);
+ const sessionMode = usesSpeakerSessionCards(intent);
+
+ const userParts = ['USER:', String(message || '').trim()];
+ if (recent) userParts.push('', 'HISTORY:', recent);
+ if (Object.keys(slimEntities).length) {
+ userParts.push('', 'ENTITIES:', JSON.stringify(slimEntities));
+ }
+ userParts.push(
+ '',
+ 'DATA:',
+ JSON.stringify(slimData),
+ '',
+ jsonOnlyHint('{"message":string,"speaker_ids":string[],"session_ids":string[]}'),
+ );
+ const userText = userParts.join('\n');
+
+ console.log('[step2][speaker][context]', {
+ intent,
+ speakers: Array.isArray(data?.speakers) ? data.speakers.length : 0,
+ candidates: Array.isArray(data?.candidates) ? data.candidates.length : 0,
+ sessions: Array.isArray(sessionData?.sessions) ? sessionData.sessions.length : 0,
+ contextChars: userText.length,
+ });
+
+ try {
+ const response = await converse(client, modelId, {
+ system,
+ messages: [{ role: 'user', content: [{ text: userText }] }],
+ inferenceConfig: CALL2_INFERENCE,
+ logLabel: 'call2-speaker',
+ });
+
+ const raw = responseText(response);
+ const parsed = parseCall2Json(raw);
+ let reply = parsed?.message || '';
+ if (looksLikeInternalNote(reply)) reply = '';
+
+ let speakerIds = speakerMode
+ ? resolveSpeakerIdsFromModel(parsed?.speakerIds, data)
+ : [];
+ let sessionIds = sessionMode && !data?.isSpeakerList
+ ? resolveSessionIds(parsed?.sessionIds, sessionData)
+ : [];
+
+ if (data?.isSpeakerList) {
+ sessionIds = [];
+ if (!speakerIds.length && Array.isArray(data.speakers)) {
+ speakerIds = data.speakers.map((s) => String(s.id)).filter(Boolean).slice(0, 8);
+ }
+ }
+
+ if (data?.needsClarification) {
+ sessionIds = [];
+ speakerIds = [];
+ const names = (Array.isArray(data.candidates) ? data.candidates : [])
+ .map((c) => c.name)
+ .filter(Boolean)
+ .slice(0, 6);
+ // Deterministic when no real candidates — models invent names otherwise
+ if (!names.length) {
+ reply = 'Which speaker did you mean? Please share their full name.';
+ } else if (!reply) {
+ reply = `I found more than one match: ${names.join(', ')}. Which speaker did you mean?`;
+ }
+ }
+
+ if (!reply) {
+ if (data?.isSpeakerList) {
+ const names = (Array.isArray(data.speakers) ? data.speakers : [])
+ .map((s) => s.name)
+ .filter(Boolean);
+ reply = names.length
+ ? `Here are speakers on the programme:\n${names.map((n) => `• ${n}`).join('\n')}\n\nAsk about anyone for a fuller bio.`
+ : String(data?.note || '').trim() || SPEAKER_FALLBACK_REPLY;
+ }
+ }
+
+ if (!reply) {
+ const sp = Array.isArray(data?.speakers) && data.speakers[0] ? data.speakers[0] : null;
+ if (intent === 'speaker_contact_info' && sp) {
+ if (!sp.isPublic) {
+ reply =
+ 'That speaker’s profile is not publicly listed. Please ask at the help desk or contact the organizers.';
+ } else {
+ const links = [
+ sp.socialLinks?.linkedin ? `LinkedIn: ${sp.socialLinks.linkedin}` : null,
+ sp.socialLinks?.twitter ? `Twitter/X: ${sp.socialLinks.twitter}` : null,
+ sp.socialLinks?.website ? `Website: ${sp.socialLinks.website}` : null,
+ ].filter(Boolean);
+ reply = links.length
+ ? `Here’s the public contact info I have for ${sp.name}:\n${links.join('\n')}`
+ : String(data?.note || '').trim()
+ || `I don’t have public social links on file for ${sp.name}. Please ask at the help desk.`;
+ }
+ } else if (intent === 'speaker_meet_and_greet') {
+ if (sessionIds.length) {
+ reply = 'Here are meet-and-greet / networking sessions related to that speaker:';
+ } else {
+ reply =
+ String(data?.note || '').trim()
+ || 'I don’t see a dedicated meet-and-greet for that speaker on the schedule. You can catch their sessions on the agenda, or ask at the help desk.';
+ }
+ } else if (sp) {
+ if (!sp.isPublic) {
+ reply =
+ 'That speaker’s profile is not publicly listed. I can still help with their sessions on the agenda if you ask when they speak.';
+ } else {
+ const bits = [
+ sp.name,
+ sp.speakerRole,
+ [sp.title, sp.organizationName].filter(Boolean).join(', '),
+ ].filter(Boolean);
+ reply = sp.bio
+ ? `${bits.join(' — ')}\n\n${sp.bio}`
+ : String(data?.note || '').trim()
+ || `${bits.join(' — ') || 'Speaker'} — no biography text is on file.`;
+ }
+ } else {
+ reply = String(data?.note || '').trim() || SPEAKER_FALLBACK_REPLY;
+ }
+ }
+
+ const cards = [];
+ if (speakerMode) {
+ cards.push(...buildSpeakerCards(speakerIds, data));
+ }
+ if (sessionMode && sessionIds.length) {
+ cards.push(...buildSessionCards(sessionIds, sessionData));
+ }
+
+ console.log('[step2][speaker][parsed]', {
+ intent,
+ messagePreview: reply.slice(0, 160),
+ speakerIds,
+ sessionIds,
+ cardCount: cards.length,
+ });
+
+ return {
+ reply: reply.slice(0, 1800),
+ cards,
+ sessionIds,
+ speakerIds,
+ modelId,
+ usage: {
+ inputTokens: Number(response?.usage?.inputTokens || 0),
+ outputTokens: Number(response?.usage?.outputTokens || 0),
+ },
+ };
+ } catch (err) {
+ console.warn('[step2] Speaker Call 2 failed:', err?.message || err);
+ const speakerIds = speakerMode ? resolveSpeakerIdsFromModel(null, data) : [];
+ const sessionIds =
+ sessionMode && !data?.isSpeakerList ? resolveSessionIds(null, sessionData) : [];
+ const cards = [
+ ...(speakerMode ? buildSpeakerCards(speakerIds, data) : []),
+ ...(sessionIds.length ? buildSessionCards(sessionIds, sessionData) : []),
+ ];
+ let reply = String(data?.note || '').trim() || SPEAKER_FALLBACK_REPLY;
+ if (data?.isSpeakerList && Array.isArray(data.speakers) && data.speakers.length) {
+ const names = data.speakers.map((s) => s.name).filter(Boolean);
+ reply = names.length
+ ? `Here are speakers on the programme:\n${names.map((n) => `• ${n}`).join('\n')}\n\nAsk about anyone for a fuller bio.`
+ : reply;
+ } else if (cards.length) {
+ reply = 'Here is what I found about that speaker:';
+ }
+ return {
+ reply,
+ cards,
+ sessionIds,
+ speakerIds,
+ modelId,
+ usage: { inputTokens: 0, outputTokens: 0 },
+ };
+ }
+}
+
+function boothHintForExhibitor(ex) {
+ if (!ex) return '';
+ const bits = [ex.boothNumber ? `booth ${ex.boothNumber}` : null, ex.hall ? `hall ${ex.hall}` : null]
+ .filter(Boolean);
+ return bits.length ? ` (${bits.join(', ')})` : '';
+}
+
+/**
+ * @param {{ intent: string, message: string, history?: object[], entities?: object, data?: object }} opts
+ */
+async function generateExhibitorReply({ intent, message, history, entities, data }) {
+ const system = buildExhibitorCall2SystemPrompt(intent);
+ if (!system) {
+ return {
+ reply: EXHIBITOR_FALLBACK_REPLY,
+ cards: [],
+ exhibitorIds: [],
+ modelId: null,
+ usage: { inputTokens: 0, outputTokens: 0 },
+ };
+ }
+
+ const cardMode = usesExhibitorCards(intent);
+ const faqs = Array.isArray(data?.faqs) ? data.faqs : [];
+ const hasFaqs = faqs.length > 0;
+
+ // Deterministic refuses — never invent booking or demo times
+ if (intent === 'exhibitor_book_meeting' || data?.refuseBooking) {
+ if (data?.needsClarification && Array.isArray(data.candidates) && data.candidates.length) {
+ const names = data.candidates.map((c) => c.name).filter(Boolean).slice(0, 6);
+ return {
+ reply: names.length
+ ? `I found more than one match: ${names.join(', ')}. Which exhibitor did you mean?`
+ : 'Which exhibitor did you mean? Please share the company name.',
+ cards: [],
+ exhibitorIds: [],
+ modelId: null,
+ usage: { inputTokens: 0, outputTokens: 0 },
+ };
+ }
+ const exhibitorIds = cardMode ? resolveExhibitorIdsFromModel(null, data) : [];
+ const cards = cardMode ? buildExhibitorCards(exhibitorIds, data) : [];
+ const ex = Array.isArray(data?.exhibitors) && data.exhibitors[0] ? data.exhibitors[0] : null;
+ let reply = EXHIBITOR_BOOKING_REFUSE_REPLY;
+ if (ex) {
+ reply = `I can’t book meetings in this chat. Please visit ${ex.name}${boothHintForExhibitor(ex)} or ask exhibitor staff / the help desk.`;
+ }
+ return {
+ reply,
+ cards,
+ exhibitorIds,
+ modelId: null,
+ usage: { inputTokens: 0, outputTokens: 0 },
+ };
+ }
+
+ if (intent === 'exhibitor_demo_schedule' && !hasFaqs && !data?.needsClarification) {
+ const exhibitorIds = cardMode ? resolveExhibitorIdsFromModel(null, data) : [];
+ const cards = cardMode ? buildExhibitorCards(exhibitorIds, data) : [];
+ const ex = Array.isArray(data?.exhibitors) && data.exhibitors[0] ? data.exhibitors[0] : null;
+ let reply = EXHIBITOR_DEMO_NONE_REPLY;
+ if (ex) {
+ reply = `I don’t have a demo schedule listed for ${ex.name}. Please check at their booth${boothHintForExhibitor(ex)} or ask at the help desk.`;
+ }
+ return {
+ reply,
+ cards,
+ exhibitorIds,
+ modelId: null,
+ usage: { inputTokens: 0, outputTokens: 0 },
+ };
+ }
+
+ const modelId = getCall2ModelId();
+ const client = createClient(getRegion());
+ const recent = formatHistory(history);
+ const slimEntities = compactEntities(entities);
+ const slimData = slimExhibitorDataForLlm(data);
+
+ const userParts = ['USER:', String(message || '').trim()];
+ if (recent) userParts.push('', 'HISTORY:', recent);
+ if (Object.keys(slimEntities).length) {
+ userParts.push('', 'ENTITIES:', JSON.stringify(slimEntities));
+ }
+ userParts.push(
+ '',
+ 'DATA:',
+ JSON.stringify(slimData),
+ '',
+ jsonOnlyHint('{"message":string,"exhibitor_ids":string[]}'),
+ );
+ const userText = userParts.join('\n');
+
+ console.log('[step2][exhibitor][context]', {
+ intent,
+ exhibitors: Array.isArray(data?.exhibitors) ? data.exhibitors.length : 0,
+ candidates: Array.isArray(data?.candidates) ? data.candidates.length : 0,
+ faqs: faqs.length,
+ contextChars: userText.length,
+ });
+
+ try {
+ const response = await converse(client, modelId, {
+ system,
+ messages: [{ role: 'user', content: [{ text: userText }] }],
+ inferenceConfig: CALL2_INFERENCE,
+ logLabel: 'call2-exhibitor',
+ });
+
+ const raw = responseText(response);
+ const parsed = parseCall2Json(raw);
+ let reply = parsed?.message || '';
+ if (looksLikeInternalNote(reply)) reply = '';
+
+ let exhibitorIds = cardMode
+ ? resolveExhibitorIdsFromModel(parsed?.exhibitorIds, data)
+ : [];
+
+ if (data?.needsClarification) {
+ exhibitorIds = [];
+ const names = (Array.isArray(data.candidates) ? data.candidates : [])
+ .map((c) => c.name)
+ .filter(Boolean)
+ .slice(0, 6);
+ if (!names.length) {
+ reply =
+ intent === 'exhibitor_by_category'
+ ? 'Which category or industry are you interested in?'
+ : 'Which exhibitor did you mean? Please share the company name.';
+ } else if (!reply) {
+ reply = `I found more than one match: ${names.join(', ')}. Which exhibitor did you mean?`;
+ }
+ }
+
+ if (!reply) {
+ if (data?.isExhibitorList || (Array.isArray(data?.exhibitors) && data.exhibitors.length > 1)) {
+ const names = (Array.isArray(data.exhibitors) ? data.exhibitors : [])
+ .map((e) => {
+ const loc = [e.boothNumber && `Booth ${e.boothNumber}`, e.hall].filter(Boolean).join(', ');
+ return loc ? `${e.name} (${loc})` : e.name;
+ })
+ .filter(Boolean);
+ reply = names.length
+ ? `Here are exhibitors I found:\n${names.map((n) => `• ${n}`).join('\n')}\n\nAsk about a company for more detail.`
+ : String(data?.note || '').trim() || EXHIBITOR_FALLBACK_REPLY;
+ } else if (Array.isArray(data?.exhibitors) && data.exhibitors[0]) {
+ const ex = data.exhibitors[0];
+ const bits = [
+ ex.name,
+ ex.boothNumber ? `Booth ${ex.boothNumber}` : null,
+ ex.hall ? `Hall ${ex.hall}` : null,
+ ex.category,
+ ].filter(Boolean);
+ reply = ex.shortDescription
+ ? `${bits.join(' · ')}\n\n${ex.shortDescription}`
+ : bits.join(' · ') || EXHIBITOR_FALLBACK_REPLY;
+ } else {
+ reply = String(data?.note || '').trim() || EXHIBITOR_FALLBACK_REPLY;
+ }
+ }
+
+ const cards = cardMode ? buildExhibitorCards(exhibitorIds, data) : [];
+
+ console.log('[step2][exhibitor][parsed]', {
+ intent,
+ messagePreview: reply.slice(0, 160),
+ exhibitorIds,
+ cardCount: cards.length,
+ });
+
+ return {
+ reply: reply.slice(0, 1800),
+ cards,
+ exhibitorIds,
+ modelId,
+ usage: {
+ inputTokens: Number(response?.usage?.inputTokens || 0),
+ outputTokens: Number(response?.usage?.outputTokens || 0),
+ },
+ };
+ } catch (err) {
+ console.warn('[step2] Exhibitor Call 2 failed:', err?.message || err);
+ const exhibitorIds = cardMode ? resolveExhibitorIdsFromModel(null, data) : [];
+ const cards = cardMode ? buildExhibitorCards(exhibitorIds, data) : [];
+ let reply = String(data?.note || '').trim() || EXHIBITOR_FALLBACK_REPLY;
+ if (Array.isArray(data?.exhibitors) && data.exhibitors.length) {
+ const names = data.exhibitors.map((e) => e.name).filter(Boolean);
+ reply = names.length
+ ? `Here are exhibitors I found:\n${names.map((n) => `• ${n}`).join('\n')}`
+ : reply;
+ }
+ return {
+ reply,
+ cards,
+ exhibitorIds,
+ modelId,
+ usage: { inputTokens: 0, outputTokens: 0 },
+ };
+ }
+}
+
+function techDeterministicFallback(intent, data) {
+ const faqs = Array.isArray(data?.faqs) ? data.faqs : [];
+ if (faqs.length) {
+ return faqs.map((f) => f.a).filter(Boolean).join('\n\n') || TECH_FALLBACK_REPLY;
+ }
+ if (intent === 'wifi_credentials') return WIFI_NONE_REPLY;
+ if (intent === 'app_issue_streaming') {
+ return formatStaticTipsReply(
+ "Sorry you're having trouble with streaming. A few quick things to try:",
+ data?.staticTips || STREAMING_STATIC_TIPS,
+ );
+ }
+ if (intent === 'app_issue_account') {
+ return formatStaticTipsReply(
+ "Sorry you're having trouble signing in. Try these steps:",
+ data?.staticTips || ACCOUNT_STATIC_TIPS,
+ );
+ }
+ if (intent === 'app_feature_guidance') return FEATURE_NONE_REPLY;
+ return TECH_FALLBACK_REPLY;
+}
+
+/**
+ * @param {{ intent: string, message: string, history?: object[], entities?: object, data?: object }} opts
+ */
+async function generateTechReply({ intent, message, history, entities, data }) {
+ const system = buildTechCall2SystemPrompt(intent);
+ if (!system) {
+ return {
+ reply: TECH_FALLBACK_REPLY,
+ cards: [],
+ modelId: null,
+ usage: { inputTokens: 0, outputTokens: 0 },
+ };
+ }
+
+ const faqs = Array.isArray(data?.faqs) ? data.faqs : [];
+ const hasFaqs = faqs.length > 0;
+
+ // Wi‑Fi with no FAQs: never call the model (avoids invented passwords)
+ if (intent === 'wifi_credentials' && !hasFaqs) {
+ return {
+ reply: WIFI_NONE_REPLY,
+ cards: [],
+ modelId: null,
+ usage: { inputTokens: 0, outputTokens: 0 },
+ };
+ }
+
+ const modelId = getCall2ModelId();
+ const client = createClient(getRegion());
+ const recent = formatHistory(history);
+ const slimEntities = compactEntities(entities);
+ const slimData = slimTechDataForLlm(data);
+
+ const userParts = ['USER:', String(message || '').trim()];
+ if (recent) userParts.push('', 'HISTORY:', recent);
+ if (Object.keys(slimEntities).length) {
+ userParts.push('', 'ENTITIES:', JSON.stringify(slimEntities));
+ }
+ userParts.push(
+ '',
+ 'DATA:',
+ JSON.stringify(slimData),
+ '',
+ jsonOnlyHint('{"message":string}'),
+ );
+ const userText = userParts.join('\n');
+
+ console.log('[step2][tech][context]', {
+ intent,
+ faqCount: faqs.length,
+ hasStaticTips: Array.isArray(data?.staticTips) && data.staticTips.length > 0,
+ contextChars: userText.length,
+ });
+
+ try {
+ const response = await converse(client, modelId, {
+ system,
+ messages: [{ role: 'user', content: [{ text: userText }] }],
+ inferenceConfig: CALL2_INFERENCE,
+ logLabel: 'call2-tech',
+ });
+
+ const raw = responseText(response);
+ const parsed = parseCall2Json(raw);
+ let reply = parsed?.message || '';
+ if (looksLikeInternalNote(reply)) reply = '';
+ if (!reply) reply = techDeterministicFallback(intent, data);
+
+ console.log('[step2][tech][parsed]', {
+ intent,
+ messagePreview: reply.slice(0, 160),
+ });
+
+ return {
+ reply: reply.slice(0, 1800),
+ cards: [],
+ modelId,
+ usage: {
+ inputTokens: Number(response?.usage?.inputTokens || 0),
+ outputTokens: Number(response?.usage?.outputTokens || 0),
+ },
+ };
+ } catch (err) {
+ console.warn('[step2] Tech Call 2 failed:', err?.message || err);
+ return {
+ reply: techDeterministicFallback(intent, data),
+ cards: [],
+ modelId,
+ usage: { inputTokens: 0, outputTokens: 0 },
+ };
+ }
+}
+
+function contactLinesFromSystemData(data) {
+ const contact = data?.contact && typeof data.contact === 'object' ? data.contact : null;
+ if (contact?.text) return String(contact.text).trim();
+ const bits = [];
+ if (contact?.email) bits.push(`Email: ${contact.email}`);
+ if (contact?.phone) bits.push(`Phone: ${contact.phone}`);
+ if (contact?.detail) bits.push(String(contact.detail));
+ if (bits.length) return bits.join('\n');
+ const faqs = Array.isArray(data?.faqs) ? data.faqs : [];
+ return faqs.map((f) => String(f?.a || '').trim()).filter(Boolean).join('\n') || '';
+}
+
+function appendContactBlock(reply, data) {
+ const contacts = contactLinesFromSystemData(data);
+ if (!contacts) return reply;
+ const text = String(reply || '').trim();
+ // Already includes the email or phone from DATA
+ const email = String(data?.contact?.email || '').trim();
+ const phone = String(data?.contact?.phone || '').trim();
+ if (email && text.toLowerCase().includes(email.toLowerCase())) return text;
+ if (phone) {
+ const digits = phone.replace(/\D/g, '');
+ if (digits.length >= 6 && text.replace(/\D/g, '').includes(digits)) return text;
+ }
+ return text ? `${text}\n\nOrganizer contact:\n${contacts}` : `Organizer contact:\n${contacts}`;
+}
+
+function systemDeterministicFallback(intent, data) {
+ const emailed = data?.emailSent === true;
+ const contacts = contactLinesFromSystemData(data);
+
+ if (intent === 'emergency_medical') {
+ const base = emailed
+ ? `${EMERGENCY_SAFETY_LINE} I’ve also notified the organizers. Please get help on site right away.`
+ : `${EMERGENCY_SAFETY_LINE} Please also ask at the help desk or info desk.`;
+ return appendContactBlock(base, data);
+ }
+ if (intent === 'lost_and_found') {
+ return emailed
+ ? 'I’ve notified the organizers about your lost-and-found request. Please share a short description of the item if you haven’t, and check with the help desk / registration.'
+ : 'For lost and found, please check with the help desk or registration desk and share a short description of the item.';
+ }
+ if (intent === 'human_agent_escalation') {
+ if (contacts) {
+ const lead = emailed
+ ? 'I’ve notified the organizers. You can also reach them here:'
+ : 'You can reach the organizers here:';
+ return `${lead}\n${contacts}\n\nYou can also ask at the info desk, or tell me how else I can help with the agenda, venue, or your ticket.`;
+ }
+ return emailed
+ ? 'I’ve notified the organizers. Meanwhile you can ask at the info desk, or tell me how else I can help with the agenda, venue, or your ticket.'
+ : 'Please speak with staff at the info desk. I can still help with the agenda, venue, or your ticket while you wait.';
+ }
+ if (intent === 'chitchat_greeting') {
+ return 'Hi — ask me about the agenda, venue, or your ticket.';
+ }
+ if (intent === 'out_of_scope') {
+ return 'I can only help with this event — try the agenda, venue, or your ticket.';
+ }
+ return SYSTEM_FALLBACK_REPLY;
+}
+
+function ensureEmergencyLead(reply) {
+ const text = String(reply || '').trim();
+ if (!text) return EMERGENCY_SAFETY_LINE;
+ if (/security|medical|emergency|ambulance|first aid/i.test(text.slice(0, 220))) {
+ return text;
+ }
+ return `${EMERGENCY_SAFETY_LINE} ${text}`;
+}
+
+/**
+ * @param {{ intent: string, message: string, history?: object[], entities?: object, data?: object }} opts
+ */
+async function generateSystemReply({ intent, message, history, entities, data }) {
+ const system = buildSystemCall2SystemPrompt(intent);
+ const greetingChips = intent === 'chitchat_greeting' ? DEFAULT_QUICK_REPLIES : null;
+
+ if (!system) {
+ return {
+ reply: systemDeterministicFallback(intent, data),
+ cards: [],
+ quickReplies: greetingChips,
+ modelId: null,
+ usage: { inputTokens: 0, outputTokens: 0 },
+ };
+ }
+
+ const modelId = getCall2ModelId();
+ const client = createClient(getRegion());
+ const recent = formatHistory(history);
+ const slimEntities = compactEntities(entities);
+ const slimData = slimSystemDataForLlm(data);
+
+ const userParts = ['USER:', String(message || '').trim()];
+ if (recent) userParts.push('', 'HISTORY:', recent);
+ if (Object.keys(slimEntities).length) {
+ userParts.push('', 'ENTITIES:', JSON.stringify(slimEntities));
+ }
+ userParts.push(
+ '',
+ 'DATA:',
+ JSON.stringify(slimData),
+ '',
+ jsonOnlyHint('{"message":string}'),
+ );
+ const userText = userParts.join('\n');
+
+ console.log('[step2][system][context]', {
+ intent,
+ emailSent: Boolean(data?.emailSent),
+ rateLimited: Boolean(data?.rateLimited),
+ hasContact: Boolean(data?.contact?.email || data?.contact?.phone || data?.contact?.text),
+ faqCount: Array.isArray(data?.faqs) ? data.faqs.length : 0,
+ contextChars: userText.length,
+ });
+
+ try {
+ const response = await converse(client, modelId, {
+ system,
+ messages: [{ role: 'user', content: [{ text: userText }] }],
+ inferenceConfig: CALL2_INFERENCE,
+ logLabel: 'call2-system',
+ });
+
+ const raw = responseText(response);
+ const parsed = parseCall2Json(raw);
+ let reply = parsed?.message || '';
+ if (looksLikeInternalNote(reply)) reply = '';
+ if (!reply) reply = systemDeterministicFallback(intent, data);
+ if (intent === 'out_of_scope') reply = sanitizeOutOfScopeReply(reply, data?.eventName);
+ if (intent === 'emergency_medical') reply = ensureEmergencyLead(reply);
+
+ // Never let the model invent a successful organizer email
+ if (
+ data?.emailSent !== true
+ && /notified the organizers|emailed the organizers|sent (an )?email to the organizers/i.test(reply)
+ ) {
+ reply = systemDeterministicFallback(intent, data);
+ if (intent === 'emergency_medical') reply = ensureEmergencyLead(reply);
+ }
+
+ // Ensure Bot FAQ contacts are always surfaced for human/emergency intents
+ if (intent === 'human_agent_escalation' || intent === 'emergency_medical') {
+ reply = appendContactBlock(reply, data);
+ // If the model claimed "no contact" despite DATA.contact, replace with deterministic reply
+ if (
+ (data?.contact?.email || data?.contact?.phone || data?.contact?.text)
+ && /don'?t have.*(contact|direct line)|no (organizer )?contact|could not be emailed since we don'?t have/i.test(reply)
+ ) {
+ reply = systemDeterministicFallback(intent, data);
+ if (intent === 'emergency_medical') reply = ensureEmergencyLead(reply);
+ }
+ }
+
+ console.log('[step2][system][parsed]', {
+ intent,
+ messagePreview: reply.slice(0, 160),
+ quickReplies: greetingChips ? greetingChips.length : 0,
+ });
+
+ return {
+ reply: reply.slice(0, 1800),
+ cards: [],
+ quickReplies: greetingChips,
+ modelId,
+ usage: {
+ inputTokens: Number(response?.usage?.inputTokens || 0),
+ outputTokens: Number(response?.usage?.outputTokens || 0),
+ },
+ };
+ } catch (err) {
+ console.warn('[step2] System Call 2 failed:', err?.message || err);
+ let reply = systemDeterministicFallback(intent, data);
+ if (intent === 'emergency_medical') reply = ensureEmergencyLead(reply);
+ return {
+ reply,
+ cards: [],
+ quickReplies: greetingChips,
+ modelId,
+ usage: { inputTokens: 0, outputTokens: 0 },
+ };
+ }
+}
+
+function localDeterministicFallback(intent, data) {
+ const location = data?.location ? ` near ${data.location}` : ' near the venue';
+ if (intent === 'accommodation') {
+ const faq = Array.isArray(data?.faqs)
+ ? data.faqs.map((f) => String(f?.a || '').trim()).filter(Boolean).join('\n\n')
+ : '';
+ if (faq) return faq;
+ if (data?.hostManagedAccommodation) return HOST_MANAGED_ACCOMMODATION_REPLY;
+ if (Array.isArray(data?.hotels) && data.hotels.length) {
+ return `Here are some stay options${location}. Please verify rates and availability yourself before booking.`;
+ }
+ return `Accommodation isn’t arranged by the organizer, and I don’t have hotel suggestions on file yet. You can book a stay${location}, or ask at the help desk.`;
+ }
+ if (intent === 'tourist_places') {
+ if (data?.categoryNoMatch) return String(data.categoryNoMatch);
+ if (Array.isArray(data?.places) && data.places.length) {
+ const label = data?.requestedCategory?.label;
+ return label
+ ? `Here are some ${label}${location}.`
+ : `Here are some places to visit${location}.`;
+ }
+ return `I don’t have sightseeing suggestions on file yet. You can explore${location}, or ask at the help desk for local tips.`;
+ }
+ return LOCAL_FALLBACK_REPLY;
+}
+
+/**
+ * @param {{ intent: string, message: string, history?: object[], entities?: object, data?: object }} opts
+ */
+async function generateLocalReply({ intent, message, history, entities, data }) {
+ const system = buildLocalCall2SystemPrompt(intent);
+ if (!system) {
+ return {
+ reply: LOCAL_FALLBACK_REPLY,
+ cards: [],
+ placeIds: [],
+ hotelIds: [],
+ modelId: null,
+ usage: { inputTokens: 0, outputTokens: 0 },
+ };
+ }
+
+ const placeMode = usesLocalPlaceCards(intent) && Array.isArray(data?.places) && data.places.length;
+ const hotelMode =
+ usesLocalHotelCards(intent)
+ && !data?.hostManagedAccommodation
+ && Array.isArray(data?.hotels)
+ && data.hotels.length;
+
+ const modelId = getCall2ModelId();
+ const client = createClient(getRegion());
+ const recent = formatHistory(history);
+ const slimEntities = compactEntities(entities);
+ const slimData = slimLocalDataForLlm(data);
+
+ const userParts = ['USER:', String(message || '').trim()];
+ if (recent) userParts.push('', 'HISTORY:', recent);
+ if (Object.keys(slimEntities).length) {
+ userParts.push('', 'ENTITIES:', JSON.stringify(slimEntities));
+ }
+
+ let jsonHint = jsonOnlyHint('{"message":string,"place_ids":[],"hotel_ids":[]}');
+ if (placeMode) {
+ jsonHint = jsonOnlyHint(
+ '{"message":string,"place_ids":string[],"hotel_ids":[]}',
+ 'using DATA.places[].id when useful',
+ );
+ } else if (hotelMode) {
+ jsonHint = jsonOnlyHint(
+ '{"message":string,"place_ids":[],"hotel_ids":string[]}',
+ 'using DATA.hotels[].id when useful',
+ );
+ }
+
+ userParts.push('', 'DATA:', JSON.stringify(slimData), '', jsonHint);
+ const userText = userParts.join('\n');
+
+ console.log('[step2][local][context]', {
+ intent,
+ placesIn: Array.isArray(data?.places) ? data.places.length : 0,
+ hotelsIn: Array.isArray(data?.hotels) ? data.hotels.length : 0,
+ hostManaged: Boolean(data?.hostManagedAccommodation),
+ contextChars: userText.length,
+ });
+
+ try {
+ const response = await converse(client, modelId, {
+ system,
+ messages: [{ role: 'user', content: [{ text: userText }] }],
+ inferenceConfig: CALL2_INFERENCE,
+ logLabel: 'call2-local',
+ });
+
+ const raw = responseText(response);
+ const parsed = parseCall2Json(raw);
+ let reply = parsed?.message || '';
+ if (looksLikeInternalNote(reply)) reply = '';
+
+ let placeIds = [];
+ let hotelIds = [];
+ if (placeMode) placeIds = resolvePlaceIds(parsed?.placeIds, data);
+ if (hotelMode) hotelIds = resolveHotelIds(parsed?.hotelIds, data);
+
+ if (!reply) reply = localDeterministicFallback(intent, data);
+
+ const cards = placeMode
+ ? buildPlaceCards(placeIds, data)
+ : hotelMode
+ ? buildAccommodationCards(hotelIds, data)
+ : [];
+
+ console.log('[step2][local][parsed]', {
+ intent,
+ messagePreview: reply.slice(0, 160),
+ placeIds,
+ hotelIds,
+ cardCount: cards.length,
+ });
+
+ return {
+ reply: reply.slice(0, 1600),
+ cards,
+ placeIds,
+ hotelIds,
+ modelId,
+ usage: {
+ inputTokens: Number(response?.usage?.inputTokens || 0),
+ outputTokens: Number(response?.usage?.outputTokens || 0),
+ },
+ };
+ } catch (err) {
+ console.warn('[step2] Local Call 2 failed:', err?.message || err);
+ const reply = localDeterministicFallback(intent, data);
+ const placeIds = placeMode ? resolvePlaceIds([], data) : [];
+ const hotelIds = hotelMode ? resolveHotelIds([], data) : [];
+ const cards = placeMode
+ ? buildPlaceCards(placeIds, data)
+ : hotelMode
+ ? buildAccommodationCards(hotelIds, data)
+ : [];
+ return {
+ reply,
+ cards,
+ placeIds,
+ hotelIds,
+ modelId,
+ usage: { inputTokens: 0, outputTokens: 0 },
+ };
+ }
+}
+
+module.exports = {
+ generateAgendaReply,
+ generateVenueReply,
+ generateTicketReply,
+ generateSpeakerReply,
+ generateExhibitorReply,
+ generateTechReply,
+ generateSystemReply,
+ generateLocalReply,
+ getCall2ModelId,
+ DEFAULT_CALL2_MODEL_ID,
+ FALLBACK_REPLY,
+ VENUE_FALLBACK_REPLY,
+ TICKET_FALLBACK_REPLY,
+ SPEAKER_FALLBACK_REPLY,
+ EXHIBITOR_FALLBACK_REPLY,
+ TECH_FALLBACK_REPLY,
+ SYSTEM_FALLBACK_REPLY,
+ LOCAL_FALLBACK_REPLY,
+ parseCall2Json,
+};
diff --git a/src/services/systemData.service.js b/src/services/systemData.service.js
new file mode 100644
index 0000000..c6f22d1
--- /dev/null
+++ b/src/services/systemData.service.js
@@ -0,0 +1,219 @@
+const Organization = require('../models/Organization');
+const Participant = require('../models/Participant');
+const { sendOrganizerEscalationEmail } = require('../utils/mail');
+const { faqAnswerText, emergencyContactFromBot } = require('../utils/faqAnswer');
+
+const SYSTEM_INTENTS = new Set([
+ 'emergency_medical',
+ 'lost_and_found',
+ 'human_agent_escalation',
+ 'chitchat_greeting',
+ 'out_of_scope',
+]);
+
+const ESCALATION_INTENTS = new Set([
+ 'emergency_medical',
+ 'lost_and_found',
+ 'human_agent_escalation',
+]);
+
+const ESCALATION_EMAIL_COOLDOWN_MS = 60 * 1000;
+const HELPDESK = 'Please ask at the venue help desk or contact event staff on site.';
+const ANSWER_MAX = 600;
+
+function isSystemIntent(intent) {
+ return SYSTEM_INTENTS.has(String(intent || ''));
+}
+
+function answeredEmergencyFaqs(bot) {
+ const rows = Array.isArray(bot?.faqs) ? bot.faqs : [];
+ return rows
+ .filter((f) => {
+ const key = String(f?.key || '').trim();
+ const category = String(f?.category || '').trim();
+ return (key === 'emergency_contact' || category === 'emergency_contact') && faqAnswerText(f);
+ })
+ .slice(0, 4)
+ .map((f) => {
+ const a = faqAnswerText(f);
+ const q = String(f?.question || '').trim();
+ return {
+ q: q.length > 200 ? `${q.slice(0, 200)}…` : q,
+ a: a.length > ANSWER_MAX ? `${a.slice(0, ANSWER_MAX)}…` : a,
+ };
+ })
+ .filter((f) => f.q && f.a);
+}
+
+function isEscalationIntent(intent) {
+ return ESCALATION_INTENTS.has(String(intent || ''));
+}
+
+async function resolveOrganizerEmail(bot) {
+ const envFallback = String(process.env.CONCIERGE_ORGANIZER_EMAIL || '').trim().toLowerCase();
+ const orgId = bot?.organization;
+ if (orgId) {
+ const org = await Organization.findById(orgId).select('email name').lean();
+ const email = String(org?.email || '').trim().toLowerCase();
+ if (email && email.includes('@')) {
+ return { email, orgName: org?.name || null };
+ }
+ }
+ // Bot FAQ emergency contact email — used for escalation notify + visitor reply
+ const faqContact = emergencyContactFromBot(bot);
+ const faqEmail = String(faqContact?.email || '').trim().toLowerCase();
+ if (faqEmail && faqEmail.includes('@')) {
+ return { email: faqEmail, orgName: null };
+ }
+ if (envFallback && envFallback.includes('@')) {
+ return { email: envFallback, orgName: null };
+ }
+ return { email: null, orgName: null };
+}
+
+async function loadParticipantBrief(session) {
+ if (!session?.participant) {
+ return { name: null, email: null };
+ }
+ const p = await Participant.findById(session.participant)
+ .select('firstName lastName displayName email')
+ .lean();
+ if (!p) return { name: null, email: null };
+ const name =
+ String(p.displayName || '').trim()
+ || [p.firstName, p.lastName].filter(Boolean).join(' ').trim()
+ || null;
+ return {
+ name,
+ email: String(p.email || '').trim().toLowerCase() || null,
+ };
+}
+
+function contactShareNote(hasContact) {
+ if (!hasContact) return '';
+ return (
+ ' Share DATA.contact (and DATA.faqs) Email/Phone exactly with the visitor — these are organizer-configured public contacts, not invented. '
+ );
+}
+
+/**
+ * @param {{ intent: string, bot?: object, event?: object, session?: object, message?: string }} opts
+ */
+async function fetchSystemData({ intent, bot = null, event = null, session = null, message = '' } = {}) {
+ const intentKey = String(intent || '');
+ const eventName = event?.name || null;
+ const contact = emergencyContactFromBot(bot);
+ const faqs =
+ intentKey === 'emergency_medical' || intentKey === 'human_agent_escalation'
+ ? answeredEmergencyFaqs(bot)
+ : [];
+ const hasContact = Boolean(contact?.text || contact?.email || contact?.phone);
+
+ const base = {
+ intent: intentKey,
+ eventName,
+ emailSent: false,
+ emailError: null,
+ rateLimited: false,
+ note: null,
+ faqs,
+ contact: hasContact
+ ? {
+ email: contact.email || null,
+ phone: contact.phone || null,
+ detail: contact.detail || null,
+ text: contact.text || null,
+ }
+ : undefined,
+ };
+
+ if (!isEscalationIntent(intentKey)) {
+ if (intentKey === 'chitchat_greeting') {
+ base.note =
+ 'Friendly hello. Briefly say what you can help with (agenda, speakers, exhibitors, venue, Wi‑Fi, ticket). Invite them to tap a suggestion chip or type a question. Do not list every option as a long bullet list.';
+ } else if (intentKey === 'out_of_scope') {
+ base.note =
+ 'Unrelated question. One short sentence — do not name the event and do not start with "I\'m afraid". Point them toward agenda, venue, or ticket help.';
+ }
+ return base;
+ }
+
+ const { email: organizerEmail } = await resolveOrganizerEmail(bot);
+ if (!organizerEmail) {
+ base.emailError = 'missing_organizer_email';
+ if (hasContact) {
+ base.note =
+ `Could not send an internal organizer alert email.${contactShareNote(true)}` +
+ `Do not say you have no contact information. Do not claim an email was sent. Also mention the on-site help desk for emergencies.`;
+ } else {
+ base.note =
+ `Organizers could not be emailed (no address on file). Acknowledge the request, urge on-site help for emergencies, and suggest the help desk. Do not claim an email was sent. ${HELPDESK}`;
+ }
+ return base;
+ }
+
+ const last = session?.lastEscalationEmailAt
+ ? new Date(session.lastEscalationEmailAt).getTime()
+ : 0;
+ if (last && Date.now() - last < ESCALATION_EMAIL_COOLDOWN_MS) {
+ base.rateLimited = true;
+ base.emailError = 'rate_limited';
+ base.note =
+ intentKey === 'emergency_medical'
+ ? `Email was recently sent. Still urge immediate contact with venue security / medical staff.${contactShareNote(hasContact)}Acknowledge the request. ${HELPDESK}`
+ : `A notification was sent recently for this chat.${contactShareNote(hasContact)}Acknowledge the request and suggest the help desk. Do not claim a new email was just sent. ${HELPDESK}`;
+ return base;
+ }
+
+ const attendee = await loadParticipantBrief(session);
+ const result = await sendOrganizerEscalationEmail({
+ to: organizerEmail,
+ eventName,
+ intent: intentKey,
+ userMessage: message,
+ sessionId: session?.sessionId || null,
+ participantName: attendee.name,
+ participantEmail: attendee.email,
+ });
+
+ if (result.ok) {
+ if (session) {
+ session.lastEscalationEmailAt = new Date();
+ }
+ base.emailSent = true;
+ if (intentKey === 'emergency_medical') {
+ base.note =
+ `Organizers were notified (DATA.emailSent true). ALWAYS lead with: contact venue security or medical staff immediately.${contactShareNote(hasContact)}Briefly confirm organizers were alerted. No medical advice.`;
+ } else if (intentKey === 'lost_and_found') {
+ base.note =
+ 'Organizers were notified. Acknowledge the lost/found item; ask for a brief description if missing; suggest the help desk / registration.';
+ } else {
+ base.note =
+ `Organizers were notified.${contactShareNote(hasContact)}Confirm the request was received; suggest the help desk meanwhile.`;
+ }
+ return base;
+ }
+
+ base.emailError = result.error || 'send_failed';
+ if (intentKey === 'emergency_medical') {
+ base.note = hasContact
+ ? `Could not email organizers. Urge immediate on-site security/medical help.${contactShareNote(true)}Do not claim email was sent.`
+ : `Could not email organizers. Urge immediate on-site security/medical help. Suggest the help desk. Do not claim email was sent. ${HELPDESK}`;
+ } else {
+ base.note = hasContact
+ ? `Could not email organizers.${contactShareNote(true)}Do not say you have no contact details. Do not claim email was sent. Suggest the help desk as well.`
+ : `Could not email organizers. Acknowledge the request and suggest the help desk. Do not claim email was sent. ${HELPDESK}`;
+ }
+ return base;
+}
+
+module.exports = {
+ SYSTEM_INTENTS,
+ ESCALATION_INTENTS,
+ ESCALATION_EMAIL_COOLDOWN_MS,
+ isSystemIntent,
+ isEscalationIntent,
+ fetchSystemData,
+ resolveOrganizerEmail,
+ answeredEmergencyFaqs,
+};
diff --git a/src/services/techData.service.js b/src/services/techData.service.js
new file mode 100644
index 0000000..bc04bff
--- /dev/null
+++ b/src/services/techData.service.js
@@ -0,0 +1,121 @@
+const TECH_INTENTS = new Set([
+ 'wifi_credentials',
+ 'app_issue_streaming',
+ 'app_issue_account',
+ 'app_feature_guidance',
+]);
+
+const INTENT_TO_FAQ_CATEGORY = {
+ wifi_credentials: 'wifi',
+ app_issue_account: 'app_account',
+};
+
+const { faqAnswerText } = require('../utils/faqAnswer');
+
+const MAX_FAQS = 10;
+const ANSWER_MAX = 600;
+const HELPDESK = 'Please ask at the venue help desk or contact event support.';
+
+const STREAMING_STATIC_TIPS = [
+ 'Refresh the page and rejoin using the latest link from your confirmation email.',
+ 'Use the latest Chrome or Edge and check your internet connection.',
+ 'Allow the browser to use your microphone/camera if it asks.',
+ 'Turn off any VPN or ad-blocker and close other video apps.',
+ 'Try an incognito window or a different device.',
+];
+
+const ACCOUNT_STATIC_TIPS = [
+ 'Confirm you are using the same email you registered with.',
+ 'Check spam/junk for a login or OTP email.',
+ 'Wait a minute and request a new code if the OTP expired.',
+ 'If you still cannot sign in, contact the organizers or help desk to reset access.',
+];
+
+function isTechIntent(intent) {
+ return TECH_INTENTS.has(String(intent || ''));
+}
+
+function trimText(value, max = ANSWER_MAX) {
+ const s = String(value || '').trim();
+ if (!s) return '';
+ return s.length > max ? `${s.slice(0, max)}…` : s;
+}
+
+function answeredFaqsForIntent(bot, intent) {
+ const category = INTENT_TO_FAQ_CATEGORY[intent];
+ if (!category) return [];
+ const rows = Array.isArray(bot?.faqs) ? bot.faqs : [];
+ return rows
+ .filter((f) => String(f?.category || '') === category && faqAnswerText(f))
+ .slice(0, MAX_FAQS)
+ .map((f) => ({
+ q: trimText(f.question, 200),
+ a: trimText(faqAnswerText(f), ANSWER_MAX),
+ }))
+ .filter((f) => f.q && f.a);
+}
+
+/**
+ * @param {{ intent: string, bot?: object }} opts
+ */
+async function fetchTechData({ intent, bot = null } = {}) {
+ const intentKey = String(intent || '');
+ const faqs = answeredFaqsForIntent(bot, intentKey);
+ const hasFaqs = faqs.length > 0;
+ const category = INTENT_TO_FAQ_CATEGORY[intentKey] || null;
+
+ const base = {
+ intent: intentKey,
+ category,
+ faqs,
+ staticTips: null,
+ note: null,
+ };
+
+ if (intentKey === 'wifi_credentials') {
+ base.note = hasFaqs
+ ? 'Share Wi‑Fi network name and/or password only from DATA.faqs. Never invent credentials.'
+ : `No Wi‑Fi FAQ is configured. Do not invent SSID or password. Suggest venue signage or the help desk. ${HELPDESK}`;
+ return base;
+ }
+
+ if (intentKey === 'app_issue_streaming') {
+ if (hasFaqs) {
+ base.note = 'Answer streaming/AV help from DATA.faqs only. Do not invent platform-specific settings.';
+ } else {
+ base.staticTips = STREAMING_STATIC_TIPS;
+ base.note =
+ `No streaming FAQ is configured. Share DATA.staticTips as numbered steps, then suggest event support. ${HELPDESK}`;
+ }
+ return base;
+ }
+
+ if (intentKey === 'app_issue_account') {
+ if (hasFaqs) {
+ base.note = 'Answer login/account help from DATA.faqs only. Never invent emails or reset links.';
+ } else {
+ base.staticTips = ACCOUNT_STATIC_TIPS;
+ base.note =
+ `No account FAQ is configured. Share DATA.staticTips, then suggest organizers / help desk. ${HELPDESK}`;
+ }
+ return base;
+ }
+
+ if (intentKey === 'app_feature_guidance') {
+ base.note = hasFaqs
+ ? 'Explain the feature using DATA.faqs only. Do not invent UI steps not in DATA.'
+ : 'No app-feature FAQ is configured. Ask which feature they need help with, or suggest in-app help / organizers. Do not invent a walkthrough.';
+ return base;
+ }
+
+ return base;
+}
+
+module.exports = {
+ TECH_INTENTS,
+ INTENT_TO_FAQ_CATEGORY,
+ isTechIntent,
+ fetchTechData,
+ STREAMING_STATIC_TIPS,
+ ACCOUNT_STATIC_TIPS,
+};
diff --git a/src/services/ticketData.service.js b/src/services/ticketData.service.js
new file mode 100644
index 0000000..bd85005
--- /dev/null
+++ b/src/services/ticketData.service.js
@@ -0,0 +1,379 @@
+const mongoose = require('mongoose');
+const Participant = require('../models/Participant');
+const Event = require('../models/Event');
+const PaymentTransaction = require('../models/PaymentTransaction');
+const TicketManualOrder = require('../models/TicketManualOrder');
+const EventTicket = require('../models/EventTicket');
+const { resolveParticipantTicket, membershipForEvent } = require('../utils/resolveParticipantTicket');
+const { buildQrImageUrl } = require('../utils/participantQr');
+const { ensureParticipantCheckInQrUrl } = require('../utils/participantCheckInQr');
+const { faqAnswerText, emergencyContactFromBot } = require('../utils/faqAnswer');
+const { loadEventRegistrationLinks } = require('../utils/registrationLinks');
+
+const TICKET_INTENTS = new Set([
+ 'ticket_qr_display',
+ 'ticket_email_qr',
+ 'ticket_status_lookup',
+ 'ticket_how_to_register',
+ 'ticket_document_receipt',
+ 'ticket_transfer_request',
+ 'ticket_upgrade',
+]);
+
+const REGISTER_FAQ_CATEGORIES = new Set([
+ 'ticket_checkin',
+ 'registration_update',
+ 'checkin_timing',
+ 'refund_policy',
+]);
+const REGISTER_FAQ_HINT = /\b(register|registration|ticket|sign ?up|pass|enrol|enroll)\b/i;
+const FAQ_ANSWER_MAX = 600;
+
+const AUTH_REQUIRED_TICKET_INTENTS = new Set([
+ 'ticket_qr_display',
+ 'ticket_email_qr',
+ 'ticket_status_lookup',
+ 'ticket_document_receipt',
+]);
+
+const REFUSE_TICKET_INTENTS = new Set(['ticket_transfer_request', 'ticket_upgrade']);
+
+function isTicketIntent(intent) {
+ return TICKET_INTENTS.has(String(intent || ''));
+}
+
+function isAuthRequiredTicketIntent(intent) {
+ return AUTH_REQUIRED_TICKET_INTENTS.has(String(intent || ''));
+}
+
+function isRefuseTicketIntent(intent) {
+ return REFUSE_TICKET_INTENTS.has(String(intent || ''));
+}
+
+function statusLabel(status) {
+ const s = String(status || '').toLowerCase();
+ if (s === 'pending') return 'pending approval';
+ if (s === 'confirmed') return 'confirmed';
+ if (s === 'checked_in') return 'checked in';
+ if (s === 'cancelled') return 'cancelled';
+ if (s === 'rejected') return 'rejected';
+ return s || 'registered';
+}
+
+function attendeeName(p) {
+ if (!p) return 'Attendee';
+ const display = String(p.displayName || '').trim();
+ if (display) return display;
+ const name = [p.firstName, p.lastName].filter(Boolean).join(' ').trim();
+ return name || p.email || 'Attendee';
+}
+
+function formatMoney(minor, currency) {
+ if (minor == null || !Number.isFinite(Number(minor))) return null;
+ const major = Math.round(Number(minor)) / 100;
+ const cur = String(currency || 'INR').toUpperCase();
+ return `${cur} ${major.toLocaleString('en-IN', { minimumFractionDigits: 0, maximumFractionDigits: 2 })}`;
+}
+
+function serializeAttendees(list) {
+ if (!Array.isArray(list)) return [];
+ return list
+ .slice(0, 12)
+ .map((a) => {
+ if (!a || typeof a !== 'object') return null;
+ const name = [a.firstName, a.lastName].filter(Boolean).join(' ').trim()
+ || String(a.displayName || '').trim()
+ || null;
+ const email = String(a.email || '').trim().toLowerCase() || null;
+ if (!name && !email) return null;
+ return { name, email };
+ })
+ .filter(Boolean);
+}
+
+async function loadReceipt({ eventId, participantId, participantEmail, resolved }) {
+ if (resolved?.paymentTx) {
+ const tx = resolved.paymentTx;
+ const ctx = tx.registrationContext && typeof tx.registrationContext === 'object'
+ ? tx.registrationContext
+ : {};
+ let ticketName = resolved.ticket?.name || null;
+ if (!ticketName && ctx.ticketId) {
+ const t = await EventTicket.findById(ctx.ticketId).select('name').lean();
+ ticketName = t?.name || null;
+ }
+ return {
+ kind: 'online',
+ status: tx.status || 'paid',
+ ticketName,
+ currency: tx.currency || ctx.quote?.currency || 'INR',
+ amountLabel: formatMoney(tx.amountGross ?? ctx.quote?.totalMinor, tx.currency),
+ orderId: tx.externalOrderId || String(tx._id),
+ paymentId: tx.externalPaymentId || null,
+ date: tx.createdAt ? new Date(tx.createdAt).toISOString() : null,
+ purchaserEmail: ctx.purchaserEmail || participantEmail || null,
+ attendees: serializeAttendees(ctx.attendees),
+ quote: ctx.quote
+ ? {
+ base: formatMoney(ctx.quote.baseMinor, tx.currency),
+ tax: formatMoney(ctx.quote.taxMinor, tx.currency),
+ total: formatMoney(ctx.quote.totalMinor, tx.currency),
+ }
+ : null,
+ };
+ }
+
+ if (resolved?.manualOrder) {
+ const order = resolved.manualOrder;
+ let ticketName = resolved.ticket?.name || null;
+ if (!ticketName && order.ticket) {
+ const t = await EventTicket.findById(order.ticket).select('name').lean();
+ ticketName = t?.name || null;
+ }
+ return {
+ kind: 'manual',
+ status: order.status || 'approved',
+ ticketName,
+ currency: order.currency || 'INR',
+ amountLabel: formatMoney(order.quote?.totalMinor, order.currency),
+ orderId: String(order._id),
+ paymentId: null,
+ date: order.createdAt ? new Date(order.createdAt).toISOString() : null,
+ purchaserEmail: order.purchaserEmail || participantEmail || null,
+ attendees: serializeAttendees(order.attendees),
+ quote: order.quote
+ ? {
+ base: formatMoney(order.quote.baseMinor, order.currency),
+ tax: formatMoney(order.quote.taxMinor, order.currency),
+ total: formatMoney(order.quote.totalMinor, order.currency),
+ }
+ : null,
+ };
+ }
+
+ const pid = String(participantId);
+ const email = String(participantEmail || '').trim().toLowerCase();
+
+ const tx = await PaymentTransaction.findOne({
+ event: eventId,
+ purpose: 'ticket_purchase',
+ status: 'paid',
+ $or: [
+ { participant: participantId },
+ { 'registrationContext.fulfilledParticipantIds': pid },
+ ...(email ? [{ 'registrationContext.purchaserEmail': email }] : []),
+ ],
+ })
+ .sort({ createdAt: -1 })
+ .lean();
+
+ if (tx) {
+ return loadReceipt({
+ eventId,
+ participantId,
+ participantEmail,
+ resolved: { paymentTx: tx, ticket: resolved?.ticket },
+ });
+ }
+
+ const orderQuery = {
+ event: eventId,
+ $or: [
+ { fulfillmentParticipantIds: pid },
+ ...(email ? [{ purchaserEmail: email }] : []),
+ ],
+ };
+ const order = await TicketManualOrder.findOne(orderQuery).sort({ createdAt: -1 }).lean();
+ if (order) {
+ return loadReceipt({
+ eventId,
+ participantId,
+ participantEmail,
+ resolved: { manualOrder: order, ticket: resolved?.ticket },
+ });
+ }
+
+ return null;
+}
+
+function registrationFaqsFromBot(bot) {
+ const rows = Array.isArray(bot?.faqs) ? bot.faqs : [];
+ return rows
+ .filter((f) => {
+ const cat = String(f?.category || '').trim();
+ const key = String(f?.key || '').trim();
+ const q = String(f?.question || '').trim();
+ if (!faqAnswerText(f)) return false;
+ if (REGISTER_FAQ_CATEGORIES.has(cat) || REGISTER_FAQ_CATEGORIES.has(key)) return true;
+ return REGISTER_FAQ_HINT.test(`${cat} ${key} ${q}`);
+ })
+ .slice(0, 6)
+ .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);
+}
+
+async function fetchHowToRegisterData({ bot, event, eventId, eventName }) {
+ const resolved = await loadEventRegistrationLinks(eventId || event?._id);
+ const eventDoc = resolved.event || (event && typeof event === 'object' ? event : null);
+ const registrationLinks = resolved.links;
+
+ const contactRow = emergencyContactFromBot(bot);
+ const hasContact = Boolean(contactRow?.text || contactRow?.email || contactRow?.phone);
+ const faqs = registrationFaqsFromBot(bot);
+
+ const hasHowTo = Boolean(registrationLinks.length || hasContact || faqs.length);
+ return {
+ intent: 'ticket_how_to_register',
+ found: hasHowTo,
+ eventName: eventName || eventDoc?.name || null,
+ registrationLinks,
+ contact: hasContact
+ ? {
+ email: contactRow.email || null,
+ phone: contactRow.phone || null,
+ detail: contactRow.detail || null,
+ text: contactRow.text || null,
+ }
+ : undefined,
+ faqs,
+ note: hasHowTo
+ ? 'Use DATA.registrationLinks only. Never invent a URL. Do not name the event.'
+ : 'No registration link on file. Share organizer contact only. Do not invent a URL.',
+ };
+}
+
+/**
+ * @param {{ intent: string, eventId: object|string, participantId?: object|string|null, eventName?: string, bot?: object, event?: object }} opts
+ */
+async function fetchTicketData({ intent, eventId, participantId, eventName, bot = null, event = null } = {}) {
+ const intentKey = String(intent || '');
+
+ if (intentKey === 'ticket_how_to_register') {
+ return fetchHowToRegisterData({ bot, event, eventId, eventName });
+ }
+
+ if (isRefuseTicketIntent(intentKey)) {
+ return {
+ intent: intentKey,
+ policy: 'refuse_contact_organizer',
+ eventName: eventName || null,
+ note:
+ 'Politely refuse. Ticket transfers and upgrades cannot be done in chat. Ask the attendee to contact the event organizer or on-site help desk. Offer to help with QR display, emailing their QR, status, or receipt instead.',
+ };
+ }
+
+ if (!participantId || !mongoose.Types.ObjectId.isValid(String(participantId))) {
+ return {
+ intent: intentKey,
+ eventName: eventName || null,
+ note: 'Participant identity is missing.',
+ found: false,
+ };
+ }
+
+ const participant = await Participant.findById(participantId).lean();
+ if (!participant) {
+ return {
+ intent: intentKey,
+ eventName: eventName || null,
+ found: false,
+ note: 'No participant record found.',
+ };
+ }
+
+ const membership = membershipForEvent(participant, eventId);
+ const resolved = await resolveParticipantTicket({
+ participantId,
+ eventId,
+ backfill: true,
+ });
+
+ let eventDoc = null;
+ if (!eventName && eventId) {
+ eventDoc = await Event.findById(eventId).select('name').lean();
+ }
+
+ const name = attendeeName(participant);
+ const email = String(participant.email || '').trim().toLowerCase() || null;
+ const membershipStatus = membership?.status || null;
+ const checkedIn = Boolean(membership?.checkedIn || membershipStatus === 'checked_in');
+
+ let qrImageUrl = null;
+ try {
+ qrImageUrl = await ensureParticipantCheckInQrUrl(participantId);
+ } catch (err) {
+ console.warn('[fetchTicketData] QR ensure failed', err?.message || err);
+ qrImageUrl = buildQrImageUrl(participantId);
+ }
+
+ const payment = participant.payment && typeof participant.payment === 'object'
+ ? {
+ status: participant.payment.status || null,
+ amount: participant.payment.amount != null ? participant.payment.amount : null,
+ currency: participant.payment.currency || 'INR',
+ paidAt: participant.payment.paidAt
+ ? new Date(participant.payment.paidAt).toISOString()
+ : null,
+ }
+ : null;
+
+ const base = {
+ intent: intentKey,
+ found: true,
+ eventName: eventName || eventDoc?.name || null,
+ attendee: {
+ id: String(participant._id),
+ name,
+ email,
+ },
+ membership: {
+ status: membershipStatus,
+ statusLabel: statusLabel(membershipStatus),
+ checkedIn,
+ registrationDate: membership?.registrationDate
+ ? new Date(membership.registrationDate).toISOString()
+ : null,
+ },
+ ticket: resolved.ticket,
+ payment,
+ qr: {
+ imageUrl: qrImageUrl,
+ available: Boolean(qrImageUrl),
+ },
+ };
+
+ if (intentKey === 'ticket_document_receipt' || intentKey === 'ticket_email_qr') {
+ base.receipt = await loadReceipt({
+ eventId,
+ participantId,
+ participantEmail: email,
+ resolved,
+ });
+ }
+
+ if (intentKey === 'ticket_document_receipt' && !base.receipt) {
+ base.note = 'No paid ticket purchase or approved manual order was found for this attendee.';
+ }
+
+ return base;
+}
+
+module.exports = {
+ TICKET_INTENTS,
+ AUTH_REQUIRED_TICKET_INTENTS,
+ REFUSE_TICKET_INTENTS,
+ isTicketIntent,
+ isAuthRequiredTicketIntent,
+ isRefuseTicketIntent,
+ fetchTicketData,
+ buildQrImageUrl,
+ statusLabel,
+ attendeeName,
+};
diff --git a/src/services/venueData.service.js b/src/services/venueData.service.js
new file mode 100644
index 0000000..997de80
--- /dev/null
+++ b/src/services/venueData.service.js
@@ -0,0 +1,306 @@
+const mongoose = require('mongoose');
+const Event = require('../models/Event');
+const Track = require('../models/Track');
+const Session = require('../models/Session');
+const { faqAnswerText, mapMediaFromBot } = require('../utils/faqAnswer');
+const { transportModesFromBot } = require('../utils/transportModes');
+const { looksLikeFrontDesk } = require('../utils/organizerContact');
+
+const VENUE_INTENTS = new Set([
+ 'venue_map_request',
+ 'venue_wayfinding',
+ 'venue_nearest_amenity',
+ 'venue_parking_transport',
+ 'venue_travel_directions',
+ 'venue_food_beverage',
+ 'venue_accessibility',
+]);
+
+/** Intent → FAQ categories (parking and transport are separate). */
+const INTENT_TO_FAQ_CATEGORIES = {
+ venue_map_request: ['venue_map'],
+ venue_wayfinding: ['venue_wayfinding'],
+ venue_nearest_amenity: ['venue_amenity'],
+ // Local parking / taxi / shuttle — parking FAQ only (transport modes via DATA.transportModes)
+ venue_parking_transport: ['venue_parking', 'venue_parking_transport'],
+ // City-to-venue routing — do not mix parking FAQ answers
+ venue_travel_directions: ['venue_transport'],
+ venue_food_beverage: ['venue_food'],
+ venue_accessibility: ['venue_accessibility'],
+};
+
+/** @deprecated use INTENT_TO_FAQ_CATEGORIES */
+const INTENT_TO_FAQ_CATEGORY = Object.fromEntries(
+ Object.entries(INTENT_TO_FAQ_CATEGORIES).map(([intent, cats]) => [intent, cats[0]]),
+);
+
+const MAX_FAQS = 10;
+const ANSWER_MAX = 600;
+const HELPDESK = 'Please ask at the venue help desk for the latest details.';
+
+function isVenueIntent(intent) {
+ return VENUE_INTENTS.has(String(intent || ''));
+}
+
+function trimText(value, max = ANSWER_MAX) {
+ const s = String(value || '').trim();
+ if (!s) return '';
+ return s.length > max ? `${s.slice(0, max)}…` : s;
+}
+
+function answeredFaqsForCategories(bot, categories) {
+ const cats = new Set((Array.isArray(categories) ? categories : []).filter(Boolean));
+ if (!cats.size) return [];
+ const rows = Array.isArray(bot?.faqs) ? bot.faqs : [];
+ return rows
+ .filter((f) => cats.has(String(f?.category || '')) && faqAnswerText(f))
+ .slice(0, MAX_FAQS)
+ .map((f) => ({
+ q: trimText(f.question, 200),
+ a: trimText(faqAnswerText(f), ANSWER_MAX),
+ }))
+ .filter((f) => f.q && f.a);
+}
+
+function answeredFaqsForIntent(bot, intent) {
+ return answeredFaqsForCategories(bot, INTENT_TO_FAQ_CATEGORIES[intent] || []);
+}
+
+function transportModesNote(modes) {
+ if (!modes?.enforce) return null;
+ const allowed = modes.allowed.length ? modes.allowed.join(', ') : '(none selected)';
+ const notAllowed = modes.notAllowed.length ? modes.notAllowed.join(', ') : '(none)';
+ return (
+ `DATA.transportModes is the organizer allow-list for local last-mile options (taxi, Uber/Ola, shuttle, public transport). ` +
+ `Allowed: ${allowed}. Not listed / do not affirm: ${notAllowed}. ` +
+ `If the user asks about a not-listed mode (e.g. Uber when Ride apps is unchecked), say organizers have not listed that option for this event — never say it is available. ` +
+ `Only recommend modes in allowed. Do not invent parking facts from transportModes.`
+ );
+}
+
+async function loadHallsAndRooms(eventId) {
+ const [tracks, sessions] = await Promise.all([
+ Track.find({ event: eventId }).select('name hall').limit(40).lean(),
+ Session.find({ event: eventId, status: { $ne: 'cancelled' } })
+ .select('title location')
+ .limit(80)
+ .lean(),
+ ]);
+
+ const halls = [];
+ const seenHall = new Set();
+ for (const t of tracks) {
+ const hall = String(t.hall || '').trim();
+ if (!hall) continue;
+ const key = hall.toLowerCase();
+ if (seenHall.has(key)) continue;
+ seenHall.add(key);
+ halls.push({ hall, track: String(t.name || '').trim() || null });
+ }
+
+ const rooms = [];
+ const seenRoom = new Set();
+ for (const s of sessions) {
+ const location = String(s.location || '').trim();
+ if (!location) continue;
+ const key = location.toLowerCase();
+ if (seenRoom.has(key)) continue;
+ seenRoom.add(key);
+ rooms.push({ room: location, session: String(s.title || '').trim() || null });
+ }
+
+ return { halls: halls.slice(0, 20), rooms: rooms.slice(0, 25) };
+}
+
+async function loadFoodPlaces(eventId) {
+ try {
+ const coll = mongoose.connection.collection('concierge_food');
+ const doc = await coll.findOne({ eventId: String(eventId) });
+ if (!doc) return [];
+ const restaurants = Array.isArray(doc.restaurants) ? doc.restaurants : [];
+ return restaurants.slice(0, 8).map((r, i) => {
+ const placeId = String(r.place_id || r.id || r.placeId || `food-${i}`);
+ const mapsUrl =
+ trimText(r.google_maps_uri || r.googleMapsUri || r.mapsUrl || r.url, 400) ||
+ (placeId.startsWith('ChIJ')
+ ? `https://www.google.com/maps/place/?q=place_id:${encodeURIComponent(placeId)}`
+ : null);
+ return {
+ id: placeId,
+ name: trimText(r.name, 120) || 'Restaurant',
+ address: trimText(r.address || r.vicinity, 200) || null,
+ rating: r.rating != null ? Number(r.rating) : null,
+ distance: r.distance_meters != null ? Number(r.distance_meters) : null,
+ mapsUrl,
+ };
+ });
+ } catch (err) {
+ console.warn('[venueData] food cache read failed:', err?.message || err);
+ return [];
+ }
+}
+
+/**
+ * @param {{ intent: string, eventId: import('mongoose').Types.ObjectId|string, bot?: object, entities?: object, message?: string }} opts
+ */
+async function fetchVenueData({ intent, eventId, bot = null, entities = {}, message = '' }) {
+ const event = await Event.findById(eventId).select('name location locationType').lean();
+ const eventName = String(event?.name || '').trim() || null;
+ const location = String(event?.location || '').trim() || null;
+
+ let faqs = answeredFaqsForIntent(bot, intent);
+ if (
+ (intent === 'venue_wayfinding' || intent === 'venue_nearest_amenity')
+ && looksLikeFrontDesk(message)
+ ) {
+ const deskFaqs = answeredFaqsForCategories(bot, ['checkin_venue']);
+ const seen = new Set(faqs.map((f) => f.a));
+ for (const row of deskFaqs) {
+ if (!seen.has(row.a)) faqs.push(row);
+ }
+ }
+ const mapMedia = intent === 'venue_map_request' ? mapMediaFromBot(bot) : null;
+ const transportModes =
+ intent === 'venue_parking_transport' || intent === 'venue_travel_directions'
+ ? transportModesFromBot(bot)
+ : null;
+ const hasFaqs = faqs.length > 0 || Boolean(mapMedia?.url);
+ const hasTransportModes = Boolean(transportModes?.enforce);
+ const origin =
+ String(entities?.location || entities?.topic || '').trim() || null;
+
+ const meta = {
+ intent,
+ eventName,
+ location,
+ origin,
+ faqs,
+ mapMedia: mapMedia || undefined,
+ transportModes: transportModes || undefined,
+ };
+
+ switch (intent) {
+ case 'venue_map_request': {
+ if (hasFaqs) {
+ return {
+ ...meta,
+ note: mapMedia?.url
+ ? 'A floor plan / map file is attached for the UI. Tell the attendee they can view or download it. Do not invent other map links.'
+ : null,
+ fallback: null,
+ };
+ }
+ return {
+ ...meta,
+ fallback: { type: 'event_address', eventName, location },
+ note: location || eventName
+ ? 'No map FAQ is configured. Share only the event name and venue address from DATA. Do not invent a map or floor plan.'
+ : 'No map FAQ or venue address is available. Suggest the venue help desk.',
+ };
+ }
+
+ case 'venue_wayfinding': {
+ const { halls, rooms } = await loadHallsAndRooms(eventId);
+ if (hasFaqs) {
+ return { ...meta, halls, rooms, note: null, fallback: null };
+ }
+ return {
+ ...meta,
+ halls,
+ rooms,
+ fallback: { type: 'halls_rooms_helpdesk' },
+ note:
+ 'No wayfinding FAQ is configured. List halls/rooms from DATA if present, and tell the attendee that more details are available at the venue help desk. Do not invent indoor directions.',
+ };
+ }
+
+ case 'venue_nearest_amenity': {
+ if (hasFaqs) return { ...meta, note: null, fallback: null };
+ return {
+ ...meta,
+ fallback: { type: 'helpdesk' },
+ note: `No amenity FAQ is configured. Direct the attendee to the venue help desk. ${HELPDESK}`,
+ };
+ }
+
+ case 'venue_parking_transport': {
+ const modeNote = transportModesNote(transportModes);
+ if (hasFaqs || hasTransportModes) {
+ return {
+ ...meta,
+ note: modeNote,
+ fallback: null,
+ };
+ }
+ return {
+ ...meta,
+ fallback: { type: 'helpdesk', location },
+ note: location
+ ? `No parking/transport FAQ is configured. You may share the venue address from DATA.location, then direct them to the venue help desk. ${HELPDESK}`
+ : `No parking/transport FAQ is configured. Direct the attendee to the venue help desk. ${HELPDESK}`,
+ };
+ }
+
+ case 'venue_travel_directions': {
+ const modeNote = transportModesNote(transportModes);
+ const travelNote = location
+ ? 'For city-to-venue routing (train/flight/road corridors), use general geographic knowledge to DATA.location / DATA.eventName. Remind to verify live schedules. Do not refuse with help-desk-only when a venue address exists.'
+ : `Venue address missing. Give best-effort guidance if the event name implies a city, else suggest the venue help desk. ${HELPDESK}`;
+ return {
+ ...meta,
+ faqs: hasFaqs ? faqs : [],
+ allowGeneralTravel: true,
+ // Keep mode allow-list note even when allowGeneralTravel is set (last-mile must not invent Uber).
+ keepNoteWithGeneralTravel: true,
+ fallback: { type: 'general_travel', location, origin },
+ note: [travelNote, modeNote].filter(Boolean).join(' '),
+ };
+ }
+
+ case 'venue_food_beverage': {
+ const places = await loadFoodPlaces(eventId);
+ if (hasFaqs) {
+ return {
+ ...meta,
+ places: places.length ? places : undefined,
+ note: null,
+ fallback: null,
+ };
+ }
+ return {
+ ...meta,
+ places: places.length ? places : undefined,
+ fallback: { type: 'helpdesk' },
+ note: places.length
+ ? `No food FAQ is configured. You may mention nearby places from DATA.places if relevant, otherwise direct them to the venue help desk. ${HELPDESK}`
+ : `No food FAQ is configured. Direct the attendee to the venue help desk. ${HELPDESK}`,
+ };
+ }
+
+ case 'venue_accessibility': {
+ if (hasFaqs) return { ...meta, note: null, fallback: null };
+ return {
+ ...meta,
+ fallback: { type: 'helpdesk' },
+ note: `No accessibility FAQ is configured. Direct the attendee to the venue help desk. ${HELPDESK}`,
+ };
+ }
+
+ default:
+ return {
+ ...meta,
+ note: 'Unsupported venue intent.',
+ fallback: { type: 'helpdesk' },
+ entities,
+ };
+ }
+}
+
+module.exports = {
+ VENUE_INTENTS,
+ INTENT_TO_FAQ_CATEGORY,
+ INTENT_TO_FAQ_CATEGORIES,
+ isVenueIntent,
+ fetchVenueData,
+ answeredFaqsForIntent,
+};
diff --git a/src/services/whatsappBlast.service.js b/src/services/whatsappBlast.service.js
new file mode 100644
index 0000000..5308b03
--- /dev/null
+++ b/src/services/whatsappBlast.service.js
@@ -0,0 +1,130 @@
+const WhatsAppBlast = require('../models/WhatsAppBlast');
+const WhatsAppChatSession = require('../models/WhatsAppChatSession');
+const { getOrgWhatsAppCredentials } = require('../utils/whatsappCloud');
+const { sendMessageWithCtas } = require('../utils/whatsappSend');
+const { logWhatsApp, maskPhone } = require('../utils/whatsappLog');
+
+const WINDOW_MS = 24 * 60 * 60 * 1000;
+const BLASTS_PER_TICK = 3;
+
+function eligibleSessionFilter(eventId, now = new Date()) {
+ const since = new Date(now.getTime() - WINDOW_MS);
+ return {
+ eventId,
+ endedAt: null,
+ participant: { $exists: true, $nin: [null] },
+ lastSeenAt: { $gte: since },
+ phoneNumber: { $exists: true, $nin: [null, ''] },
+ };
+}
+
+function digits(phone) {
+ return String(phone || '').replace(/\D/g, '');
+}
+
+function uniqueEligibleSessions(rows) {
+ const seen = new Set();
+ const out = [];
+ for (const row of rows || []) {
+ const phone = digits(row.phoneNumber);
+ if (!phone || seen.has(phone)) continue;
+ seen.add(phone);
+ out.push({ ...row, phoneNumber: phone });
+ }
+ return out;
+}
+
+async function claimDueBlast(now) {
+ return WhatsAppBlast.findOneAndUpdate(
+ { status: 'queued', sendAt: { $lte: now } },
+ { $set: { status: 'sending' } },
+ { sort: { sendAt: 1 }, new: true },
+ );
+}
+
+async function sendOne(creds, session, blast) {
+ await sendMessageWithCtas(creds.phoneNumberId, creds.accessToken, session.phoneNumber, {
+ body: blast.body,
+ mediaType: blast.mediaType,
+ mediaUrl: blast.mediaUrl,
+ ctas: blast.ctas,
+ });
+}
+
+async function processBlast(blast) {
+ const now = new Date();
+ const creds = await getOrgWhatsAppCredentials(String(blast.organization || ''));
+ if (!creds?.accessToken || !creds.phoneNumberId) {
+ blast.status = 'failed';
+ blast.lastError = 'missing_credentials';
+ blast.sentAt = now;
+ await blast.save();
+ logWhatsApp('blast.fail', { blastId: String(blast._id), reason: 'missing_credentials' });
+ return;
+ }
+
+ const rows = await WhatsAppChatSession.find(eligibleSessionFilter(blast.event, now))
+ .select('phoneNumber lastSeenAt participant')
+ .lean();
+ const sessions = uniqueEligibleSessions(rows);
+ const stats = { eligible: sessions.length, sent: 0, skipped: 0, failed: 0 };
+
+ for (const session of sessions) {
+ try {
+ await sendOne(creds, session, blast);
+ stats.sent += 1;
+ logWhatsApp('blast.sent', {
+ blastId: String(blast._id),
+ to: maskPhone(session.phoneNumber),
+ });
+ } catch (err) {
+ if (err && err.skip) {
+ stats.skipped += 1;
+ logWhatsApp('blast.skip', {
+ blastId: String(blast._id),
+ to: maskPhone(session.phoneNumber),
+ reason: err.message || 'skipped',
+ });
+ } else {
+ stats.failed += 1;
+ logWhatsApp('blast.send_fail', {
+ blastId: String(blast._id),
+ to: maskPhone(session.phoneNumber),
+ error: (err && err.message) || 'send_failed',
+ });
+ }
+ }
+ }
+
+ blast.stats = stats;
+ blast.sentAt = new Date();
+ blast.status = 'sent';
+ blast.lastError = stats.failed && !stats.sent ? 'all_sends_failed' : '';
+ if (stats.failed && !stats.sent && stats.eligible > 0) {
+ blast.status = 'failed';
+ }
+ await blast.save();
+ logWhatsApp('blast.done', { blastId: String(blast._id), status: blast.status, ...stats });
+}
+
+async function sendDueBlasts() {
+ const now = new Date();
+ for (let i = 0; i < BLASTS_PER_TICK; i += 1) {
+ const blast = await claimDueBlast(now);
+ if (!blast) return;
+ try {
+ await processBlast(blast);
+ } catch (err) {
+ console.error('[whatsappBlast]', err.message);
+ blast.status = 'failed';
+ blast.lastError = err.message || 'send_failed';
+ blast.sentAt = new Date();
+ await blast.save().catch(() => {});
+ }
+ }
+}
+
+module.exports = {
+ sendDueBlasts,
+ eligibleSessionFilter,
+};
diff --git a/src/services/whatsappDrip.service.js b/src/services/whatsappDrip.service.js
new file mode 100644
index 0000000..45b9b39
--- /dev/null
+++ b/src/services/whatsappDrip.service.js
@@ -0,0 +1,300 @@
+const WhatsAppDripJob = require('../models/WhatsAppDripJob');
+const WhatsAppChatSession = require('../models/WhatsAppChatSession');
+const WhatsAppTemplate = require('../models/WhatsAppTemplate');
+const ConciergeBot = require('../models/ConciergeBot');
+const Event = require('../models/Event');
+const { getOrgWhatsAppCredentials } = require('../utils/whatsappCloud');
+const {
+ sendTextMessage,
+ sendTemplateMessage,
+ sendMessageWithCtas,
+} = require('../utils/whatsappSend');
+const { interpolateReception } = require('../utils/receptionCopy');
+const { logWhatsApp, maskPhone } = require('../utils/whatsappLog');
+const { listUpcomingSessions } = require('./agendaData.service');
+const { normalizeWhatsAppCtas } = require('../utils/whatsappCtas');
+const Participant = require('../models/Participant');
+
+const WINDOW_MS = 24 * 60 * 60 * 1000;
+
+function dripSendAt(drip, session, now) {
+ const trigger = drip.trigger || 'after_first_inbound';
+ if (trigger === 'absolute' && drip.sendAt) return new Date(drip.sendAt);
+ const minutes = Number(drip.delayMinutes) || 0;
+ const base =
+ trigger === 'after_match' && session.matchedAt
+ ? new Date(session.matchedAt)
+ : session.firstInboundAt
+ ? new Date(session.firstInboundAt)
+ : now;
+ return new Date(base.getTime() + minutes * 60 * 1000);
+}
+
+function dripIdentity(drip) {
+ if (drip && drip._id) return String(drip._id);
+ return [
+ drip?.contentKind || 'custom',
+ drip?.trigger || '',
+ drip?.delayMinutes ?? '',
+ drip?.agendaCount ?? '',
+ drip?.body || '',
+ ].join('|');
+}
+
+function clampAgendaCount(n) {
+ const v = Number(n);
+ if (!Number.isFinite(v)) return 3;
+ return Math.min(8, Math.max(1, Math.round(v)));
+}
+
+function formatAgendaSessionBlock(row) {
+ const speakers = Array.isArray(row.speakers)
+ ? row.speakers.map((s) => s && s.name).filter(Boolean).join(', ')
+ : '';
+ const place = [row.track && row.track.name, row.track && row.track.hall, row.location]
+ .filter(Boolean)
+ .join(' · ');
+ return [
+ row.timeRangeLocal || '',
+ row.title || 'Session',
+ place,
+ speakers ? `Speakers: ${speakers}` : '',
+ ]
+ .filter(Boolean)
+ .join('\n');
+}
+
+function formatUpcomingAgendaBody(intro, sessions) {
+ const blocks = (sessions || []).map(formatAgendaSessionBlock).filter(Boolean);
+ const list = blocks.join('\n\n');
+ const lead = String(intro || '').trim();
+ if (lead && list) return `${lead}\n\n${list}`;
+ return list || lead;
+}
+
+async function scheduleDripsForSession(session, bot, { phase }) {
+ const reception = bot.reception || {};
+ const drips = Array.isArray(reception.drips) ? reception.drips : [];
+ if (!drips.length) {
+ logWhatsApp('drip.schedule_skip', { reason: 'no_drips', phase, sessionId: String(session._id) });
+ return 0;
+ }
+ const audience = reception.dripAudience === 'all_inbound' ? 'all_inbound' : 'matched';
+ const now = new Date();
+ const created = [];
+
+ for (const drip of drips) {
+ const trigger = drip.trigger || 'after_first_inbound';
+ if (phase === 'first_inbound' && trigger === 'after_match') continue;
+ // Matched audience schedules every drip on identify. All-inbound already queued
+ // first_inbound/absolute at first message — only enqueue after_match here.
+ if (phase === 'match' && audience === 'all_inbound' && trigger !== 'after_match') continue;
+
+ const sendAt = dripSendAt(drip, session, now);
+ if (!sendAt || Number.isNaN(sendAt.getTime())) continue;
+ const dripId = dripIdentity(drip);
+ const contentKind = drip.contentKind === 'upcoming_agenda' ? 'upcoming_agenda' : 'custom';
+ const dup = await WhatsAppDripJob.findOne({
+ session: session._id,
+ dripId,
+ status: { $in: ['pending', 'sent'] },
+ }).select('_id');
+ if (dup) continue;
+ const job = await WhatsAppDripJob.create({
+ botId: session.botId,
+ session: session._id,
+ phoneNumber: session.phoneNumber,
+ organization: session.organization,
+ sendAt,
+ requireMatched: audience === 'matched',
+ body: drip.body || '',
+ mediaType: contentKind === 'upcoming_agenda' ? 'none' : drip.mediaType || 'none',
+ mediaUrl: contentKind === 'upcoming_agenda' ? '' : drip.mediaUrl || '',
+ contentKind,
+ agendaCount: contentKind === 'upcoming_agenda' ? clampAgendaCount(drip.agendaCount) : 0,
+ dripId,
+ templateId: drip.templateId || null,
+ ctas: contentKind === 'upcoming_agenda' ? [] : normalizeWhatsAppCtas(drip.ctas),
+ status: 'pending',
+ });
+ created.push(job._id);
+ logWhatsApp('drip.queued', {
+ sessionId: String(session._id),
+ to: maskPhone(session.phoneNumber),
+ sendAt: sendAt.toISOString(),
+ delayMinutes: Number(drip.delayMinutes) || 0,
+ trigger,
+ contentKind,
+ agendaCount: contentKind === 'upcoming_agenda' ? clampAgendaCount(drip.agendaCount) : undefined,
+ dueNow: sendAt.getTime() <= now.getTime(),
+ });
+ }
+ if (created.length) {
+ session.dripJobIds = [...(session.dripJobIds || []), ...created];
+ await session.save();
+ await sendDueDrips();
+ }
+ return created.length;
+}
+
+async function sendDueDrips() {
+ const now = new Date();
+ const due = await WhatsAppDripJob.find({ status: 'pending', sendAt: { $lte: now } }).limit(25);
+ for (const job of due) {
+ try {
+ const session = await WhatsAppChatSession.findById(job.session);
+ if (!session) {
+ job.status = 'skipped';
+ job.lastError = 'session_missing';
+ await job.save();
+ continue;
+ }
+ if (job.requireMatched && !session.participant) {
+ logWhatsApp('drip.hold', {
+ jobId: String(job._id),
+ reason: 'waiting_for_match',
+ to: maskPhone(session.phoneNumber),
+ });
+ continue;
+ }
+ const bot = await ConciergeBot.findById(job.botId).lean();
+ if (!bot || !bot.reception?.enabled) {
+ job.status = 'skipped';
+ job.lastError = 'reception_disabled';
+ await job.save();
+ continue;
+ }
+ const creds = await getOrgWhatsAppCredentials(String(job.organization || bot.organization));
+ if (!creds?.accessToken || !creds.phoneNumberId) {
+ job.status = 'failed';
+ job.lastError = 'missing_credentials';
+ await job.save();
+ continue;
+ }
+
+ const firstAt = session.firstInboundAt ? new Date(session.firstInboundAt).getTime() : 0;
+ const outsideWindow = firstAt && now.getTime() - firstAt > WINDOW_MS;
+ let participant = null;
+ if (session.participant) {
+ participant = await Participant.findById(session.participant).lean();
+ }
+ const firstName = participant?.firstName || String(session.waProfileName || '').split(/\s+/)[0];
+ let eventName = '';
+ if (session.eventId) {
+ const ev = await Event.findById(session.eventId).select('name').lean();
+ eventName = ev?.name || '';
+ }
+ const body = interpolateReception(job.body, {
+ profileName: session.waProfileName,
+ firstName,
+ eventName,
+ });
+ const isAgenda = job.contentKind === 'upcoming_agenda';
+
+ if (outsideWindow) {
+ if (isAgenda) {
+ job.status = 'skipped';
+ job.lastError = 'outside_24h_agenda';
+ await job.save();
+ continue;
+ }
+ if (!job.templateId) {
+ job.status = 'skipped';
+ job.lastError = 'outside_24h_no_template';
+ await job.save();
+ continue;
+ }
+ const tpl = await WhatsAppTemplate.findById(job.templateId).lean();
+ if (!tpl || String(tpl.status || '').toUpperCase() !== 'APPROVED') {
+ job.status = 'failed';
+ job.lastError = 'template_not_approved';
+ await job.save();
+ continue;
+ }
+ await sendTemplateMessage(creds.phoneNumberId, creds.accessToken, session.phoneNumber, {
+ name: tpl.name,
+ language: { code: String(tpl.language || 'en').replace('-', '_') },
+ });
+ } else if (isAgenda) {
+ const sessions = await listUpcomingSessions(session.eventId, {
+ limit: clampAgendaCount(job.agendaCount),
+ now,
+ });
+ if (!sessions.length) {
+ job.status = 'skipped';
+ job.lastError = 'no_upcoming_sessions';
+ await job.save();
+ logWhatsApp('drip.skip', {
+ jobId: String(job._id),
+ reason: 'no_upcoming_sessions',
+ to: maskPhone(session.phoneNumber),
+ });
+ continue;
+ }
+ const text = formatUpcomingAgendaBody(body, sessions);
+ await sendTextMessage(creds.phoneNumberId, creds.accessToken, session.phoneNumber, text);
+ logWhatsApp('drip.agenda', {
+ jobId: String(job._id),
+ count: sessions.length,
+ to: maskPhone(session.phoneNumber),
+ });
+ } else {
+ try {
+ await sendMessageWithCtas(creds.phoneNumberId, creds.accessToken, session.phoneNumber, {
+ body,
+ mediaType: job.mediaType,
+ mediaUrl: job.mediaUrl,
+ ctas: job.ctas,
+ });
+ } catch (sendErr) {
+ if (sendErr && sendErr.code === 'empty_body') {
+ job.status = 'skipped';
+ job.lastError = 'empty_body';
+ await job.save();
+ continue;
+ }
+ throw sendErr;
+ }
+ }
+ job.status = 'sent';
+ job.sentAt = new Date();
+ await job.save();
+ logWhatsApp('drip.sent', { jobId: String(job._id), to: maskPhone(session.phoneNumber) });
+ } catch (err) {
+ console.error('[whatsappDrip]', err.message);
+ job.status = 'failed';
+ job.lastError = err.message || 'send_failed';
+ await job.save();
+ }
+ }
+}
+
+const { sendDueBlasts } = require('./whatsappBlast.service');
+
+let pollTimer = null;
+
+function pollTick() {
+ sendDueDrips().catch((err) => console.error('[whatsappDrip] poll', err.message));
+ sendDueBlasts().catch((err) => console.error('[whatsappBlast] poll', err.message));
+}
+
+function startDripPoller() {
+ if (pollTimer) return;
+ pollTick();
+ pollTimer = setInterval(pollTick, 20 * 1000);
+ if (typeof pollTimer.unref === 'function') pollTimer.unref();
+ console.log('[whatsappDrip] poller started');
+}
+
+function stopDripPoller() {
+ if (!pollTimer) return;
+ clearInterval(pollTimer);
+ pollTimer = null;
+}
+
+module.exports = {
+ scheduleDripsForSession,
+ sendDueDrips,
+ startDripPoller,
+ stopDripPoller,
+};
diff --git a/src/services/whatsappFollowup.service.js b/src/services/whatsappFollowup.service.js
new file mode 100644
index 0000000..72fefd8
--- /dev/null
+++ b/src/services/whatsappFollowup.service.js
@@ -0,0 +1,91 @@
+const crypto = require('crypto');
+const { handleChatTurn } = require('./orchestrator.service');
+const { sendConciergeResultAsWhatsApp } = require('../utils/whatsappFormat');
+const { getOrgWhatsAppCredentials } = require('../utils/whatsappCloud');
+const { sendTextMessage } = require('../utils/whatsappSend');
+const { logWhatsApp } = require('../utils/whatsappLog');
+
+const MAX_HISTORY_MESSAGES = 40;
+
+function inboundText(messageData) {
+ const text = String(messageData?.text?.body || '').trim();
+ if (text) return text;
+ const btn =
+ messageData?.interactive?.button_reply?.title ||
+ messageData?.interactive?.list_reply?.title ||
+ '';
+ return String(btn).trim();
+}
+
+async function handleFollowup({ bot, event, session, messageData }) {
+ const type = String(messageData?.type || '');
+ const creds = await getOrgWhatsAppCredentials(String(bot.organization));
+ if (!creds?.accessToken || !creds.phoneNumberId) {
+ logWhatsApp('followup.skip', { reason: 'missing_credentials' });
+ return;
+ }
+
+ if (type === 'image' || type === 'video' || type === 'audio' || type === 'document' || type === 'sticker') {
+ await sendTextMessage(
+ creds.phoneNumberId,
+ creds.accessToken,
+ session.phoneNumber,
+ 'Please send your question as text.',
+ );
+ return;
+ }
+
+ const msg = inboundText(messageData);
+ if (!msg) return;
+
+ session.history = Array.isArray(session.history) ? session.history : [];
+ session.history.push({
+ role: 'user',
+ text: msg,
+ ts: new Date(),
+ modelId: null,
+ inputTokens: 0,
+ outputTokens: 0,
+ });
+ if (session.history.length > MAX_HISTORY_MESSAGES) {
+ session.history = session.history.slice(-MAX_HISTORY_MESSAGES);
+ }
+ session.lastSeenAt = new Date();
+
+ const result = await handleChatTurn({ bot, event, session, message: msg });
+ const reply = String(result.reply || '').trim().slice(0, 2500) || 'Sorry, I could not respond just now.';
+ const inTok = result.usage?.inputTokens || 0;
+ const outTok = result.usage?.outputTokens || 0;
+ session.inputTokensTotal = Number(session.inputTokensTotal || 0) + inTok;
+ session.outputTokensTotal = Number(session.outputTokensTotal || 0) + outTok;
+ session.history.push({
+ role: 'assistant',
+ text: reply,
+ ts: new Date(),
+ modelId: result.modelId || null,
+ inputTokens: inTok,
+ outputTokens: outTok,
+ msgId: crypto.randomBytes(8).toString('hex'),
+ satisfied: null,
+ feedbackAt: null,
+ });
+ if (session.history.length > MAX_HISTORY_MESSAGES) {
+ session.history = session.history.slice(-MAX_HISTORY_MESSAGES);
+ }
+ if (result.intent) session.lastIntent = result.intent;
+ if (Array.isArray(result.sessionIds) && result.sessionIds.length) {
+ session.lastSessionIds = result.sessionIds.map((id) => String(id)).filter(Boolean).slice(0, 8);
+ }
+ await session.save();
+
+ try {
+ await sendConciergeResultAsWhatsApp(creds, session.phoneNumber, {
+ ...result,
+ reply,
+ });
+ } catch (err) {
+ console.error('[whatsapp] followup send failed', err.message);
+ }
+}
+
+module.exports = { handleFollowup, inboundText };
diff --git a/src/services/whatsappInbound.service.js b/src/services/whatsappInbound.service.js
new file mode 100644
index 0000000..8448229
--- /dev/null
+++ b/src/services/whatsappInbound.service.js
@@ -0,0 +1,144 @@
+const ConciergeBot = require('../models/ConciergeBot');
+const Event = require('../models/Event');
+const WhatsAppChatSession = require('../models/WhatsAppChatSession');
+const { getOrgWhatsAppCredentials } = require('../utils/whatsappCloud');
+const { handleFirstInbound, handleUnlinkedMessage } = require('./whatsappReception.service');
+const { handleFollowup, inboundText } = require('./whatsappFollowup.service');
+const { logWhatsApp, maskPhone } = require('../utils/whatsappLog');
+
+async function findReceptionBotByPhoneNumberId(phoneNumberId) {
+ const pid = String(phoneNumberId || '').trim();
+ if (!pid) {
+ logWhatsApp('inbound.no_phone_id', {});
+ return null;
+ }
+ // Match on reception.enabled only. Status ARCHIVED is a knowledge-purge
+ // marker (ended events) and must not block a reception desk that is still on.
+ const bots = await ConciergeBot.find({ 'reception.enabled': true })
+ .setOptions({ strictQuery: false })
+ .lean();
+ logWhatsApp('inbound.reception_bots', {
+ incomingPhoneNumberId: pid,
+ count: bots.length,
+ bots: bots.map((b) => ({
+ botId: String(b._id),
+ name: b.name || '',
+ status: b.status || '',
+ })),
+ });
+ for (const bot of bots) {
+ const creds = await getOrgWhatsAppCredentials(String(bot.organization));
+ const orgPid = creds ? String(creds.phoneNumberId || '').trim() : '';
+ logWhatsApp('inbound.bot_check', {
+ botId: String(bot._id),
+ botName: bot.name || '',
+ status: bot.status || '',
+ orgPhoneNumberId: orgPid || '(none)',
+ match: Boolean(creds && orgPid === pid),
+ });
+ if (creds && orgPid === pid) return bot;
+ }
+ return null;
+}
+
+async function processInboundMessage(jobData) {
+ const { phoneId, messageData, profileName } = jobData || {};
+ if (!messageData) {
+ logWhatsApp('inbound.skip', { reason: 'no_message' });
+ return;
+ }
+
+ const bot = await findReceptionBotByPhoneNumberId(phoneId);
+ if (!bot) {
+ logWhatsApp('inbound.skip', {
+ reason: 'no_reception_bot',
+ phoneNumberId: String(phoneId || ''),
+ });
+ return;
+ }
+
+ const eventId = bot.event;
+ const event = await Event.findById(eventId).select('name location locationType').lean();
+ const eventName = event?.name || 'Event';
+ const userPhone = String(messageData.from || '').replace(/\D/g, '');
+ if (!userPhone) {
+ logWhatsApp('inbound.skip', { reason: 'no_from', botId: String(bot._id) });
+ return;
+ }
+
+ logWhatsApp('inbound.process', {
+ botId: String(bot._id),
+ eventName,
+ from: maskPhone(userPhone),
+ type: messageData.type || '',
+ profileName: profileName || '',
+ dripCount: Array.isArray(bot.reception?.drips) ? bot.reception.drips.length : 0,
+ dripAudience: bot.reception?.dripAudience || 'matched',
+ });
+
+ let session = await WhatsAppChatSession.findOne({ botId: bot._id, phoneNumber: userPhone });
+ if (!session) {
+ try {
+ session = await WhatsAppChatSession.create({
+ botId: bot._id,
+ eventId,
+ organization: bot.organization,
+ phoneNumber: userPhone,
+ waProfileName: profileName || '',
+ receptionState: 'IDENTIFYING',
+ history: [],
+ lastSeenAt: new Date(),
+ });
+ } catch (err) {
+ if (err && err.code === 11000) {
+ session = await WhatsAppChatSession.findOne({ botId: bot._id, phoneNumber: userPhone });
+ } else {
+ throw err;
+ }
+ }
+ if (!session) return;
+ }
+ session.lastSeenAt = new Date();
+
+ const isFirst = !session.firstInboundAt;
+ logWhatsApp('inbound.session', {
+ sessionId: String(session._id),
+ first: isFirst,
+ receptionState: session.receptionState,
+ hasParticipant: Boolean(session.participant),
+ });
+ if (isFirst) {
+ await handleFirstInbound(bot, session, {
+ profileName: profileName || session.waProfileName,
+ eventId,
+ eventName,
+ messageData,
+ });
+ return;
+ }
+
+ // Drips are queued at first inbound / match. If the organiser added them later,
+ // pick them up on the next message (deduped) so a 1-minute test can still fire.
+ if (!(session.dripJobIds || []).length) {
+ const audience = bot.reception?.dripAudience === 'all_inbound' ? 'all_inbound' : 'matched';
+ if (session.participant || audience === 'all_inbound') {
+ const { scheduleDripsForSession } = require('./whatsappDrip.service');
+ await scheduleDripsForSession(session, bot, {
+ phase: session.participant ? 'match' : 'first_inbound',
+ });
+ }
+ }
+
+ if (session.receptionState !== 'ONBOARDED' || !session.participant) {
+ await handleUnlinkedMessage(bot, session, { messageData, eventId, eventName });
+ return;
+ }
+
+ await handleFollowup({ bot, event, session, messageData });
+}
+
+module.exports = {
+ processInboundMessage,
+ findReceptionBotByPhoneNumberId,
+ inboundText,
+};
diff --git a/src/services/whatsappReception.service.js b/src/services/whatsappReception.service.js
new file mode 100644
index 0000000..6320fc5
--- /dev/null
+++ b/src/services/whatsappReception.service.js
@@ -0,0 +1,211 @@
+const Participant = require('../models/Participant');
+const {
+ matchParticipantByPhone,
+ matchParticipantByName,
+ participantFullName,
+ membershipForEvent,
+ membershipAllowed,
+} = require('../utils/participantMatch');
+const { getOrgWhatsAppCredentials } = require('../utils/whatsappCloud');
+const { sendTextMessage, downloadWhatsAppMedia } = require('../utils/whatsappSend');
+const { decodeParticipantIdFromImage } = require('../utils/badgeQr');
+const { detectBadgeTextLines } = require('../utils/badgeOcr');
+const {
+ interpolateReception,
+ defaultBadgePrompt,
+ defaultStaffHandoff,
+ defaultWelcome,
+} = require('../utils/receptionCopy');
+const { scheduleDripsForSession } = require('./whatsappDrip.service');
+const { logWhatsApp, maskPhone } = require('../utils/whatsappLog');
+
+function receptionCopy(bot) {
+ const r = bot.reception || {};
+ return {
+ welcome: String(r.welcomeMessage || '').trim() || defaultWelcome(),
+ badge: String(r.unmatchedBadgePrompt || '').trim() || defaultBadgePrompt(),
+ staff: String(r.staffHandoffMessage || '').trim() || defaultStaffHandoff(),
+ };
+}
+
+async function credsForBot(bot) {
+ return getOrgWhatsAppCredentials(String(bot.organization));
+}
+
+async function sendToSession(bot, session, text) {
+ const creds = await credsForBot(bot);
+ if (!creds?.accessToken || !creds.phoneNumberId) {
+ logWhatsApp('send.skip', {
+ reason: 'missing_credentials',
+ hasToken: Boolean(creds?.accessToken),
+ phoneNumberId: creds?.phoneNumberId || '',
+ to: maskPhone(session.phoneNumber),
+ });
+ return;
+ }
+ try {
+ logWhatsApp('send.text', {
+ to: maskPhone(session.phoneNumber),
+ preview: String(text || '').slice(0, 80),
+ });
+ await sendTextMessage(creds.phoneNumberId, creds.accessToken, session.phoneNumber, text);
+ } catch (err) {
+ console.error('[whatsapp] reception send failed', err.message);
+ }
+}
+
+async function linkParticipant(session, participant, method, score) {
+ session.participant = participant._id;
+ session.identityVerifiedAt = new Date();
+ session.identityMethod = method;
+ session.matchScore = score != null ? score : 1;
+ session.matchedAt = new Date();
+ session.receptionState = 'ONBOARDED';
+ await session.save();
+}
+
+async function sendWelcomeAndDrips(bot, session, participant, eventName) {
+ const copy = receptionCopy(bot);
+ const firstName = participant?.firstName || '';
+ const profileName = participantFullName(participant) || session.waProfileName;
+ const text = interpolateReception(copy.welcome, {
+ firstName,
+ profileName,
+ eventName,
+ });
+ await sendToSession(bot, session, text);
+ session.welcomeSentAt = new Date();
+ logWhatsApp('welcome.queued', { participantId: String(participant._id), eventName });
+ await session.save();
+ await scheduleDripsForSession(session, bot, { phase: 'match' });
+}
+
+async function tryIdentify(bot, session, { waName, eventId, eventName }) {
+ const phoneHit = await matchParticipantByPhone({
+ Participant,
+ organizationId: bot.organization,
+ eventId,
+ waDigits: session.phoneNumber,
+ });
+ if (phoneHit.kind === 'unique') {
+ logWhatsApp('match.phone', { kind: 'unique', score: phoneHit.score });
+ await linkParticipant(session, phoneHit.participant, 'phone', phoneHit.score);
+ await sendWelcomeAndDrips(bot, session, phoneHit.participant, eventName);
+ return true;
+ }
+ logWhatsApp('match.phone', { kind: phoneHit.kind, candidates: (phoneHit.candidates || []).length });
+
+ const nameHit = await matchParticipantByName({
+ Participant,
+ organizationId: bot.organization,
+ eventId,
+ waName,
+ });
+ if (nameHit.kind === 'unique') {
+ logWhatsApp('match.name', { kind: 'unique', score: nameHit.score, waName });
+ await linkParticipant(session, nameHit.participant, 'name', nameHit.score);
+ await sendWelcomeAndDrips(bot, session, nameHit.participant, eventName);
+ return true;
+ }
+ logWhatsApp('match.name', { kind: nameHit.kind, score: nameHit.topScore || 0, waName });
+
+ session.receptionState = 'AWAITING_BADGE';
+ await session.save();
+ return false;
+}
+
+async function handleFirstInbound(bot, session, { profileName, eventId, eventName, messageData }) {
+ session.waProfileName = profileName || session.waProfileName || '';
+ session.firstInboundAt = session.firstInboundAt || new Date();
+ session.receptionState = 'IDENTIFYING';
+ await session.save();
+
+ if (bot.reception?.dripAudience === 'all_inbound') {
+ await scheduleDripsForSession(session, bot, { phase: 'first_inbound' });
+ }
+
+ const linked = await tryIdentify(bot, session, { waName: session.waProfileName, eventId, eventName });
+ if (linked) return true;
+ if (String(messageData?.type || '') === 'image' && messageData.image?.id) {
+ await handleBadgeImage(bot, session, { mediaId: messageData.image.id, eventId, eventName });
+ return Boolean(session.participant);
+ }
+ await sendToSession(bot, session, receptionCopy(bot).badge);
+ logWhatsApp('badge.prompt', { from: maskPhone(session.phoneNumber) });
+ return false;
+}
+
+async function handleBadgeImage(bot, session, { mediaId, eventId, eventName }) {
+ const creds = await credsForBot(bot);
+ if (!creds?.accessToken) {
+ await sendToSession(bot, session, receptionCopy(bot).staff);
+ return;
+ }
+ const media = await downloadWhatsAppMedia(mediaId, creds.accessToken);
+ session.badgeAttempts = Number(session.badgeAttempts || 0) + 1;
+
+ if (media?.buffer) {
+ const participantId = await decodeParticipantIdFromImage(media.buffer);
+ if (participantId) {
+ const p = await Participant.findById(participantId).lean();
+ const mem = p && membershipForEvent(p, eventId);
+ if (
+ p &&
+ !p.deletedAt &&
+ String(p.tenantOrganization) === String(bot.organization) &&
+ membershipAllowed(mem)
+ ) {
+ await linkParticipant(session, p, 'badge_qr', 1);
+ await sendWelcomeAndDrips(bot, session, p, eventName);
+ return;
+ }
+ }
+
+ const lines = await detectBadgeTextLines(media.buffer);
+ for (const line of lines) {
+ const nameHit = await matchParticipantByName({
+ Participant,
+ organizationId: bot.organization,
+ eventId,
+ waName: line,
+ });
+ if (nameHit.kind === 'unique') {
+ await linkParticipant(session, nameHit.participant, 'badge_ocr', nameHit.score);
+ await sendWelcomeAndDrips(bot, session, nameHit.participant, eventName);
+ return;
+ }
+ }
+ }
+
+ session.receptionState = 'AWAITING_STAFF';
+ await session.save();
+ await sendToSession(bot, session, receptionCopy(bot).staff);
+}
+
+async function handleUnlinkedMessage(bot, session, { messageData, eventId, eventName }) {
+ const type = String(messageData?.type || '');
+ if (type === 'image' && messageData.image?.id) {
+ await handleBadgeImage(bot, session, {
+ mediaId: messageData.image.id,
+ eventId,
+ eventName,
+ });
+ return;
+ }
+ const copy = receptionCopy(bot);
+ if (session.receptionState === 'AWAITING_STAFF') {
+ await sendToSession(bot, session, copy.staff);
+ return;
+ }
+ session.receptionState = 'AWAITING_BADGE';
+ await session.save();
+ await sendToSession(bot, session, copy.badge);
+}
+
+module.exports = {
+ handleFirstInbound,
+ handleUnlinkedMessage,
+ handleBadgeImage,
+ linkParticipant,
+ sendWelcomeAndDrips,
+};
diff --git a/src/utils/accommodationCards.js b/src/utils/accommodationCards.js
new file mode 100644
index 0000000..79cb14e
--- /dev/null
+++ b/src/utils/accommodationCards.js
@@ -0,0 +1,55 @@
+const MAX_HOTEL_CARDS = 6;
+
+function buildAccommodationCards(hotelIds, data) {
+ const hotels = Array.isArray(data?.hotels) ? data.hotels : [];
+ const byId = new Map(hotels.map((h) => [String(h.id), h]));
+ let ids = (Array.isArray(hotelIds) ? hotelIds : [])
+ .map((id) => String(id || '').trim())
+ .filter(Boolean);
+
+ if (!ids.length && hotels.length) {
+ ids = hotels.map((h) => String(h.id)).filter(Boolean).slice(0, MAX_HOTEL_CARDS);
+ }
+
+ const seen = new Set();
+ const cards = [];
+ for (const id of ids) {
+ if (seen.has(id)) continue;
+ seen.add(id);
+ const h = byId.get(id);
+ if (!h) continue;
+ cards.push({
+ type: 'accommodation',
+ data: {
+ hotelId: h.id,
+ placeId: h.id,
+ name: h.name || 'Stay',
+ address: h.address || null,
+ rating: h.rating != null ? h.rating : null,
+ distance: h.distance != null ? h.distance : null,
+ mapsUrl: h.mapsUrl || null,
+ website: h.website || null,
+ priceLevel: h.priceLevel != null ? h.priceLevel : null,
+ tags: Array.isArray(h.tags) ? h.tags.slice(0, 4) : [],
+ },
+ });
+ if (cards.length >= MAX_HOTEL_CARDS) break;
+ }
+ return cards;
+}
+
+function resolveHotelIds(hotelIds, data) {
+ const hotels = Array.isArray(data?.hotels) ? data.hotels : [];
+ const allowed = new Set(hotels.map((h) => String(h.id)));
+ const fromModel = (Array.isArray(hotelIds) ? hotelIds : [])
+ .map((id) => String(id || '').trim())
+ .filter((id) => allowed.has(id));
+ if (fromModel.length) return fromModel;
+ return hotels.map((h) => String(h.id)).filter(Boolean).slice(0, MAX_HOTEL_CARDS);
+}
+
+module.exports = {
+ buildAccommodationCards,
+ resolveHotelIds,
+ MAX_HOTEL_CARDS,
+};
diff --git a/src/utils/badgeOcr.js b/src/utils/badgeOcr.js
new file mode 100644
index 0000000..eb36692
--- /dev/null
+++ b/src/utils/badgeOcr.js
@@ -0,0 +1,42 @@
+const { RekognitionClient, DetectTextCommand } = require('@aws-sdk/client-rekognition');
+
+let client;
+
+function getClient() {
+ if (client) return client;
+ const region = process.env.AWS_DEFAULT_REGION || 'ap-south-1';
+ const accessKeyId = process.env.AWS_ACCESS_KEY_ID;
+ const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY;
+ client = new RekognitionClient({
+ region,
+ credentials:
+ accessKeyId && secretAccessKey ? { accessKeyId, secretAccessKey } : undefined,
+ });
+ return client;
+}
+
+/**
+ * OCR printed text from a badge photo. Returns candidate name-like lines.
+ * @param {Buffer} buffer
+ * @returns {Promise}
+ */
+async function detectBadgeTextLines(buffer) {
+ if (!buffer || !buffer.length) return [];
+ try {
+ const max = 4.5 * 1024 * 1024;
+ const bytes = buffer.length > max ? buffer.subarray(0, max) : buffer;
+ const out = await getClient().send(
+ new DetectTextCommand({ Image: { Bytes: bytes } }),
+ );
+ const dets = Array.isArray(out.TextDetections) ? out.TextDetections : [];
+ return dets
+ .filter((d) => String(d.Type || '') === 'LINE' && String(d.DetectedText || '').trim())
+ .map((d) => String(d.DetectedText).trim())
+ .filter((t) => t.length >= 3 && /[a-zA-Z]/.test(t));
+ } catch (err) {
+ console.warn('[badgeOcr]', err.message);
+ return [];
+ }
+}
+
+module.exports = { detectBadgeTextLines };
diff --git a/src/utils/badgeQr.js b/src/utils/badgeQr.js
new file mode 100644
index 0000000..afa1f5c
--- /dev/null
+++ b/src/utils/badgeQr.js
@@ -0,0 +1,47 @@
+const jsQR = require('jsqr');
+
+function parseParticipantQrPayload(raw) {
+ const text = String(raw || '').trim();
+ const m = text.match(/^eventbuzz:participant:([a-f0-9]{24})$/i);
+ if (m) return m[1];
+ if (/^[a-f0-9]{24}$/i.test(text)) return text;
+ return '';
+}
+
+/**
+ * Decode a check-in QR from a badge photo buffer.
+ * @param {Buffer} buffer
+ * @returns {Promise} participant id or empty
+ */
+async function decodeParticipantIdFromImage(buffer) {
+ if (!buffer || !buffer.length) return '';
+ let Jimp;
+ try {
+ Jimp = require('jimp');
+ } catch (err) {
+ console.warn('[badgeQr] jimp not installed', err.message);
+ return '';
+ }
+ try {
+ const image = await Jimp.read(buffer);
+ const scan = (img) => {
+ const { data, width, height } = img.bitmap;
+ return jsQR(new Uint8ClampedArray(data), width, height);
+ };
+ let code = scan(image);
+ if (!code || !code.data) {
+ image.invert();
+ code = scan(image);
+ }
+ if (!code || !code.data) return '';
+ return parseParticipantQrPayload(code.data);
+ } catch (err) {
+ console.warn('[badgeQr] decode failed', err.message);
+ return '';
+ }
+}
+
+module.exports = {
+ parseParticipantQrPayload,
+ decodeParticipantIdFromImage,
+};
diff --git a/src/utils/bedrock.js b/src/utils/bedrock.js
new file mode 100644
index 0000000..be41f75
--- /dev/null
+++ b/src/utils/bedrock.js
@@ -0,0 +1,159 @@
+const {
+ BedrockRuntimeClient,
+ ConverseCommand,
+} = require('@aws-sdk/client-bedrock-runtime');
+
+const DEFAULT_ROUTER_MODEL_ID = 'amazon.nova-micro-v1:0';
+const DEFAULT_CALL2_MODEL_ID = 'anthropic.claude-3-haiku-20240307-v1:0';
+
+function getRegion() {
+ return (
+ process.env.BEDROCK_REGION ||
+ process.env.AWS_DEFAULT_REGION ||
+ process.env.AWS_REGION ||
+ 'ap-south-1'
+ );
+}
+
+function getRouterModelId() {
+ return process.env.BEDROCK_ROUTER_MODEL_ID || DEFAULT_ROUTER_MODEL_ID;
+}
+
+function getCall2ModelId() {
+ return process.env.BEDROCK_CALL2_MODEL_ID || DEFAULT_CALL2_MODEL_ID;
+}
+
+/**
+ * Nova models in APAC often need inference profile IDs (apac.*) instead of foundation model IDs.
+ * Anthropic / other foundation model IDs are left unchanged.
+ */
+function resolveModelId(modelId, region = getRegion()) {
+ const id = String(modelId || '').trim();
+ if (!id || id.startsWith('arn:aws:bedrock:')) return id;
+ if (/^(us|eu|apac)\./.test(id)) return id;
+ if (id.startsWith('anthropic.')) return id;
+
+ const mode = String(process.env.BEDROCK_USE_INFERENCE_PROFILES ?? 'auto').toLowerCase();
+ if (mode === 'false' || mode === '0' || mode === 'no') return id;
+
+ if (id.startsWith('amazon.nova-') && String(region).startsWith('ap-')) {
+ return `apac.${id}`;
+ }
+ return id;
+}
+
+function createClient(region = getRegion()) {
+ const config = { region };
+ if (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) {
+ config.credentials = {
+ accessKeyId: process.env.AWS_ACCESS_KEY_ID,
+ secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
+ ...(process.env.AWS_SESSION_TOKEN
+ ? { sessionToken: process.env.AWS_SESSION_TOKEN }
+ : {}),
+ };
+ }
+ return new BedrockRuntimeClient(config);
+}
+
+function responseText(response) {
+ return (
+ response.output?.message?.content
+ ?.map((block) => block.text)
+ .filter(Boolean)
+ .join('\n') || ''
+ );
+}
+
+function isLlmLogEnabled() {
+ const v = String(process.env.BEDROCK_LOG_LLM || '').toLowerCase();
+ return v === 'true' || v === '1' || v === 'yes';
+}
+
+function messageTexts(messages) {
+ if (!Array.isArray(messages)) return [];
+ return messages.map((m) => ({
+ role: m?.role,
+ text: (Array.isArray(m?.content) ? m.content : [])
+ .map((b) => b?.text || '')
+ .filter(Boolean)
+ .join('\n'),
+ }));
+}
+
+/**
+ * @param {import('@aws-sdk/client-bedrock-runtime').BedrockRuntimeClient} client
+ * @param {string} modelId
+ * @param {{ system?: string, messages: object[], inferenceConfig?: object, logLabel?: string }} opts
+ */
+async function converse(client, modelId, opts) {
+ const { system, messages, inferenceConfig, logLabel } = opts;
+ const region = getRegion();
+ const resolvedModelId = resolveModelId(modelId, region);
+ const systemBlocks = system ? [{ text: system }] : undefined;
+ const mergedInferenceConfig = {
+ maxTokens: 400,
+ temperature: 0,
+ topP: 0.1,
+ ...inferenceConfig,
+ };
+ const label = logLabel || 'converse';
+ const shouldLog = Boolean(logLabel) || isLlmLogEnabled();
+ const startedAt = Date.now();
+
+ if (shouldLog) {
+ console.log(`[bedrock][${label}][request]`, {
+ region,
+ modelId: resolvedModelId,
+ requestedModelId: modelId !== resolvedModelId ? modelId : undefined,
+ inferenceConfig: mergedInferenceConfig,
+ system,
+ messages: messageTexts(messages),
+ });
+ }
+
+ try {
+ const response = await client.send(
+ new ConverseCommand({
+ modelId: resolvedModelId,
+ ...(systemBlocks ? { system: systemBlocks } : {}),
+ messages,
+ inferenceConfig: mergedInferenceConfig,
+ }),
+ );
+
+ if (shouldLog) {
+ console.log(`[bedrock][${label}][response]`, {
+ modelId: resolvedModelId,
+ latencyMs: Date.now() - startedAt,
+ usage: response.usage || null,
+ stopReason: response.stopReason || null,
+ text: responseText(response),
+ });
+ }
+
+ return response;
+ } catch (err) {
+ if (shouldLog) {
+ console.error(`[bedrock][${label}][error]`, {
+ modelId: resolvedModelId,
+ latencyMs: Date.now() - startedAt,
+ name: err?.name || err?.Code || 'Error',
+ message: err?.message || String(err),
+ });
+ }
+ throw err;
+ }
+}
+
+module.exports = {
+ DEFAULT_ROUTER_MODEL_ID,
+ DEFAULT_CALL2_MODEL_ID,
+ getRegion,
+ getRouterModelId,
+ getCall2ModelId,
+ resolveModelId,
+ createClient,
+ converse,
+ responseText,
+};
diff --git a/src/utils/call2Context.js b/src/utils/call2Context.js
new file mode 100644
index 0000000..5b8bca7
--- /dev/null
+++ b/src/utils/call2Context.js
@@ -0,0 +1,664 @@
+/**
+ * Compact Call 2 LLM context: drop nulls/empties/duplicates.
+ * Full session objects stay in memory for card building.
+ */
+
+const DETAIL_INTENTS = new Set(['agenda_session_details', 'agenda_download_materials']);
+
+function isEmpty(value) {
+ if (value == null) return true;
+ if (typeof value === 'string') return !value.trim();
+ if (Array.isArray(value)) return value.length === 0;
+ if (typeof value === 'object') return Object.keys(value).length === 0;
+ return false;
+}
+
+function omitEmpty(obj) {
+ if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return obj;
+ const out = {};
+ for (const [k, v] of Object.entries(obj)) {
+ if (isEmpty(v)) continue;
+ out[k] = v;
+ }
+ return out;
+}
+
+function compactEntities(entities) {
+ return omitEmpty(entities && typeof entities === 'object' ? entities : {});
+}
+
+function slimTrack(track) {
+ if (!track) return undefined;
+ if (typeof track === 'string') return track;
+ const name = String(track.name || '').trim();
+ const hall = String(track.hall || '').trim();
+ if (!name && !hall) return undefined;
+ return hall ? `${name} · ${hall}` : name || hall;
+}
+
+function slimSpeakers(speakers) {
+ if (!Array.isArray(speakers) || !speakers.length) return undefined;
+ const names = speakers
+ .map((sp) => (typeof sp === 'string' ? sp : sp?.name))
+ .map((n) => String(n || '').trim())
+ .filter(Boolean)
+ .slice(0, 4);
+ return names.length ? names : undefined;
+}
+
+function formatTopicDuration(duration) {
+ const raw = String(duration || '').trim();
+ if (!raw) return '';
+ const match = raw.match(/^(\d+(?:\s*\+\s*\d+)?)\s*(m|min|mins|minutes)?$/i);
+ if (match) return `${match[1].replace(/\s+/g, '')}m`;
+ return raw;
+}
+
+function slimTopics(topics) {
+ if (!Array.isArray(topics) || !topics.length) return undefined;
+ const lines = topics
+ .map((t) => {
+ if (!t) return null;
+ if (typeof t === 'string') return t.trim() || null;
+ const title = String(t.title || '').trim();
+ if (!title) return null;
+ const dur = formatTopicDuration(t.duration);
+ const role = String(t.role || '').trim();
+ let line = title;
+ if (dur) line += ` (${dur})`;
+ if (role) line += ` · ${role}`;
+ return line;
+ })
+ .filter(Boolean)
+ .slice(0, 8);
+ return lines.length ? lines : undefined;
+}
+
+function slimResources(resources) {
+ if (!Array.isArray(resources) || !resources.length) return undefined;
+ const rows = resources
+ .map((r) => {
+ const url = String(r?.url || '').trim();
+ if (!url) return null;
+ return omitEmpty({
+ name: String(r.name || 'Resource').trim().slice(0, 80),
+ url: url.slice(0, 300),
+ });
+ })
+ .filter(Boolean)
+ .slice(0, 6);
+ return rows.length ? rows : undefined;
+}
+
+function slimSessionForLlm(session, intent) {
+ if (!session) return null;
+ const detail = DETAIL_INTENTS.has(intent);
+
+ return omitEmpty({
+ id: session.id,
+ title: session.title,
+ when: session.timeRangeLocal || null,
+ track: slimTrack(session.track),
+ type: detail ? session.type || null : null,
+ speakers: slimSpeakers(session.speakers),
+ // Always include topics for detail intents so Call 2 can match by topic
+ topics: detail ? slimTopics(session.topics) : undefined,
+ resources: intent === 'agenda_download_materials' ? slimResources(session.resources) : undefined,
+ });
+}
+
+function slimConflicts(conflicts) {
+ if (!Array.isArray(conflicts) || !conflicts.length) return undefined;
+ return conflicts.slice(0, 12).map((c) =>
+ omitEmpty({
+ a: omitEmpty({
+ id: c?.a?.id,
+ title: c?.a?.title,
+ when: c?.a?.timeRangeLocal || null,
+ }),
+ b: omitEmpty({
+ id: c?.b?.id,
+ title: c?.b?.title,
+ when: c?.b?.timeRangeLocal || null,
+ }),
+ }),
+ );
+}
+
+function slimTracksList(tracks) {
+ if (!Array.isArray(tracks) || !tracks.length) return undefined;
+ return tracks
+ .map((t) => slimTrack(t))
+ .filter(Boolean)
+ .slice(0, 30);
+}
+
+/**
+ * Build compact DATA for the LLM. Full `data` is still used for cards.
+ * @param {object} data full fetchAgendaData result
+ * @param {string} intent
+ */
+function slimDataForLlm(data, intent) {
+ const src = data && typeof data === 'object' ? data : {};
+ const sessions = Array.isArray(src.sessions)
+ ? src.sessions.map((s) => slimSessionForLlm(s, intent)).filter(Boolean)
+ : [];
+
+ // Always keep sessions (even []) so the model cannot miss an empty personal schedule.
+ return {
+ ...omitEmpty({
+ now: src.nowLocal || src.now || null,
+ tz: src.timeZone || 'Asia/Kolkata',
+ note: src.note || null,
+ day: src.query?.day || null,
+ trackQuery: src.trackQuery || null,
+ speakerQuery: src.speakerQuery || null,
+ query: src.query ? omitEmpty(src.query) : null,
+ tracks: slimTracksList(src.tracks),
+ conflicts: slimConflicts(src.conflicts),
+ }),
+ sessions,
+ };
+}
+
+/**
+ * Compact venue DATA for Call 2.
+ * @param {object} data fetchVenueData result
+ */
+function slimVenueDataForLlm(data) {
+ const src = data && typeof data === 'object' ? data : {};
+ const faqs = Array.isArray(src.faqs)
+ ? src.faqs
+ .map((f) => omitEmpty({ q: f.q || f.question || null, a: f.a || f.answer || null }))
+ .filter((f) => f.q && f.a)
+ .slice(0, 10)
+ : [];
+ const places = Array.isArray(src.places)
+ ? src.places
+ .map((p) =>
+ omitEmpty({
+ id: p.id,
+ name: p.name || null,
+ address: p.address || null,
+ rating: p.rating != null ? p.rating : null,
+ distance: p.distance != null ? p.distance : null,
+ }),
+ )
+ .filter((p) => p.id && p.name)
+ .slice(0, 8)
+ : [];
+
+ // Keep instructional notes out of the payload when allowGeneralTravel is set —
+ // models sometimes echo DATA.note as the user-facing message — unless
+ // keepNoteWithGeneralTravel (transport allow-list must still be visible).
+ const includeNote =
+ src.note && (!src.allowGeneralTravel || src.keepNoteWithGeneralTravel);
+
+ const mapMedia = src.mapMedia && src.mapMedia.url
+ ? omitEmpty({
+ name: src.mapMedia.name || 'Venue floor plan',
+ mediaType: src.mapMedia.mediaType || null,
+ available: true,
+ })
+ : null;
+
+ const transportModes =
+ src.transportModes && src.transportModes.enforce
+ ? {
+ enforce: true,
+ allowed: Array.isArray(src.transportModes.allowed)
+ ? src.transportModes.allowed.slice(0, 8)
+ : [],
+ notAllowed: Array.isArray(src.transportModes.notAllowed)
+ ? src.transportModes.notAllowed.slice(0, 8)
+ : [],
+ }
+ : null;
+
+ return omitEmpty({
+ eventName: src.eventName || null,
+ location: src.location || null,
+ origin: src.origin || null,
+ allowGeneralTravel: src.allowGeneralTravel ? true : null,
+ note: includeNote ? src.note : null,
+ fallback: src.fallback || null,
+ faqs: faqs.length ? faqs : undefined,
+ transportModes,
+ mapMedia,
+ halls: Array.isArray(src.halls) && src.halls.length ? src.halls.slice(0, 20) : undefined,
+ rooms: Array.isArray(src.rooms) && src.rooms.length ? src.rooms.slice(0, 25) : undefined,
+ places: places.length ? places : undefined,
+ });
+}
+
+/**
+ * Compact ticket DATA for Call 2.
+ * @param {object} data fetchTicketData result
+ */
+function slimTicketDataForLlm(data) {
+ const src = data && typeof data === 'object' ? data : {};
+ const receipt = src.receipt && typeof src.receipt === 'object'
+ ? omitEmpty({
+ kind: src.receipt.kind || null,
+ status: src.receipt.status || null,
+ ticketName: src.receipt.ticketName || null,
+ amount: src.receipt.amountLabel || null,
+ orderId: src.receipt.orderId || null,
+ paymentId: src.receipt.paymentId || null,
+ date: src.receipt.date || null,
+ purchaserEmail: src.receipt.purchaserEmail || null,
+ attendees: Array.isArray(src.receipt.attendees)
+ ? src.receipt.attendees.slice(0, 8)
+ : undefined,
+ quote: src.receipt.quote || null,
+ })
+ : null;
+
+ const faqs = Array.isArray(src.faqs)
+ ? src.faqs
+ .map((f) =>
+ omitEmpty({
+ q: f.q || null,
+ a: f.a || null,
+ }),
+ )
+ .filter((f) => f.a)
+ .slice(0, 6)
+ : [];
+ const contact = src.contact && typeof src.contact === 'object'
+ ? omitEmpty({
+ email: src.contact.email || null,
+ phone: src.contact.phone || null,
+ detail: src.contact.detail || null,
+ text: src.contact.text || null,
+ })
+ : null;
+ const tickets = Array.isArray(src.tickets)
+ ? src.tickets
+ .map((t) =>
+ omitEmpty({
+ name: t.name || null,
+ price: t.price || null,
+ }),
+ )
+ .filter((t) => t.name)
+ .slice(0, 8)
+ : [];
+ const hideEventName = src.intent === 'ticket_how_to_register';
+
+ return omitEmpty({
+ eventName: hideEventName ? null : src.eventName || null,
+ found: src.found === false ? false : src.found ? true : null,
+ policy: src.policy || null,
+ note: hideEventName ? null : src.note || null,
+ registrationLinks: Array.isArray(src.registrationLinks)
+ ? src.registrationLinks
+ .map((l) =>
+ omitEmpty({
+ label: l.label || null,
+ url: l.url || null,
+ }),
+ )
+ .filter((l) => l.url)
+ .slice(0, 8)
+ : undefined,
+ contact: contact && (contact.email || contact.phone || contact.text) ? contact : undefined,
+ faqs: faqs.length ? faqs : undefined,
+ tickets: tickets.length ? tickets : undefined,
+ emailSent: src.emailSent === true ? true : src.emailSent === false ? false : null,
+ emailTo: src.emailTo || null,
+ emailError: src.emailError || null,
+ attendee: src.attendee
+ ? omitEmpty({
+ name: src.attendee.name || null,
+ email: src.attendee.email || null,
+ })
+ : null,
+ membership: src.membership
+ ? omitEmpty({
+ status: src.membership.statusLabel || src.membership.status || null,
+ checkedIn: src.membership.checkedIn ? true : null,
+ registrationDate: src.membership.registrationDate || null,
+ })
+ : null,
+ ticket: src.ticket
+ ? omitEmpty({
+ name: src.ticket.name || null,
+ price: src.ticket.basePriceMajor != null
+ ? `${src.ticket.currency || 'INR'} ${src.ticket.basePriceMajor}`
+ : null,
+ })
+ : null,
+ payment: src.payment
+ ? omitEmpty({
+ status: src.payment.status || null,
+ amount: src.payment.amount != null
+ ? `${src.payment.currency || 'INR'} ${src.payment.amount}`
+ : null,
+ })
+ : null,
+ qr: src.qr
+ ? omitEmpty({
+ available: Boolean(src.qr.available),
+ })
+ : null,
+ receipt: receipt && Object.keys(receipt).length ? receipt : null,
+ });
+}
+
+/**
+ * Compact speaker DATA for Call 2.
+ * @param {object} data fetchSpeakerData result
+ */
+function slimSpeakerDataForLlm(data) {
+ const src = data && typeof data === 'object' ? data : {};
+ const isList = Boolean(src.isSpeakerList);
+ const speakerCap = isList ? 24 : 4;
+ const bioCap = isList ? 160 : 800;
+ const speakers = Array.isArray(src.speakers)
+ ? src.speakers
+ .map((s) =>
+ omitEmpty({
+ id: s.id,
+ name: s.name || null,
+ title: s.title || null,
+ organizationName: s.organizationName || null,
+ speakerRole: s.speakerRole || null,
+ bio: s.bio ? String(s.bio).slice(0, bioCap) : null,
+ expertise:
+ !isList && Array.isArray(s.expertise) && s.expertise.length
+ ? s.expertise.slice(0, 8)
+ : undefined,
+ socialLinks:
+ !isList && s.socialLinks
+ ? omitEmpty({
+ linkedin: s.socialLinks.linkedin || null,
+ twitter: s.socialLinks.twitter || null,
+ website: s.socialLinks.website || null,
+ })
+ : undefined,
+ isPublic: s.isPublic === false ? false : true,
+ profileVisibility: s.profileVisibility || null,
+ }),
+ )
+ .filter((s) => s.id && s.name)
+ .slice(0, speakerCap)
+ : [];
+
+ const candidates = Array.isArray(src.candidates)
+ ? src.candidates
+ .map((c) =>
+ omitEmpty({
+ id: c.id,
+ name: c.name || null,
+ title: c.title || null,
+ organizationName: c.organizationName || null,
+ }),
+ )
+ .filter((c) => c.name)
+ .slice(0, 8)
+ : [];
+
+ const sessionSource =
+ src.intent === 'speaker_meet_and_greet' && Array.isArray(src.meetSessions) && src.meetSessions.length
+ ? src.meetSessions
+ : src.sessions;
+
+ const sessions = Array.isArray(sessionSource)
+ ? sessionSource
+ .map((s) =>
+ omitEmpty({
+ id: s.id,
+ title: s.title || null,
+ when: s.timeRangeLocal || null,
+ track: s.track?.name || (typeof s.track === 'string' ? s.track : null),
+ type: s.type || null,
+ }),
+ )
+ .filter((s) => s.id && s.title)
+ .slice(0, 12)
+ : [];
+
+ return omitEmpty({
+ speakerQuery: src.speakerQuery || null,
+ isSpeakerList: isList ? true : null,
+ needsClarification: src.needsClarification ? true : null,
+ note: src.note || null,
+ speakers: speakers.length ? speakers : undefined,
+ candidates: candidates.length ? candidates : undefined,
+ sessions: !isList && sessions.length ? sessions : undefined,
+ meetSessionCount:
+ !isList && Array.isArray(src.meetSessions) && src.meetSessions.length
+ ? src.meetSessions.length
+ : undefined,
+ });
+}
+
+/**
+ * Compact exhibitor DATA for Call 2.
+ * @param {object} data fetchExhibitorData result
+ */
+function slimExhibitorDataForLlm(data) {
+ const src = data && typeof data === 'object' ? data : {};
+ const isList = Boolean(src.isExhibitorList);
+ const exhibitorCap = isList ? 24 : 4;
+ const descCap = isList ? 120 : 500;
+ const exhibitors = Array.isArray(src.exhibitors)
+ ? src.exhibitors
+ .map((e) =>
+ omitEmpty({
+ id: e.id,
+ name: e.name || null,
+ boothNumber: e.boothNumber || null,
+ hall: e.hall || null,
+ category: e.category || null,
+ shortDescription: e.shortDescription
+ ? String(e.shortDescription).slice(0, descCap)
+ : null,
+ description:
+ !isList && e.description ? String(e.description).slice(0, descCap) : null,
+ products:
+ Array.isArray(e.products) && e.products.length
+ ? e.products.slice(0, isList ? 4 : 10)
+ : undefined,
+ website: e.website || null,
+ socialLinks:
+ !isList && e.socialLinks
+ ? omitEmpty({
+ linkedin: e.socialLinks.linkedin || null,
+ twitter: e.socialLinks.twitter || null,
+ instagram: e.socialLinks.instagram || null,
+ website: e.socialLinks.website || null,
+ })
+ : undefined,
+ isFeatured: e.isFeatured ? true : null,
+ }),
+ )
+ .filter((e) => e.id && e.name)
+ .slice(0, exhibitorCap)
+ : [];
+
+ const candidates = Array.isArray(src.candidates)
+ ? src.candidates
+ .map((c) =>
+ omitEmpty({
+ id: c.id,
+ name: c.name || null,
+ boothNumber: c.boothNumber || null,
+ hall: c.hall || null,
+ category: c.category || null,
+ }),
+ )
+ .filter((c) => c.name)
+ .slice(0, 8)
+ : [];
+
+ const faqs = Array.isArray(src.faqs)
+ ? src.faqs
+ .map((f) =>
+ omitEmpty({
+ q: f.q || null,
+ a: f.a || null,
+ }),
+ )
+ .filter((f) => f.a)
+ .slice(0, 8)
+ : [];
+
+ return omitEmpty({
+ companyQuery: src.companyQuery || null,
+ categoryQuery: src.categoryQuery || null,
+ isExhibitorList: isList ? true : null,
+ needsClarification: src.needsClarification ? true : null,
+ refuseBooking: src.refuseBooking ? true : null,
+ note: src.note || null,
+ exhibitors: exhibitors.length ? exhibitors : undefined,
+ candidates: candidates.length ? candidates : undefined,
+ faqs: faqs.length ? faqs : undefined,
+ });
+}
+
+/**
+ * Compact tech DATA for Call 2.
+ * @param {object} data fetchTechData result
+ */
+function slimTechDataForLlm(data) {
+ const src = data && typeof data === 'object' ? data : {};
+ const faqs = Array.isArray(src.faqs)
+ ? src.faqs
+ .map((f) =>
+ omitEmpty({
+ q: f.q || null,
+ a: f.a || null,
+ }),
+ )
+ .filter((f) => f.a)
+ .slice(0, 10)
+ : [];
+ const staticTips = Array.isArray(src.staticTips)
+ ? src.staticTips.map((t) => String(t || '').trim()).filter(Boolean).slice(0, 8)
+ : [];
+
+ return omitEmpty({
+ category: src.category || null,
+ note: src.note || null,
+ faqs: faqs.length ? faqs : undefined,
+ staticTips: staticTips.length ? staticTips : undefined,
+ });
+}
+
+/**
+ * Compact system DATA for Call 2.
+ * Visitor-facing Bot FAQ contacts (DATA.contact / DATA.faqs) are intentionally included.
+ * Internal org mailbox used only for escalation email is not exposed separately.
+ * @param {object} data fetchSystemData result
+ */
+function slimSystemDataForLlm(data) {
+ const src = data && typeof data === 'object' ? data : {};
+ const faqs = Array.isArray(src.faqs)
+ ? src.faqs
+ .map((f) =>
+ omitEmpty({
+ q: f.q || null,
+ a: f.a || null,
+ }),
+ )
+ .filter((f) => f.a)
+ .slice(0, 4)
+ : [];
+ const contact = src.contact && typeof src.contact === 'object'
+ ? omitEmpty({
+ email: src.contact.email || null,
+ phone: src.contact.phone || null,
+ detail: src.contact.detail || null,
+ text: src.contact.text || null,
+ })
+ : null;
+ const hideEventName = src.intent === 'out_of_scope' || src.intent === 'chitchat_greeting';
+ return omitEmpty({
+ eventName: hideEventName ? null : src.eventName || null,
+ emailSent: src.emailSent === true ? true : null,
+ rateLimited: src.rateLimited === true ? true : null,
+ emailError: src.emailError || null,
+ note: src.note || null,
+ contact: contact && (contact.email || contact.phone || contact.text) ? contact : undefined,
+ faqs: faqs.length ? faqs : undefined,
+ });
+}
+
+/**
+ * Compact local places / accommodation DATA for Call 2.
+ * @param {object} data fetchLocalData result
+ */
+function slimLocalDataForLlm(data) {
+ const src = data && typeof data === 'object' ? data : {};
+ const places = Array.isArray(src.places)
+ ? src.places
+ .map((p) =>
+ omitEmpty({
+ id: p.id,
+ name: p.name || null,
+ address: p.address || null,
+ rating: p.rating != null ? p.rating : null,
+ distance: p.distance != null ? p.distance : null,
+ category: p.category || null,
+ tags: Array.isArray(p.tags) && p.tags.length ? p.tags.slice(0, 6) : undefined,
+ }),
+ )
+ .filter((p) => p.id && p.name)
+ .slice(0, 8)
+ : [];
+ const hotels = Array.isArray(src.hotels)
+ ? src.hotels
+ .map((h) =>
+ omitEmpty({
+ id: h.id,
+ name: h.name || null,
+ address: h.address || null,
+ rating: h.rating != null ? h.rating : null,
+ distance: h.distance != null ? h.distance : null,
+ priceLevel: h.priceLevel != null ? h.priceLevel : null,
+ }),
+ )
+ .filter((h) => h.id && h.name)
+ .slice(0, 8)
+ : [];
+
+ const faqs = Array.isArray(src.faqs)
+ ? src.faqs
+ .map((f) =>
+ omitEmpty({
+ q: f.q || null,
+ a: f.a || null,
+ }),
+ )
+ .filter((f) => f.a)
+ .slice(0, 2)
+ : [];
+
+ return omitEmpty({
+ eventName: src.eventName || null,
+ location: src.location || null,
+ hostManagedAccommodation: src.hostManagedAccommodation ? true : null,
+ faqs: faqs.length ? faqs : undefined,
+ requestedCategory: src.requestedCategory?.label || null,
+ categoryNoMatch: src.categoryNoMatch || null,
+ note: src.note || null,
+ places: places.length ? places : undefined,
+ hotels: hotels.length ? hotels : undefined,
+ });
+}
+
+module.exports = {
+ compactEntities,
+ slimDataForLlm,
+ slimVenueDataForLlm,
+ slimTicketDataForLlm,
+ slimSpeakerDataForLlm,
+ slimExhibitorDataForLlm,
+ slimTechDataForLlm,
+ slimSystemDataForLlm,
+ slimLocalDataForLlm,
+ slimSessionForLlm,
+ omitEmpty,
+};
diff --git a/src/utils/eventTime.js b/src/utils/eventTime.js
new file mode 100644
index 0000000..b8c7f70
--- /dev/null
+++ b/src/utils/eventTime.js
@@ -0,0 +1,321 @@
+const DEFAULT_TZ = process.env.EVENT_TIME_ZONE || 'Asia/Kolkata';
+
+function getTimeZone() {
+ return process.env.EVENT_TIME_ZONE || DEFAULT_TZ;
+}
+
+function toDate(value) {
+ if (!value) return null;
+ const d = value instanceof Date ? value : new Date(value);
+ return Number.isNaN(d.getTime()) ? null : d;
+}
+
+function zonedParts(value, timeZone = getTimeZone()) {
+ const d = toDate(value);
+ if (!d) return null;
+ const parts = new Intl.DateTimeFormat('en-US', {
+ timeZone,
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ hourCycle: 'h23',
+ }).formatToParts(d);
+ const get = (type) => Number(parts.find((p) => p.type === type)?.value || 0);
+ return {
+ year: get('year'),
+ month: get('month'),
+ day: get('day'),
+ hour: get('hour'),
+ minute: get('minute'),
+ second: get('second'),
+ };
+}
+
+/** Convert a wall-clock datetime in `timeZone` to a UTC Date. */
+function zonedWallTimeToUtc(year, month, day, hour = 0, minute = 0, second = 0, timeZone = getTimeZone()) {
+ let utc = Date.UTC(year, month - 1, day, hour, minute, second);
+ for (let i = 0; i < 4; i += 1) {
+ const parts = zonedParts(new Date(utc), timeZone);
+ if (!parts) break;
+ const asIfUtc = Date.UTC(
+ parts.year,
+ parts.month - 1,
+ parts.day,
+ parts.hour,
+ parts.minute,
+ parts.second,
+ );
+ const desired = Date.UTC(year, month - 1, day, hour, minute, second);
+ const delta = desired - asIfUtc;
+ if (delta === 0) break;
+ utc += delta;
+ }
+ return new Date(utc);
+}
+
+/** Inclusive calendar-day bounds in event TZ → UTC Date range [start, end). */
+function dayBoundsUtc(year, month, day, timeZone = getTimeZone()) {
+ const start = zonedWallTimeToUtc(year, month, day, 0, 0, 0, timeZone);
+ const next = new Date(start.getTime() + 36 * 3600 * 1000);
+ const nextParts = zonedParts(next, timeZone);
+ const end = zonedWallTimeToUtc(nextParts.year, nextParts.month, nextParts.day, 0, 0, 0, timeZone);
+ return { start, end };
+}
+
+function addCalendarDays(year, month, day, deltaDays) {
+ const utc = Date.UTC(year, month - 1, day) + deltaDays * 86400000;
+ const d = new Date(utc);
+ return { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate() };
+}
+
+function formatDayLabel(year, month, day) {
+ const utc = zonedWallTimeToUtc(year, month, day, 12, 0, 0);
+ return formatInTz(utc, {
+ weekday: 'short',
+ day: 'numeric',
+ month: 'short',
+ year: 'numeric',
+ });
+}
+
+const MONTHS = {
+ jan: 1,
+ january: 1,
+ feb: 2,
+ february: 2,
+ mar: 3,
+ march: 3,
+ apr: 4,
+ april: 4,
+ may: 5,
+ jun: 6,
+ june: 6,
+ jul: 7,
+ july: 7,
+ aug: 8,
+ august: 8,
+ sep: 9,
+ sept: 9,
+ september: 9,
+ oct: 10,
+ october: 10,
+ nov: 11,
+ november: 11,
+ dec: 12,
+ december: 12,
+};
+
+/**
+ * Parse attendee time_reference into event-TZ day bounds.
+ * @param {string} raw
+ * @param {Date} [now]
+ * @param {{ minDate?: Date|null, maxDate?: Date|null }} [hint] session range to resolve bare day numbers
+ * @returns {{ start: Date, end: Date, label: string, key: string }|null}
+ */
+function parseTimeReference(raw, now = new Date(), hint = {}) {
+ const text = String(raw || '').trim().toLowerCase();
+ if (!text) return null;
+ const tz = getTimeZone();
+ const nowParts = zonedParts(now, tz);
+ if (!nowParts) return null;
+
+ const build = (year, month, day) => {
+ const { start, end } = dayBoundsUtc(year, month, day, tz);
+ return {
+ start,
+ end,
+ label: formatDayLabel(year, month, day),
+ key: `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`,
+ };
+ };
+
+ if (/\btoday\b/.test(text)) return build(nowParts.year, nowParts.month, nowParts.day);
+ if (/\btomorrow\b/.test(text)) {
+ const n = addCalendarDays(nowParts.year, nowParts.month, nowParts.day, 1);
+ return build(n.year, n.month, n.day);
+ }
+ if (/\byesterday\b/.test(text)) {
+ const n = addCalendarDays(nowParts.year, nowParts.month, nowParts.day, -1);
+ return build(n.year, n.month, n.day);
+ }
+
+ // ISO / yyyy-mm-dd
+ let m = text.match(/\b(20\d{2})-(\d{1,2})-(\d{1,2})\b/);
+ if (m) return build(Number(m[1]), Number(m[2]), Number(m[3]));
+
+ // 8 August / August 8 / Aug 8th / 8th Aug 2026
+ m = text.match(/\b(\d{1,2})(?:st|nd|rd|th)?\s+(jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|aug(?:ust)?|sep(?:t(?:ember)?)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)(?:\s*,?\s*(20\d{2}))?\b/);
+ if (m) {
+ const day = Number(m[1]);
+ const month = MONTHS[m[2]];
+ const year = m[3] ? Number(m[3]) : pickYearForMonthDay(nowParts, month, day, hint);
+ return build(year, month, day);
+ }
+ m = text.match(/\b(jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|aug(?:ust)?|sep(?:t(?:ember)?)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)\s+(\d{1,2})(?:st|nd|rd|th)?(?:\s*,?\s*(20\d{2}))?\b/);
+ if (m) {
+ const month = MONTHS[m[1]];
+ const day = Number(m[2]);
+ const year = m[3] ? Number(m[3]) : pickYearForMonthDay(nowParts, month, day, hint);
+ return build(year, month, day);
+ }
+
+ // Bare day: "8th", "on the 8", "8"
+ m = text.match(/\b(?:on\s+)?(?:the\s+)?(\d{1,2})(?:st|nd|rd|th)?\b/);
+ if (m) {
+ const day = Number(m[1]);
+ if (day >= 1 && day <= 31) {
+ const resolved = resolveBareDay(day, nowParts, hint, tz);
+ if (resolved) return build(resolved.year, resolved.month, resolved.day);
+ }
+ }
+
+ return null;
+}
+
+function pickYearForMonthDay(nowParts, month, day, hint) {
+ const candidates = [nowParts.year - 1, nowParts.year, nowParts.year + 1];
+ if (hint?.minDate || hint?.maxDate) {
+ const minP = hint.minDate ? zonedParts(hint.minDate) : null;
+ const maxP = hint.maxDate ? zonedParts(hint.maxDate) : null;
+ if (minP) candidates.push(minP.year);
+ if (maxP) candidates.push(maxP.year);
+ }
+ const uniq = [...new Set(candidates)].sort((a, b) => a - b);
+ // Prefer year where month/day is on/after "now", else nearest in hint range, else current year
+ const scored = uniq.map((year) => {
+ const { start } = dayBoundsUtc(year, month, day);
+ return { year, start: start.getTime() };
+ });
+ const upcoming = scored.filter((c) => c.start >= Date.UTC(nowParts.year, nowParts.month - 1, nowParts.day) - 14 * 86400000);
+ if (upcoming.length) {
+ upcoming.sort((a, b) => a.start - b.start);
+ return upcoming[0].year;
+ }
+ return nowParts.year;
+}
+
+function resolveBareDay(day, nowParts, hint, timeZone) {
+ const candidates = [];
+ const pushRange = (from, to) => {
+ let cur = { year: from.year, month: from.month, day: from.day };
+ const endKey = `${to.year}-${to.month}-${to.day}`;
+ for (let i = 0; i < 400; i += 1) {
+ if (cur.day === day) candidates.push({ ...cur });
+ const key = `${cur.year}-${cur.month}-${cur.day}`;
+ if (key === endKey) break;
+ cur = addCalendarDays(cur.year, cur.month, cur.day, 1);
+ }
+ };
+
+ if (hint?.minDate && hint?.maxDate) {
+ const minP = zonedParts(hint.minDate, timeZone);
+ const maxP = zonedParts(hint.maxDate, timeZone);
+ if (minP && maxP) pushRange(minP, maxP);
+ }
+
+ if (!candidates.length) {
+ // current + next month
+ for (const monthDelta of [0, 1, 2]) {
+ let year = nowParts.year;
+ let month = nowParts.month + monthDelta;
+ while (month > 12) {
+ month -= 12;
+ year += 1;
+ }
+ const dim = new Date(Date.UTC(year, month, 0)).getUTCDate();
+ if (day <= dim) candidates.push({ year, month, day });
+ }
+ }
+
+ if (!candidates.length) return null;
+
+ const nowStart = dayBoundsUtc(nowParts.year, nowParts.month, nowParts.day, timeZone).start.getTime();
+ const upcoming = candidates
+ .map((c) => ({ ...c, t: dayBoundsUtc(c.year, c.month, c.day, timeZone).start.getTime() }))
+ .filter((c) => c.t >= nowStart - 6 * 3600000)
+ .sort((a, b) => a.t - b.t);
+ if (upcoming.length) return upcoming[0];
+ candidates.sort((a, b) => {
+ const ta = dayBoundsUtc(a.year, a.month, a.day, timeZone).start.getTime();
+ const tb = dayBoundsUtc(b.year, b.month, b.day, timeZone).start.getTime();
+ return Math.abs(ta - nowStart) - Math.abs(tb - nowStart);
+ });
+ return candidates[0];
+}
+
+function formatInTz(value, options) {
+ const d = toDate(value);
+ if (!d) return null;
+ try {
+ return new Intl.DateTimeFormat('en-IN', {
+ timeZone: getTimeZone(),
+ ...options,
+ }).format(d);
+ } catch {
+ return null;
+ }
+}
+
+/** e.g. Fri, 7 Aug 2025, 10:15 am */
+function formatDateTimeLocal(value) {
+ return formatInTz(value, {
+ weekday: 'short',
+ day: 'numeric',
+ month: 'short',
+ year: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+ hour12: true,
+ });
+}
+
+/** e.g. 10:30 am */
+function formatTimeLocal(value) {
+ return formatInTz(value, {
+ hour: 'numeric',
+ minute: '2-digit',
+ hour12: true,
+ });
+}
+
+function sameCalendarDay(a, b) {
+ const da = toDate(a);
+ const db = toDate(b);
+ if (!da || !db) return false;
+ const opts = { timeZone: getTimeZone(), year: 'numeric', month: '2-digit', day: '2-digit' };
+ try {
+ return (
+ new Intl.DateTimeFormat('en-CA', opts).format(da) ===
+ new Intl.DateTimeFormat('en-CA', opts).format(db)
+ );
+ } catch {
+ return false;
+ }
+}
+
+/** e.g. Fri, 7 Aug 2025, 10:15 am – 10:30 am IST */
+function formatTimeRangeLocal(start, end) {
+ const startLabel = formatDateTimeLocal(start);
+ if (!startLabel) return null;
+ const endLabel = end
+ ? sameCalendarDay(start, end)
+ ? formatTimeLocal(end)
+ : formatDateTimeLocal(end)
+ : null;
+ const tz = getTimeZone() === 'Asia/Kolkata' ? 'IST' : getTimeZone();
+ return endLabel ? `${startLabel} – ${endLabel} ${tz}` : `${startLabel} ${tz}`;
+}
+
+module.exports = {
+ getTimeZone,
+ formatDateTimeLocal,
+ formatTimeLocal,
+ formatTimeRangeLocal,
+ sameCalendarDay,
+ zonedParts,
+ dayBoundsUtc,
+ parseTimeReference,
+};
diff --git a/src/utils/exhibitorCards.js b/src/utils/exhibitorCards.js
new file mode 100644
index 0000000..5c8cdfa
--- /dev/null
+++ b/src/utils/exhibitorCards.js
@@ -0,0 +1,59 @@
+const MAX_EXHIBITOR_CARDS = 8;
+
+function buildExhibitorCards(exhibitorIds, data) {
+ const exhibitors = Array.isArray(data?.exhibitors) ? data.exhibitors : [];
+ const byId = new Map(exhibitors.map((e) => [String(e.id), e]));
+ let ids = (Array.isArray(exhibitorIds) ? exhibitorIds : [])
+ .map((id) => String(id || '').trim())
+ .filter(Boolean);
+
+ if (!ids.length && exhibitors.length === 1) {
+ ids = [String(exhibitors[0].id)];
+ }
+
+ const seen = new Set();
+ const cards = [];
+ for (const id of ids) {
+ if (seen.has(id)) continue;
+ seen.add(id);
+ const e = byId.get(id);
+ if (!e) continue;
+ const social = e.socialLinks || {};
+ cards.push({
+ type: 'exhibitor',
+ data: {
+ exhibitorId: e.id,
+ name: e.name || 'Exhibitor',
+ boothNumber: e.boothNumber || null,
+ hall: e.hall || null,
+ category: e.category || null,
+ shortDescription: e.shortDescription || null,
+ logo: e.logo || null,
+ products: Array.isArray(e.products) ? e.products.slice(0, 8) : [],
+ website: e.website || social.website || null,
+ linkedin: social.linkedin || null,
+ twitter: social.twitter || null,
+ instagram: social.instagram || null,
+ isFeatured: Boolean(e.isFeatured),
+ },
+ });
+ if (cards.length >= MAX_EXHIBITOR_CARDS) break;
+ }
+ return cards;
+}
+
+function resolveExhibitorIdsFromModel(exhibitorIds, data) {
+ const exhibitors = Array.isArray(data?.exhibitors) ? data.exhibitors : [];
+ const allowed = new Set(exhibitors.map((e) => String(e.id)));
+ const fromModel = (Array.isArray(exhibitorIds) ? exhibitorIds : [])
+ .map((id) => String(id || '').trim())
+ .filter((id) => allowed.has(id));
+ if (fromModel.length) return fromModel;
+ return exhibitors.map((e) => String(e.id)).filter(Boolean).slice(0, MAX_EXHIBITOR_CARDS);
+}
+
+module.exports = {
+ buildExhibitorCards,
+ resolveExhibitorIdsFromModel,
+ MAX_EXHIBITOR_CARDS,
+};
diff --git a/src/utils/faqAnswer.js b/src/utils/faqAnswer.js
new file mode 100644
index 0000000..41a2c23
--- /dev/null
+++ b/src/utils/faqAnswer.js
@@ -0,0 +1,108 @@
+/**
+ * Build the visitor-facing answer text for a Bot FAQ row.
+ * @param {object|null|undefined} row
+ */
+function faqAnswerText(row) {
+ if (!row || typeof row !== 'object') return '';
+ const format = String(row.answerFormat || '').trim();
+ const email = String(row.contactEmail || '').trim();
+ const phone = String(row.contactPhone || '').trim();
+ const detail = String(row.answerDetail || '').trim();
+
+ // contact_details — also honor email/phone when answerFormat was not persisted
+ if (format === 'contact_details' || email || phone) {
+ const bits = [];
+ if (email) bits.push(`Email: ${email}`);
+ if (phone) bits.push(`Phone: ${phone}`);
+ if (detail) bits.push(detail);
+ return bits.join('\n');
+ }
+
+ if (format === 'media_upload') {
+ const url = String(row.mediaUrl || '').trim();
+ if (!url) return '';
+ const name = String(row.mediaName || '').trim();
+ const note = String(row.answer || row.answerDetail || '').trim();
+ if (note) return note;
+ return name
+ ? `A floor plan / map is available: ${name}. You can view or download it below.`
+ : 'A floor plan / map is available. You can view or download it below.';
+ }
+
+ if (format === 'mcq_multi') {
+ const opts = Array.isArray(row.selectedOptions)
+ ? row.selectedOptions.map((x) => String(x || '').trim()).filter(Boolean)
+ : [];
+ if (opts.length) return opts.join(', ');
+ }
+
+ const a = String(row.answer || '').trim();
+ const d = String(row.answerDetail || '').trim();
+ if (a && d) return `${a}\n${d}`;
+ return a || d;
+}
+
+/**
+ * Extract floor-plan / map media from the venue_map FAQ row.
+ * @param {object|null|undefined} bot
+ */
+function mapMediaFromBot(bot) {
+ const rows = Array.isArray(bot?.faqs) ? bot.faqs : [];
+ const row = rows.find(
+ (f) =>
+ (String(f?.key || '') === 'venue_map' || String(f?.category || '') === 'venue_map') &&
+ String(f?.mediaUrl || '').trim(),
+ );
+ if (!row) return null;
+ const url = String(row.mediaUrl || '').trim();
+ if (!url) return null;
+ const name = String(row.mediaName || '').trim() || 'Venue floor plan';
+ let mediaType = String(row.mediaType || '').trim().toLowerCase();
+ if (mediaType !== 'image' && mediaType !== 'pdf') {
+ const hay = `${url} ${name}`.toLowerCase();
+ if (/\.pdf(\?|#|$)/i.test(hay)) mediaType = 'pdf';
+ else if (/\.(png|jpe?g|gif|webp|svg)(\?|#|$)/i.test(hay)) mediaType = 'image';
+ else mediaType = 'file';
+ }
+ return { url, name, mediaType };
+}
+
+function buildMapFileCard(mapMedia) {
+ if (!mapMedia?.url) return null;
+ return {
+ type: 'file',
+ data: {
+ title: mapMedia.name || 'Venue floor plan',
+ url: mapMedia.url,
+ mediaType: mapMedia.mediaType || 'file',
+ },
+ };
+}
+
+/**
+ * Organizer / emergency contact from Bot FAQ (email + phone for visitors).
+ * @param {object|null|undefined} bot
+ * @returns {{ email: string|null, phone: string|null, detail: string|null, text: string }|null}
+ */
+function emergencyContactFromBot(bot) {
+ const rows = Array.isArray(bot?.faqs) ? bot.faqs : [];
+ const row = rows.find((f) => {
+ const key = String(f?.key || '').trim();
+ const category = String(f?.category || '').trim();
+ return key === 'emergency_contact' || category === 'emergency_contact';
+ });
+ if (!row) return null;
+ const email = String(row.contactEmail || '').trim() || null;
+ const phone = String(row.contactPhone || '').trim() || null;
+ const detail = String(row.answerDetail || '').trim() || null;
+ const text = faqAnswerText(row);
+ if (!text && !email && !phone) return null;
+ return { email, phone, detail, text: text || '' };
+}
+
+module.exports = {
+ faqAnswerText,
+ mapMediaFromBot,
+ buildMapFileCard,
+ emergencyContactFromBot,
+};
diff --git a/src/utils/greeting.js b/src/utils/greeting.js
new file mode 100644
index 0000000..657fd6d
--- /dev/null
+++ b/src/utils/greeting.js
@@ -0,0 +1,12 @@
+function interpolateGreeting(template, { profileName, eventName } = {}) {
+ const text = String(template || '').trim();
+ if (!text) {
+ const name = eventName || 'the event';
+ return `Hi! Welcome to ${name}. How can I help?`;
+ }
+ return text
+ .replace(/\{profileName\}/gi, profileName || 'there')
+ .replace(/\{eventName\}/gi, eventName || 'the event');
+}
+
+module.exports = { interpolateGreeting };
diff --git a/src/utils/livekitConcierge.js b/src/utils/livekitConcierge.js
new file mode 100644
index 0000000..094a9b4
--- /dev/null
+++ b/src/utils/livekitConcierge.js
@@ -0,0 +1,79 @@
+const AGENT_NAME = process.env.LIVEKIT_AGENT_NAME || 'eventbuzz-concierge';
+
+function getLivekitConfig() {
+ const url = String(process.env.LIVEKIT_URL || '').trim();
+ const apiKey = String(process.env.LIVEKIT_API_KEY || '').trim();
+ const apiSecret = String(process.env.LIVEKIT_API_SECRET || '').trim();
+ return { url, apiKey, apiSecret, configured: Boolean(url && apiKey && apiSecret) };
+}
+
+function voiceRoomName(botId, sessionId) {
+ return `concierge-${String(botId)}-${String(sessionId)}`;
+}
+
+function parseVoiceRoomName(roomName) {
+ const raw = String(roomName || '');
+ const match = raw.match(/^concierge-([a-f0-9]{24})-([a-f0-9]+)$/i);
+ if (!match) return null;
+ return { botId: match[1], sessionId: match[2] };
+}
+
+async function createVisitorVoiceToken({ botId, sessionId, identity }) {
+ const { url, apiKey, apiSecret, configured } = getLivekitConfig();
+ if (!configured) {
+ throw new Error('LiveKit is not configured');
+ }
+
+ const { AccessToken, RoomServiceClient, AgentDispatchClient } = require('livekit-server-sdk');
+ const roomName = voiceRoomName(botId, sessionId);
+ const metadata = JSON.stringify({ botId: String(botId), sessionId: String(sessionId) });
+
+ const httpUrl = url.replace(/^wss:/, 'https:').replace(/^ws:/, 'http:');
+ const rooms = new RoomServiceClient(httpUrl, apiKey, apiSecret);
+ try {
+ await rooms.createRoom({
+ name: roomName,
+ emptyTimeout: 15 * 60,
+ maxParticipants: 8,
+ metadata,
+ });
+ } catch (err) {
+ const msg = String(err?.message || err);
+ if (!/already exists|conflict/i.test(msg)) throw err;
+ }
+
+ try {
+ const dispatch = new AgentDispatchClient(httpUrl, apiKey, apiSecret);
+ await dispatch.createDispatch(roomName, AGENT_NAME, { metadata });
+ } catch (err) {
+ console.warn('[livekit] agent dispatch failed (worker may still auto-join):', err?.message || err);
+ }
+
+ const at = new AccessToken(apiKey, apiSecret, {
+ identity: identity || `visitor-${sessionId}`,
+ ttl: '15m',
+ metadata,
+ });
+ at.addGrant({
+ roomJoin: true,
+ room: roomName,
+ canPublish: true,
+ canSubscribe: true,
+ canPublishData: true,
+ });
+
+ return {
+ url,
+ token: await at.toJwt(),
+ roomName,
+ sessionId: String(sessionId),
+ };
+}
+
+module.exports = {
+ AGENT_NAME,
+ getLivekitConfig,
+ voiceRoomName,
+ parseVoiceRoomName,
+ createVisitorVoiceToken,
+};
diff --git a/src/utils/mail.js b/src/utils/mail.js
new file mode 100644
index 0000000..22adf92
--- /dev/null
+++ b/src/utils/mail.js
@@ -0,0 +1,292 @@
+const nodemailer = require('nodemailer');
+
+let defaultTransporter;
+let transporterCacheKey = null;
+
+function resolveSmtp() {
+ const host = process.env.MAIL_HOST || process.env.SMTP_HOST;
+ const port = parseInt(process.env.MAIL_PORT || process.env.SMTP_PORT || '587', 10);
+ const enc = String(process.env.MAIL_ENCRYPTION || '').toLowerCase();
+ const secure = enc === 'ssl' || port === 465 || process.env.SMTP_SECURE === 'true';
+ const user = process.env.MAIL_USERNAME || process.env.SMTP_USER;
+ const pass = process.env.MAIL_PASSWORD || process.env.SMTP_PASS;
+ return { host, port, secure, user, pass };
+}
+
+function getMailFrom() {
+ const addr =
+ process.env.MAIL_FROM_ADDRESS ||
+ process.env.SMTP_FROM ||
+ process.env.MAIL_USERNAME ||
+ process.env.SMTP_USER ||
+ 'noreply@eventbuzz.local';
+ let name = process.env.MAIL_FROM_NAME;
+ if (name) {
+ name = String(name).replace(/^["']|["']$/g, '').trim();
+ const safe = name.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
+ return `"${safe}" <${addr}>`;
+ }
+ return addr;
+}
+
+/** When set, SMTP delivers to this address only (testing). Content still uses the intended recipient. */
+function getTestRedirectTo() {
+ const to = String(process.env.MAIL_TEST_REDIRECT_TO || '').trim().toLowerCase();
+ return to.includes('@') ? to : '';
+}
+
+/**
+ * @param {string} intendedTo
+ * @returns {{ to: string, intendedTo: string, redirected: boolean }}
+ */
+function resolveDeliveryTo(intendedTo) {
+ const intended = String(intendedTo || '').trim().toLowerCase();
+ const redirect = getTestRedirectTo();
+ if (redirect && intended && redirect !== intended) {
+ return { to: redirect, intendedTo: intended, redirected: true };
+ }
+ return { to: intended, intendedTo: intended, redirected: false };
+}
+
+function getDefaultTransporter() {
+ const { host, port, secure, user, pass } = resolveSmtp();
+ const cacheKey = `${host || ''}|${port}|${secure}|${user || ''}|${pass ? '1' : '0'}`;
+ if (defaultTransporter !== undefined && transporterCacheKey === cacheKey) {
+ return defaultTransporter;
+ }
+ transporterCacheKey = cacheKey;
+ if (!host) {
+ defaultTransporter = null;
+ return null;
+ }
+ defaultTransporter = nodemailer.createTransport({
+ host,
+ port,
+ secure,
+ auth: user ? { user, pass: pass || '' } : undefined,
+ });
+ return defaultTransporter;
+}
+
+function escapeHtml(s) {
+ return String(s || '')
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+}
+
+async function sendConciergeOtpEmail({ to, otp, eventName }) {
+ const delivery = resolveDeliveryTo(to);
+ const from = getMailFrom();
+ const name = eventName || 'your event';
+ const subject = `Your verification code for ${name}`;
+ const redirectNote = delivery.redirected
+ ? `\n[TEST] Intended recipient: ${delivery.intendedTo}\n`
+ : '';
+ const text = `Your one-time code is: ${otp}
+
+It expires in 10 minutes.
+${redirectNote}
+— EventBuzz Concierge`;
+ const html = `
Your verification code for ${escapeHtml(name)} is: