elevenlabs-webhook-nodejs 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +27 -0
- package/.dockerignore +6 -0
- package/.env.example +43 -0
- package/BEFORE-PRODUCTION.md +32 -0
- package/Dockerfile +23 -0
- package/SYSTEM_OVERVIEW.md +942 -0
- package/SYSTEM_STATUS.md +332 -0
- package/backup_script/main.py +146 -0
- package/baileys/.dockerignore +7 -0
- package/baileys/Dockerfile +13 -0
- package/baileys/README.md +412 -0
- package/baileys/index.js +499 -0
- package/baileys/package-lock.json +2532 -0
- package/baileys/package.json +25 -0
- package/baileys/server.js +96 -0
- package/baileys/src/config.js +55 -0
- package/baileys/src/middleware/api-key.js +16 -0
- package/baileys/src/routes/accounts.js +51 -0
- package/baileys/src/routes/chats.js +103 -0
- package/baileys/src/routes/webhooks.js +34 -0
- package/baileys/src/services/account-store.js +259 -0
- package/baileys/src/services/webhook-dispatcher.js +161 -0
- package/baileys/src/services/worker-manager.js +597 -0
- package/baileys/src/utils/jid.js +79 -0
- package/baileys/src/utils/logger.js +16 -0
- package/baileys/src/utils/use-mongodb-auth-state.js +122 -0
- package/baileys/worker.js +721 -0
- package/dist/app.d.ts +1 -0
- package/dist/app.js +84 -0
- package/dist/app.js.map +1 -0
- package/dist/config/agentPrompts.json +19 -0
- package/dist/config/database.d.ts +1 -0
- package/dist/config/database.js +26 -0
- package/dist/config/database.js.map +1 -0
- package/dist/config/env.d.ts +41 -0
- package/dist/config/env.js +81 -0
- package/dist/config/env.js.map +1 -0
- package/dist/config/index.d.ts +3 -0
- package/dist/config/index.js +73 -0
- package/dist/config/index.js.map +1 -0
- package/dist/controllers/webhook.controller.d.ts +10 -0
- package/dist/controllers/webhook.controller.js +128 -0
- package/dist/controllers/webhook.controller.js.map +1 -0
- package/dist/errors/index.d.ts +4 -0
- package/dist/errors/index.js +13 -0
- package/dist/errors/index.js.map +1 -0
- package/dist/hooks/webhookValidator.d.ts +2 -0
- package/dist/hooks/webhookValidator.js +47 -0
- package/dist/hooks/webhookValidator.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +55 -0
- package/dist/index.js.map +1 -0
- package/dist/migrations/001_session-architecture.d.ts +3 -0
- package/dist/migrations/001_session-architecture.js +119 -0
- package/dist/migrations/001_session-architecture.js.map +1 -0
- package/dist/migrations/002_agent-ref.d.ts +3 -0
- package/dist/migrations/002_agent-ref.js +55 -0
- package/dist/migrations/002_agent-ref.js.map +1 -0
- package/dist/migrations/003_shift-schedule.d.ts +3 -0
- package/dist/migrations/003_shift-schedule.js +48 -0
- package/dist/migrations/003_shift-schedule.js.map +1 -0
- package/dist/migrations/004_invert-shift-to-clinic-hours.d.ts +3 -0
- package/dist/migrations/004_invert-shift-to-clinic-hours.js +27 -0
- package/dist/migrations/004_invert-shift-to-clinic-hours.js.map +1 -0
- package/dist/migrations/005_composite-baileys-chat-ids.d.ts +3 -0
- package/dist/migrations/005_composite-baileys-chat-ids.js +47 -0
- package/dist/migrations/005_composite-baileys-chat-ids.js.map +1 -0
- package/dist/migrations/migration.model.d.ts +18 -0
- package/dist/migrations/migration.model.js +45 -0
- package/dist/migrations/migration.model.js.map +1 -0
- package/dist/migrations/migration.routes.d.ts +2 -0
- package/dist/migrations/migration.routes.js +37 -0
- package/dist/migrations/migration.routes.js.map +1 -0
- package/dist/migrations/migration.runner.d.ts +19 -0
- package/dist/migrations/migration.runner.js +74 -0
- package/dist/migrations/migration.runner.js.map +1 -0
- package/dist/models/ConversationClaim.d.ts +13 -0
- package/dist/models/ConversationClaim.js +44 -0
- package/dist/models/ConversationClaim.js.map +1 -0
- package/dist/models/ConversationEvaluation.d.ts +23 -0
- package/dist/models/ConversationEvaluation.js +92 -0
- package/dist/models/ConversationEvaluation.js.map +1 -0
- package/dist/models/Summary.d.ts +37 -0
- package/dist/models/Summary.js +78 -0
- package/dist/models/Summary.js.map +1 -0
- package/dist/models/Transcription.d.ts +34 -0
- package/dist/models/Transcription.js +71 -0
- package/dist/models/Transcription.js.map +1 -0
- package/dist/models/WhatsAppRecipient.d.ts +23 -0
- package/dist/models/WhatsAppRecipient.js +53 -0
- package/dist/models/WhatsAppRecipient.js.map +1 -0
- package/dist/modules/admin/admin.routes.d.ts +2 -0
- package/dist/modules/admin/admin.routes.js +1966 -0
- package/dist/modules/admin/admin.routes.js.map +1 -0
- package/dist/modules/admin/elevenlabs-test-chat.routes.d.ts +2 -0
- package/dist/modules/admin/elevenlabs-test-chat.routes.js +158 -0
- package/dist/modules/admin/elevenlabs-test-chat.routes.js.map +1 -0
- package/dist/modules/admin/whatsapp-test-chat.routes.d.ts +2 -0
- package/dist/modules/admin/whatsapp-test-chat.routes.js +204 -0
- package/dist/modules/admin/whatsapp-test-chat.routes.js.map +1 -0
- package/dist/modules/agents/agent.model.d.ts +38 -0
- package/dist/modules/agents/agent.model.js +92 -0
- package/dist/modules/agents/agent.model.js.map +1 -0
- package/dist/modules/agents/agent.routes.d.ts +2 -0
- package/dist/modules/agents/agent.routes.js +61 -0
- package/dist/modules/agents/agent.routes.js.map +1 -0
- package/dist/modules/appointment-validation/appointment-validation.model.d.ts +76 -0
- package/dist/modules/appointment-validation/appointment-validation.model.js +118 -0
- package/dist/modules/appointment-validation/appointment-validation.model.js.map +1 -0
- package/dist/modules/appointment-validation/appointment-validation.routes.d.ts +2 -0
- package/dist/modules/appointment-validation/appointment-validation.routes.js +202 -0
- package/dist/modules/appointment-validation/appointment-validation.routes.js.map +1 -0
- package/dist/modules/appointment-validation/appointment-validation.service.d.ts +53 -0
- package/dist/modules/appointment-validation/appointment-validation.service.js +827 -0
- package/dist/modules/appointment-validation/appointment-validation.service.js.map +1 -0
- package/dist/modules/auth/auth.model.d.ts +17 -0
- package/dist/modules/auth/auth.model.js +64 -0
- package/dist/modules/auth/auth.model.js.map +1 -0
- package/dist/modules/auth/auth.routes.d.ts +2 -0
- package/dist/modules/auth/auth.routes.js +202 -0
- package/dist/modules/auth/auth.routes.js.map +1 -0
- package/dist/modules/auth/auth.service.d.ts +28 -0
- package/dist/modules/auth/auth.service.js +183 -0
- package/dist/modules/auth/auth.service.js.map +1 -0
- package/dist/modules/auth/refresh-token.model.d.ts +13 -0
- package/dist/modules/auth/refresh-token.model.js +52 -0
- package/dist/modules/auth/refresh-token.model.js.map +1 -0
- package/dist/modules/billing/billing-alert.model.d.ts +16 -0
- package/dist/modules/billing/billing-alert.model.js +47 -0
- package/dist/modules/billing/billing-alert.model.js.map +1 -0
- package/dist/modules/billing/billing-period-snapshot.model.d.ts +35 -0
- package/dist/modules/billing/billing-period-snapshot.model.js +68 -0
- package/dist/modules/billing/billing-period-snapshot.model.js.map +1 -0
- package/dist/modules/billing/billing.model.d.ts +18 -0
- package/dist/modules/billing/billing.model.js +62 -0
- package/dist/modules/billing/billing.model.js.map +1 -0
- package/dist/modules/billing/billing.routes.d.ts +2 -0
- package/dist/modules/billing/billing.routes.js +63 -0
- package/dist/modules/billing/billing.routes.js.map +1 -0
- package/dist/modules/billing/billing.service.d.ts +69 -0
- package/dist/modules/billing/billing.service.js +498 -0
- package/dist/modules/billing/billing.service.js.map +1 -0
- package/dist/modules/billing/payment.model.d.ts +24 -0
- package/dist/modules/billing/payment.model.js +57 -0
- package/dist/modules/billing/payment.model.js.map +1 -0
- package/dist/modules/calls/call.model.d.ts +41 -0
- package/dist/modules/calls/call.model.js +97 -0
- package/dist/modules/calls/call.model.js.map +1 -0
- package/dist/modules/calls/call.routes.d.ts +2 -0
- package/dist/modules/calls/call.routes.js +103 -0
- package/dist/modules/calls/call.routes.js.map +1 -0
- package/dist/modules/campaigns/campaign.model.d.ts +45 -0
- package/dist/modules/campaigns/campaign.model.js +98 -0
- package/dist/modules/campaigns/campaign.model.js.map +1 -0
- package/dist/modules/campaigns/campaign.routes.d.ts +2 -0
- package/dist/modules/campaigns/campaign.routes.js +323 -0
- package/dist/modules/campaigns/campaign.routes.js.map +1 -0
- package/dist/modules/campaigns/campaign.service.d.ts +11 -0
- package/dist/modules/campaigns/campaign.service.js +86 -0
- package/dist/modules/campaigns/campaign.service.js.map +1 -0
- package/dist/modules/google-calendar/google-calendar.routes.d.ts +2 -0
- package/dist/modules/google-calendar/google-calendar.routes.js +32 -0
- package/dist/modules/google-calendar/google-calendar.routes.js.map +1 -0
- package/dist/modules/inbound-call/inbound-call-config.model.d.ts +20 -0
- package/dist/modules/inbound-call/inbound-call-config.model.js +68 -0
- package/dist/modules/inbound-call/inbound-call-config.model.js.map +1 -0
- package/dist/modules/inbound-call/inbound-call.routes.d.ts +2 -0
- package/dist/modules/inbound-call/inbound-call.routes.js +243 -0
- package/dist/modules/inbound-call/inbound-call.routes.js.map +1 -0
- package/dist/modules/leads/lead.model.d.ts +24 -0
- package/dist/modules/leads/lead.model.js +54 -0
- package/dist/modules/leads/lead.model.js.map +1 -0
- package/dist/modules/leads/lead.routes.d.ts +2 -0
- package/dist/modules/leads/lead.routes.js +201 -0
- package/dist/modules/leads/lead.routes.js.map +1 -0
- package/dist/modules/plans/plan.model.d.ts +25 -0
- package/dist/modules/plans/plan.model.js +59 -0
- package/dist/modules/plans/plan.model.js.map +1 -0
- package/dist/modules/surveys/survey.model.d.ts +30 -0
- package/dist/modules/surveys/survey.model.js +75 -0
- package/dist/modules/surveys/survey.model.js.map +1 -0
- package/dist/modules/surveys/survey.routes.d.ts +2 -0
- package/dist/modules/surveys/survey.routes.js +153 -0
- package/dist/modules/surveys/survey.routes.js.map +1 -0
- package/dist/modules/tenants/tenant.model.d.ts +86 -0
- package/dist/modules/tenants/tenant.model.js +127 -0
- package/dist/modules/tenants/tenant.model.js.map +1 -0
- package/dist/modules/tenants/tenant.routes.d.ts +2 -0
- package/dist/modules/tenants/tenant.routes.js +65 -0
- package/dist/modules/tenants/tenant.routes.js.map +1 -0
- package/dist/modules/users/user.routes.d.ts +2 -0
- package/dist/modules/users/user.routes.js +106 -0
- package/dist/modules/users/user.routes.js.map +1 -0
- package/dist/modules/webhooks/elevenlabs-tool.routes.d.ts +20 -0
- package/dist/modules/webhooks/elevenlabs-tool.routes.js +85 -0
- package/dist/modules/webhooks/elevenlabs-tool.routes.js.map +1 -0
- package/dist/modules/webhooks/elevenlabs-tool.service.d.ts +11 -0
- package/dist/modules/webhooks/elevenlabs-tool.service.js +360 -0
- package/dist/modules/webhooks/elevenlabs-tool.service.js.map +1 -0
- package/dist/modules/webhooks/elevenlabs.routes.d.ts +2 -0
- package/dist/modules/webhooks/elevenlabs.routes.js +34 -0
- package/dist/modules/webhooks/elevenlabs.routes.js.map +1 -0
- package/dist/modules/webhooks/elevenlabs.service.d.ts +6 -0
- package/dist/modules/webhooks/elevenlabs.service.js +512 -0
- package/dist/modules/webhooks/elevenlabs.service.js.map +1 -0
- package/dist/modules/webhooks/unipile.routes.d.ts +2 -0
- package/dist/modules/webhooks/unipile.routes.js +780 -0
- package/dist/modules/webhooks/unipile.routes.js.map +1 -0
- package/dist/modules/whatsapp/appointment.model.d.ts +27 -0
- package/dist/modules/whatsapp/appointment.model.js +58 -0
- package/dist/modules/whatsapp/appointment.model.js.map +1 -0
- package/dist/modules/whatsapp/operator-request.model.d.ts +29 -0
- package/dist/modules/whatsapp/operator-request.model.js +65 -0
- package/dist/modules/whatsapp/operator-request.model.js.map +1 -0
- package/dist/modules/whatsapp/stt-usage.model.d.ts +16 -0
- package/dist/modules/whatsapp/stt-usage.model.js +53 -0
- package/dist/modules/whatsapp/stt-usage.model.js.map +1 -0
- package/dist/modules/whatsapp/whatsapp-chat.model.d.ts +23 -0
- package/dist/modules/whatsapp/whatsapp-chat.model.js +55 -0
- package/dist/modules/whatsapp/whatsapp-chat.model.js.map +1 -0
- package/dist/modules/whatsapp/whatsapp-contact-profile.model.d.ts +23 -0
- package/dist/modules/whatsapp/whatsapp-contact-profile.model.js +54 -0
- package/dist/modules/whatsapp/whatsapp-contact-profile.model.js.map +1 -0
- package/dist/modules/whatsapp/whatsapp-message.model.d.ts +30 -0
- package/dist/modules/whatsapp/whatsapp-message.model.js +52 -0
- package/dist/modules/whatsapp/whatsapp-message.model.js.map +1 -0
- package/dist/modules/whatsapp/whatsapp-session.model.d.ts +33 -0
- package/dist/modules/whatsapp/whatsapp-session.model.js +65 -0
- package/dist/modules/whatsapp/whatsapp-session.model.js.map +1 -0
- package/dist/modules/whatsapp/whatsapp.routes.d.ts +2 -0
- package/dist/modules/whatsapp/whatsapp.routes.js +1237 -0
- package/dist/modules/whatsapp/whatsapp.routes.js.map +1 -0
- package/dist/plugins/cors.d.ts +3 -0
- package/dist/plugins/cors.js +11 -0
- package/dist/plugins/cors.js.map +1 -0
- package/dist/plugins/jwt.d.ts +3 -0
- package/dist/plugins/jwt.js +22 -0
- package/dist/plugins/jwt.js.map +1 -0
- package/dist/plugins/rawBody.d.ts +3 -0
- package/dist/plugins/rawBody.js +16 -0
- package/dist/plugins/rawBody.js.map +1 -0
- package/dist/plugins/rbac.d.ts +5 -0
- package/dist/plugins/rbac.js +29 -0
- package/dist/plugins/rbac.js.map +1 -0
- package/dist/routes/admin.routes.d.ts +2 -0
- package/dist/routes/admin.routes.js +169 -0
- package/dist/routes/admin.routes.js.map +1 -0
- package/dist/routes/health.routes.d.ts +2 -0
- package/dist/routes/health.routes.js +16 -0
- package/dist/routes/health.routes.js.map +1 -0
- package/dist/routes/webhook.routes.d.ts +2 -0
- package/dist/routes/webhook.routes.js +17 -0
- package/dist/routes/webhook.routes.js.map +1 -0
- package/dist/services/ai/base.ai.d.ts +19 -0
- package/dist/services/ai/base.ai.js +120 -0
- package/dist/services/ai/base.ai.js.map +1 -0
- package/dist/services/ai/gemini.service.d.ts +11 -0
- package/dist/services/ai/gemini.service.js +43 -0
- package/dist/services/ai/gemini.service.js.map +1 -0
- package/dist/services/ai/index.d.ts +2 -0
- package/dist/services/ai/index.js +26 -0
- package/dist/services/ai/index.js.map +1 -0
- package/dist/services/ai/openai.service.d.ts +11 -0
- package/dist/services/ai/openai.service.js +50 -0
- package/dist/services/ai/openai.service.js.map +1 -0
- package/dist/services/elevenlabs.service.d.ts +52 -0
- package/dist/services/elevenlabs.service.js +447 -0
- package/dist/services/elevenlabs.service.js.map +1 -0
- package/dist/services/google-calendar.service.d.ts +60 -0
- package/dist/services/google-calendar.service.js +494 -0
- package/dist/services/google-calendar.service.js.map +1 -0
- package/dist/services/inbound-call-schedule.service.d.ts +11 -0
- package/dist/services/inbound-call-schedule.service.js +162 -0
- package/dist/services/inbound-call-schedule.service.js.map +1 -0
- package/dist/services/netgsm.service.d.ts +41 -0
- package/dist/services/netgsm.service.js +89 -0
- package/dist/services/netgsm.service.js.map +1 -0
- package/dist/services/unipile.service.d.ts +41 -0
- package/dist/services/unipile.service.js +149 -0
- package/dist/services/unipile.service.js.map +1 -0
- package/dist/services/whatsapp-agent.service.d.ts +139 -0
- package/dist/services/whatsapp-agent.service.js +2055 -0
- package/dist/services/whatsapp-agent.service.js.map +1 -0
- package/dist/services/whatsapp.service.d.ts +26 -0
- package/dist/services/whatsapp.service.js +206 -0
- package/dist/services/whatsapp.service.js.map +1 -0
- package/dist/templates/index.d.ts +39 -0
- package/dist/templates/index.js +35 -0
- package/dist/templates/index.js.map +1 -0
- package/dist/templates/receptionist.d.ts +2 -0
- package/dist/templates/receptionist.js +39 -0
- package/dist/templates/receptionist.js.map +1 -0
- package/dist/templates/survey.d.ts +2 -0
- package/dist/templates/survey.js +41 -0
- package/dist/templates/survey.js.map +1 -0
- package/dist/types/index.d.ts +173 -0
- package/dist/types/index.js +3 -0
- package/dist/types/index.js.map +1 -0
- package/dist/utils/logger.d.ts +3 -0
- package/dist/utils/logger.js +105 -0
- package/dist/utils/logger.js.map +1 -0
- package/docker-compose.nestjs.yml +89 -0
- package/docker-compose.yml +78 -0
- package/docs/AI_AGENT_ENHANCEMENT_PLAN.md +164 -0
- package/docs/API.md +1193 -0
- package/docs/API_ENDPOINTS.md +344 -0
- package/docs/ARCHITECTURE.md +305 -0
- package/docs/AUTH_API.md +252 -0
- package/docs/BILLING_SMS_ALERTS.md +94 -0
- package/docs/CHAT_ASSIGNMENT_SYSTEM.md +118 -0
- package/docs/CLIENT_TOOLS_AND_FEATURES.md +337 -0
- package/docs/ELEVENLABS_WEBHOOK_TOOLS.md +644 -0
- package/docs/FRONTEND_CHECKLIST.md +227 -0
- package/docs/IMPLEMENTATION_STATUS.md +470 -0
- package/docs/MIGRATION_GUIDE.md +96 -0
- package/docs/MISSINGS_REPORT.md +507 -0
- package/docs/NESTJS_MIGRATION_REFERENCE.md +5136 -0
- package/docs/PROJECT_DESCRIPTION.md +1038 -0
- package/docs/SCALING.md +148 -0
- package/docs/SESSION_SUMMARY_2026_03_17.md +135 -0
- package/docs/WHATSAPP_AGENT.md +1086 -0
- package/docs/architecture/00-SYSTEM-OVERVIEW.md +318 -0
- package/docs/architecture/01-DATABASE-SCHEMA.md +2564 -0
- package/docs/architecture/02-AUTHENTICATION.md +1040 -0
- package/docs/architecture/03-MULTI-CLINIC.md +742 -0
- package/docs/architecture/04-WHATSAPP-AGENT.md +608 -0
- package/docs/architecture/05-OPERATOR-WORKFLOW.md +444 -0
- package/docs/architecture/06-BAILEYS-MICROSERVICE.md +616 -0
- package/docs/architecture/07-APPOINTMENTS.md +849 -0
- package/docs/architecture/08-VOICE-CALLS.md +470 -0
- package/docs/architecture/09-OUTBOUND-CAMPAIGNS.md +542 -0
- package/docs/architecture/10-CLIENT-TOOLS.md +665 -0
- package/docs/architecture/11-BILLING.md +458 -0
- package/docs/architecture/12-SECURITY.md +216 -0
- package/docs/architecture/13-LOGGING-AUDIT.md +549 -0
- package/docs/architecture/14-META-BUSINESS-API.md +454 -0
- package/docs/architecture/15-AI-MODULE.md +479 -0
- package/docs/architecture/16-BACKGROUND-JOBS.md +469 -0
- package/docs/architecture/17-REALTIME-LIVECHAT.md +447 -0
- package/docs/architecture/18-FILE-STORAGE.md +410 -0
- package/docs/architecture/19-PATIENTS.md +1034 -0
- package/docs/architecture/20-TREATMENTS-AND-PLANS.md +774 -0
- package/docs/architecture/21-BEFORE-AFTER-PHOTOS.md +519 -0
- package/docs/database.md +456 -0
- package/docs/ornek-randevu-onay.csv +3 -0
- package/ecosystem.config.js +16 -0
- package/elevenlabs-convai-api-reference.md +1171 -0
- package/frontend/.dockerignore +2 -0
- package/frontend/Dockerfile +24 -0
- package/frontend/README.md +75 -0
- package/frontend/components.json +25 -0
- package/frontend/eslint.config.js +23 -0
- package/frontend/index.html +13 -0
- package/frontend/nginx.conf +37 -0
- package/frontend/package-lock.json +8709 -0
- package/frontend/package.json +71 -0
- package/frontend/public/favicon.svg +1 -0
- package/frontend/public/icons.svg +24 -0
- package/frontend/src/App.tsx +125 -0
- package/frontend/src/components/error-boundary.tsx +50 -0
- package/frontend/src/components/shared/activity-timeline.tsx +66 -0
- package/frontend/src/components/shared/appointment-calendar.css +80 -0
- package/frontend/src/components/shared/appointment-calendar.tsx +245 -0
- package/frontend/src/components/shared/chat-bubble.tsx +72 -0
- package/frontend/src/components/shared/combobox.tsx +119 -0
- package/frontend/src/components/shared/confirm-dialog.tsx +57 -0
- package/frontend/src/components/shared/data-table-pagination.tsx +97 -0
- package/frontend/src/components/shared/data-table-toolbar.tsx +39 -0
- package/frontend/src/components/shared/empty-state.tsx +27 -0
- package/frontend/src/components/shared/file-upload.tsx +140 -0
- package/frontend/src/components/shared/index.ts +20 -0
- package/frontend/src/components/shared/language-switcher.tsx +29 -0
- package/frontend/src/components/shared/notification-dropdown.tsx +115 -0
- package/frontend/src/components/shared/page-header.tsx +23 -0
- package/frontend/src/components/shared/stat-card.tsx +37 -0
- package/frontend/src/components/shared/status-badge.tsx +70 -0
- package/frontend/src/components/shared/status-dot.tsx +43 -0
- package/frontend/src/components/ui/alert-dialog.tsx +187 -0
- package/frontend/src/components/ui/alert.tsx +76 -0
- package/frontend/src/components/ui/avatar.tsx +109 -0
- package/frontend/src/components/ui/badge.tsx +52 -0
- package/frontend/src/components/ui/breadcrumb.tsx +125 -0
- package/frontend/src/components/ui/button.tsx +60 -0
- package/frontend/src/components/ui/calendar.tsx +219 -0
- package/frontend/src/components/ui/card.tsx +103 -0
- package/frontend/src/components/ui/chart.tsx +371 -0
- package/frontend/src/components/ui/checkbox.tsx +29 -0
- package/frontend/src/components/ui/collapsible.tsx +19 -0
- package/frontend/src/components/ui/command.tsx +194 -0
- package/frontend/src/components/ui/dialog.tsx +158 -0
- package/frontend/src/components/ui/dropdown-menu.tsx +268 -0
- package/frontend/src/components/ui/input-group.tsx +156 -0
- package/frontend/src/components/ui/input.tsx +20 -0
- package/frontend/src/components/ui/label.tsx +20 -0
- package/frontend/src/components/ui/pagination.tsx +130 -0
- package/frontend/src/components/ui/popover.tsx +90 -0
- package/frontend/src/components/ui/progress.tsx +83 -0
- package/frontend/src/components/ui/radio-group.tsx +36 -0
- package/frontend/src/components/ui/scroll-area.tsx +54 -0
- package/frontend/src/components/ui/select.tsx +199 -0
- package/frontend/src/components/ui/separator.tsx +23 -0
- package/frontend/src/components/ui/sheet.tsx +138 -0
- package/frontend/src/components/ui/sidebar.tsx +723 -0
- package/frontend/src/components/ui/skeleton.tsx +13 -0
- package/frontend/src/components/ui/sonner.tsx +47 -0
- package/frontend/src/components/ui/switch.tsx +30 -0
- package/frontend/src/components/ui/table.tsx +114 -0
- package/frontend/src/components/ui/tabs.tsx +82 -0
- package/frontend/src/components/ui/textarea.tsx +18 -0
- package/frontend/src/components/ui/toggle-group.tsx +89 -0
- package/frontend/src/components/ui/toggle.tsx +43 -0
- package/frontend/src/components/ui/tooltip.tsx +64 -0
- package/frontend/src/hooks/use-mobile.ts +19 -0
- package/frontend/src/i18n/index.ts +20 -0
- package/frontend/src/i18n/locales/en.json +786 -0
- package/frontend/src/i18n/locales/tr.json +786 -0
- package/frontend/src/index.css +134 -0
- package/frontend/src/layouts/admin-layout.tsx +111 -0
- package/frontend/src/layouts/app-header.tsx +130 -0
- package/frontend/src/layouts/app-layout.tsx +19 -0
- package/frontend/src/layouts/app-sidebar.tsx +212 -0
- package/frontend/src/layouts/auth-guard.tsx +31 -0
- package/frontend/src/layouts/mobile-sidebar.tsx +120 -0
- package/frontend/src/lib/api.ts +68 -0
- package/frontend/src/lib/hooks/use-appointments.ts +224 -0
- package/frontend/src/lib/hooks/use-availability-blocks.ts +83 -0
- package/frontend/src/lib/hooks/use-chats.ts +236 -0
- package/frontend/src/lib/hooks/use-clinic-detail.ts +171 -0
- package/frontend/src/lib/hooks/use-clinics.ts +37 -0
- package/frontend/src/lib/hooks/use-doctor-calendar.ts +80 -0
- package/frontend/src/lib/hooks/use-examinations.ts +89 -0
- package/frontend/src/lib/hooks/use-medical-history.ts +52 -0
- package/frontend/src/lib/hooks/use-patients.ts +296 -0
- package/frontend/src/lib/hooks/use-photo-sets.ts +118 -0
- package/frontend/src/lib/hooks/use-treatment-plans.ts +152 -0
- package/frontend/src/lib/hooks/use-treatments.ts +125 -0
- package/frontend/src/lib/hooks/use-users.ts +172 -0
- package/frontend/src/lib/utils.ts +6 -0
- package/frontend/src/main.tsx +17 -0
- package/frontend/src/pages/admin/agents/detail.tsx +774 -0
- package/frontend/src/pages/admin/agents/import.tsx +280 -0
- package/frontend/src/pages/admin/agents/index.tsx +245 -0
- package/frontend/src/pages/admin/ai-playground.tsx +543 -0
- package/frontend/src/pages/appointments/create-appointment-dialog.tsx +390 -0
- package/frontend/src/pages/appointments/index.tsx +860 -0
- package/frontend/src/pages/audit/index.tsx +24 -0
- package/frontend/src/pages/auth/login.tsx +194 -0
- package/frontend/src/pages/billing/index.tsx +24 -0
- package/frontend/src/pages/calendar/index.tsx +704 -0
- package/frontend/src/pages/calendar-connections/index.tsx +295 -0
- package/frontend/src/pages/calls/index.tsx +24 -0
- package/frontend/src/pages/campaigns/index.tsx +24 -0
- package/frontend/src/pages/chats/index.tsx +981 -0
- package/frontend/src/pages/clinics/index.tsx +224 -0
- package/frontend/src/pages/clinics/settings.tsx +412 -0
- package/frontend/src/pages/components-showcase.tsx +773 -0
- package/frontend/src/pages/connections/index.tsx +328 -0
- package/frontend/src/pages/dashboard.tsx +50 -0
- package/frontend/src/pages/leads/index.tsx +24 -0
- package/frontend/src/pages/my-schedule/index.tsx +496 -0
- package/frontend/src/pages/patients/create-patient-dialog.tsx +358 -0
- package/frontend/src/pages/patients/detail.tsx +1195 -0
- package/frontend/src/pages/patients/edit-patient-dialog.tsx +387 -0
- package/frontend/src/pages/patients/examinations-tab.tsx +460 -0
- package/frontend/src/pages/patients/index.tsx +381 -0
- package/frontend/src/pages/patients/medical-history-dialog.tsx +207 -0
- package/frontend/src/pages/patients/photo-sets-tab.tsx +616 -0
- package/frontend/src/pages/patients/timeline-tab.tsx +164 -0
- package/frontend/src/pages/patients/treatment-plans-tab.tsx +598 -0
- package/frontend/src/pages/phone-numbers/detail.tsx +427 -0
- package/frontend/src/pages/phone-numbers/index.tsx +455 -0
- package/frontend/src/pages/platform/index.tsx +454 -0
- package/frontend/src/pages/settings/index.tsx +126 -0
- package/frontend/src/pages/treatments/index.tsx +487 -0
- package/frontend/src/pages/users/doctor-profile.tsx +672 -0
- package/frontend/src/pages/users/edit.tsx +329 -0
- package/frontend/src/pages/users/index.tsx +407 -0
- package/frontend/src/pages/validation/index.tsx +24 -0
- package/frontend/src/stores/auth.store.ts +108 -0
- package/frontend/src/stores/ui.store.ts +41 -0
- package/frontend/tsconfig.app.json +32 -0
- package/frontend/tsconfig.json +13 -0
- package/frontend/tsconfig.node.json +26 -0
- package/frontend/vite.config.ts +29 -0
- package/nestjs/.dockerignore +5 -0
- package/nestjs/.env.docker +64 -0
- package/nestjs/.prettierrc +4 -0
- package/nestjs/Dockerfile +36 -0
- package/nestjs/README.md +98 -0
- package/nestjs/eslint.config.mjs +35 -0
- package/nestjs/nest-cli.json +8 -0
- package/nestjs/package-lock.json +13390 -0
- package/nestjs/package.json +114 -0
- package/nestjs/prisma/migrations/20260409161536_add_message_metadata_fields/migration.sql +1746 -0
- package/nestjs/prisma/migrations/20260410140436_add_agent_ai_fields/migration.sql +36 -0
- package/nestjs/prisma/migrations/20260410175519_add_agent_clinic_assignments/migration.sql +21 -0
- package/nestjs/prisma/migrations/20260412094344_make_agent_tenant_optional/migration.sql +2 -0
- package/nestjs/prisma/migrations/20260412110008_add_admin_chat_sessions/migration.sql +47 -0
- package/nestjs/prisma/migrations/migration_lock.toml +3 -0
- package/nestjs/prisma/schema.prisma +1843 -0
- package/nestjs/prisma/seed.ts +375 -0
- package/nestjs/prisma.config.ts +14 -0
- package/nestjs/src/admin/admin.controller.ts +27 -0
- package/nestjs/src/admin/admin.module.ts +16 -0
- package/nestjs/src/admin/admin.service.ts +91 -0
- package/nestjs/src/admin/ai-chat-session.service.ts +454 -0
- package/nestjs/src/admin/ai-test.controller.ts +191 -0
- package/nestjs/src/admin/superadmin.controller.ts +106 -0
- package/nestjs/src/agents/agents.controller.ts +262 -0
- package/nestjs/src/agents/agents.module.ts +13 -0
- package/nestjs/src/agents/agents.service.ts +733 -0
- package/nestjs/src/agents/dto/create-agent.dto.ts +99 -0
- package/nestjs/src/agents/dto/index.ts +2 -0
- package/nestjs/src/agents/dto/update-agent.dto.ts +148 -0
- package/nestjs/src/app.module.ts +115 -0
- package/nestjs/src/appointment-validation/appointment-validation.controller.ts +194 -0
- package/nestjs/src/appointment-validation/appointment-validation.module.ts +16 -0
- package/nestjs/src/appointment-validation/appointment-validation.service.ts +450 -0
- package/nestjs/src/appointment-validation/dto/create-batch.dto.ts +105 -0
- package/nestjs/src/appointment-validation/dto/index.ts +1 -0
- package/nestjs/src/appointment-validation/processors/validation-dispatch.processor.ts +26 -0
- package/nestjs/src/appointment-validation/processors/validation-sync.processor.ts +23 -0
- package/nestjs/src/appointments/appointments.controller.ts +268 -0
- package/nestjs/src/appointments/appointments.module.ts +13 -0
- package/nestjs/src/appointments/appointments.service.ts +773 -0
- package/nestjs/src/appointments/dto/create-appointment.dto.ts +72 -0
- package/nestjs/src/appointments/dto/index.ts +4 -0
- package/nestjs/src/appointments/dto/query-appointments.dto.ts +60 -0
- package/nestjs/src/appointments/dto/update-appointment.dto.ts +43 -0
- package/nestjs/src/appointments/dto/update-status.dto.ts +18 -0
- package/nestjs/src/appointments/processors/reminder.processor.ts +243 -0
- package/nestjs/src/audit/audit.controller.ts +84 -0
- package/nestjs/src/audit/audit.decorator.ts +20 -0
- package/nestjs/src/audit/audit.interceptor.ts +67 -0
- package/nestjs/src/audit/audit.interfaces.ts +15 -0
- package/nestjs/src/audit/audit.module.ts +12 -0
- package/nestjs/src/audit/audit.service.ts +177 -0
- package/nestjs/src/auth/auth.controller.ts +116 -0
- package/nestjs/src/auth/auth.module.ts +25 -0
- package/nestjs/src/auth/auth.service.ts +612 -0
- package/nestjs/src/auth/dto/change-password.dto.ts +13 -0
- package/nestjs/src/auth/dto/forgot-password.dto.ts +8 -0
- package/nestjs/src/auth/dto/index.ts +6 -0
- package/nestjs/src/auth/dto/login.dto.ts +13 -0
- package/nestjs/src/auth/dto/refresh.dto.ts +8 -0
- package/nestjs/src/auth/dto/register.dto.ts +28 -0
- package/nestjs/src/auth/dto/reset-password.dto.ts +13 -0
- package/nestjs/src/auth/permissions.config.ts +85 -0
- package/nestjs/src/availability-blocks/availability-blocks.controller.ts +83 -0
- package/nestjs/src/availability-blocks/availability-blocks.module.ts +10 -0
- package/nestjs/src/availability-blocks/availability-blocks.service.ts +202 -0
- package/nestjs/src/billing/billing.controller.ts +104 -0
- package/nestjs/src/billing/billing.module.ts +12 -0
- package/nestjs/src/billing/billing.service.ts +398 -0
- package/nestjs/src/billing/dto/index.ts +2 -0
- package/nestjs/src/billing/dto/query-billing.dto.ts +29 -0
- package/nestjs/src/billing/dto/record-payment.dto.ts +60 -0
- package/nestjs/src/billing/processors/alerts.processor.ts +216 -0
- package/nestjs/src/billing/processors/snapshot.processor.ts +181 -0
- package/nestjs/src/calls/calls.controller.ts +92 -0
- package/nestjs/src/calls/calls.module.ts +10 -0
- package/nestjs/src/calls/calls.service.ts +359 -0
- package/nestjs/src/calls/dto/index.ts +1 -0
- package/nestjs/src/calls/dto/query-calls.dto.ts +46 -0
- package/nestjs/src/clinics/clinics.controller.ts +226 -0
- package/nestjs/src/clinics/clinics.module.ts +11 -0
- package/nestjs/src/clinics/clinics.service.ts +203 -0
- package/nestjs/src/clinics/dto/create-clinic.dto.ts +40 -0
- package/nestjs/src/clinics/dto/create-meta-connection.dto.ts +44 -0
- package/nestjs/src/clinics/dto/index.ts +4 -0
- package/nestjs/src/clinics/dto/update-clinic.dto.ts +133 -0
- package/nestjs/src/clinics/dto/update-meta-connection.dto.ts +22 -0
- package/nestjs/src/clinics/meta-connections.service.ts +210 -0
- package/nestjs/src/common/decorators/accessible-clinics.decorator.ts +9 -0
- package/nestjs/src/common/decorators/current-user.decorator.ts +10 -0
- package/nestjs/src/common/decorators/features.decorator.ts +7 -0
- package/nestjs/src/common/decorators/index.ts +4 -0
- package/nestjs/src/common/decorators/permissions.decorator.ts +13 -0
- package/nestjs/src/common/gateways/events.gateway.ts +157 -0
- package/nestjs/src/common/guards/clinic-access.guard.ts +40 -0
- package/nestjs/src/common/guards/features.guard.ts +38 -0
- package/nestjs/src/common/guards/index.ts +5 -0
- package/nestjs/src/common/guards/jwt-auth.guard.ts +54 -0
- package/nestjs/src/common/guards/permissions.guard.ts +50 -0
- package/nestjs/src/common/guards/superadmin.guard.ts +35 -0
- package/nestjs/src/common/interceptors/request-context.interceptor.ts +32 -0
- package/nestjs/src/common/interceptors/superadmin-tenant.interceptor.ts +42 -0
- package/nestjs/src/common/interfaces/jwt-payload.interface.ts +11 -0
- package/nestjs/src/config/config.module.ts +13 -0
- package/nestjs/src/database/database.module.ts +9 -0
- package/nestjs/src/database/prisma.service.ts +20 -0
- package/nestjs/src/database/tenant-context.ts +27 -0
- package/nestjs/src/google-calendar/google-calendar.controller.ts +259 -0
- package/nestjs/src/google-calendar/google-calendar.module.ts +10 -0
- package/nestjs/src/google-calendar/google-calendar.service.ts +811 -0
- package/nestjs/src/integrations/ai/ai.interface.ts +74 -0
- package/nestjs/src/integrations/ai/ai.module.ts +11 -0
- package/nestjs/src/integrations/ai/ai.service.ts +148 -0
- package/nestjs/src/integrations/ai/providers/anthropic.provider.ts +146 -0
- package/nestjs/src/integrations/ai/providers/openai.provider.ts +158 -0
- package/nestjs/src/integrations/elevenlabs/elevenlabs.module.ts +8 -0
- package/nestjs/src/integrations/elevenlabs/elevenlabs.service.ts +226 -0
- package/nestjs/src/integrations/encryption/encryption.module.ts +9 -0
- package/nestjs/src/integrations/encryption/encryption.service.ts +31 -0
- package/nestjs/src/integrations/image/image.module.ts +9 -0
- package/nestjs/src/integrations/image/image.service.ts +61 -0
- package/nestjs/src/integrations/meta-business/meta-business.module.ts +10 -0
- package/nestjs/src/integrations/meta-business/meta-instagram.service.ts +94 -0
- package/nestjs/src/integrations/meta-business/meta-webhook.service.ts +52 -0
- package/nestjs/src/integrations/meta-business/meta-whatsapp.service.ts +254 -0
- package/nestjs/src/integrations/minio/minio.module.ts +9 -0
- package/nestjs/src/integrations/minio/minio.service.ts +88 -0
- package/nestjs/src/integrations/netgsm/netgsm.module.ts +8 -0
- package/nestjs/src/integrations/netgsm/netgsm.service.ts +17 -0
- package/nestjs/src/integrations/tektippay/tektippay.module.ts +8 -0
- package/nestjs/src/integrations/tektippay/tektippay.service.ts +23 -0
- package/nestjs/src/leads/dto/index.ts +2 -0
- package/nestjs/src/leads/dto/query-leads.dto.ts +50 -0
- package/nestjs/src/leads/dto/update-lead.dto.ts +26 -0
- package/nestjs/src/leads/leads.controller.ts +184 -0
- package/nestjs/src/leads/leads.module.ts +10 -0
- package/nestjs/src/leads/leads.service.ts +375 -0
- package/nestjs/src/logging/logging.controller.ts +82 -0
- package/nestjs/src/logging/logging.module.ts +11 -0
- package/nestjs/src/logging/logging.service.ts +180 -0
- package/nestjs/src/main.ts +86 -0
- package/nestjs/src/outbound-campaigns/dto/create-campaign.dto.ts +47 -0
- package/nestjs/src/outbound-campaigns/dto/index.ts +3 -0
- package/nestjs/src/outbound-campaigns/dto/update-campaign.dto.ts +31 -0
- package/nestjs/src/outbound-campaigns/dto/upload-entries.dto.ts +35 -0
- package/nestjs/src/outbound-campaigns/outbound-campaigns.controller.ts +307 -0
- package/nestjs/src/outbound-campaigns/outbound-campaigns.module.ts +13 -0
- package/nestjs/src/outbound-campaigns/outbound-campaigns.service.ts +471 -0
- package/nestjs/src/outbound-campaigns/processors/campaign-dispatch.processor.ts +366 -0
- package/nestjs/src/patients/documents.service.ts +231 -0
- package/nestjs/src/patients/dto/create-examination.dto.ts +34 -0
- package/nestjs/src/patients/dto/create-note.dto.ts +14 -0
- package/nestjs/src/patients/dto/create-patient.dto.ts +86 -0
- package/nestjs/src/patients/dto/create-photo-set.dto.ts +32 -0
- package/nestjs/src/patients/dto/index.ts +10 -0
- package/nestjs/src/patients/dto/update-examination.dto.ts +29 -0
- package/nestjs/src/patients/dto/update-medical-history.dto.ts +47 -0
- package/nestjs/src/patients/dto/update-patient.dto.ts +87 -0
- package/nestjs/src/patients/dto/update-photo-set.dto.ts +28 -0
- package/nestjs/src/patients/dto/upload-document.dto.ts +31 -0
- package/nestjs/src/patients/dto/upload-photo.dto.ts +27 -0
- package/nestjs/src/patients/examinations.service.ts +271 -0
- package/nestjs/src/patients/medical-history.service.ts +149 -0
- package/nestjs/src/patients/notes.service.ts +172 -0
- package/nestjs/src/patients/patients.controller.ts +485 -0
- package/nestjs/src/patients/patients.module.ts +22 -0
- package/nestjs/src/patients/patients.service.ts +412 -0
- package/nestjs/src/patients/photo-sets.service.ts +389 -0
- package/nestjs/src/phone-numbers/dto/create-phone-number.dto.ts +57 -0
- package/nestjs/src/phone-numbers/dto/index.ts +3 -0
- package/nestjs/src/phone-numbers/dto/update-phone-number.dto.ts +38 -0
- package/nestjs/src/phone-numbers/dto/update-schedule.dto.ts +39 -0
- package/nestjs/src/phone-numbers/phone-numbers.controller.ts +125 -0
- package/nestjs/src/phone-numbers/phone-numbers.module.ts +10 -0
- package/nestjs/src/phone-numbers/phone-numbers.service.ts +209 -0
- package/nestjs/src/plans/dto/create-plan.dto.ts +70 -0
- package/nestjs/src/plans/dto/index.ts +2 -0
- package/nestjs/src/plans/dto/update-plan.dto.ts +80 -0
- package/nestjs/src/plans/plans.controller.ts +50 -0
- package/nestjs/src/plans/plans.module.ts +10 -0
- package/nestjs/src/plans/plans.service.ts +115 -0
- package/nestjs/src/platform/dto/create-tenant.dto.ts +36 -0
- package/nestjs/src/platform/dto/index.ts +2 -0
- package/nestjs/src/platform/dto/update-tenant-platform.dto.ts +44 -0
- package/nestjs/src/platform/platform.controller.ts +79 -0
- package/nestjs/src/platform/platform.module.ts +10 -0
- package/nestjs/src/platform/platform.service.ts +301 -0
- package/nestjs/src/queue/queue.module.ts +56 -0
- package/nestjs/src/redis/redis.module.ts +20 -0
- package/nestjs/src/tenants/dto/index.ts +1 -0
- package/nestjs/src/tenants/dto/update-tenant.dto.ts +15 -0
- package/nestjs/src/tenants/tenants.controller.ts +45 -0
- package/nestjs/src/tenants/tenants.module.ts +10 -0
- package/nestjs/src/tenants/tenants.service.ts +41 -0
- package/nestjs/src/tools/adapters/elevenlabs-http.adapter.ts +51 -0
- package/nestjs/src/tools/adapters/elevenlabs-ws.adapter.ts +59 -0
- package/nestjs/src/tools/handlers/calendar.tools.ts +441 -0
- package/nestjs/src/tools/handlers/notification.tools.ts +34 -0
- package/nestjs/src/tools/handlers/operator.tools.ts +303 -0
- package/nestjs/src/tools/handlers/patient.tools.ts +242 -0
- package/nestjs/src/tools/handlers/payment.tools.ts +43 -0
- package/nestjs/src/tools/handlers/validation.tools.ts +152 -0
- package/nestjs/src/tools/tool-registry.service.ts +221 -0
- package/nestjs/src/tools/tool.decorator.ts +16 -0
- package/nestjs/src/tools/tool.interfaces.ts +26 -0
- package/nestjs/src/tools/tools.module.ts +50 -0
- package/nestjs/src/treatments/dto/create-plan-item.dto.ts +27 -0
- package/nestjs/src/treatments/dto/create-treatment-plan.dto.ts +69 -0
- package/nestjs/src/treatments/dto/create-treatment.dto.ts +59 -0
- package/nestjs/src/treatments/dto/index.ts +6 -0
- package/nestjs/src/treatments/dto/update-plan-item.dto.ts +23 -0
- package/nestjs/src/treatments/dto/update-treatment-plan.dto.ts +22 -0
- package/nestjs/src/treatments/dto/update-treatment.dto.ts +70 -0
- package/nestjs/src/treatments/treatment-plans.service.ts +362 -0
- package/nestjs/src/treatments/treatments.controller.ts +265 -0
- package/nestjs/src/treatments/treatments.module.ts +14 -0
- package/nestjs/src/treatments/treatments.service.ts +165 -0
- package/nestjs/src/users/doctor-profiles.service.ts +202 -0
- package/nestjs/src/users/dto/index.ts +4 -0
- package/nestjs/src/users/dto/invite-user.dto.ts +52 -0
- package/nestjs/src/users/dto/update-clinic-assignments.dto.ts +9 -0
- package/nestjs/src/users/dto/update-doctor-profile.dto.ts +49 -0
- package/nestjs/src/users/dto/update-user.dto.ts +41 -0
- package/nestjs/src/users/users.controller.ts +142 -0
- package/nestjs/src/users/users.module.ts +11 -0
- package/nestjs/src/users/users.service.ts +250 -0
- package/nestjs/src/webhooks/elevenlabs-tool.controller.ts +66 -0
- package/nestjs/src/webhooks/elevenlabs-webhook.controller.ts +60 -0
- package/nestjs/src/webhooks/meta-webhook.controller.ts +178 -0
- package/nestjs/src/webhooks/processors/elevenlabs-webhook.processor.ts +28 -0
- package/nestjs/src/webhooks/webhooks.module.ts +17 -0
- package/nestjs/src/whatsapp/chat-context.service.ts +281 -0
- package/nestjs/src/whatsapp/dto/add-blacklist.dto.ts +13 -0
- package/nestjs/src/whatsapp/dto/index.ts +2 -0
- package/nestjs/src/whatsapp/dto/send-message.dto.ts +14 -0
- package/nestjs/src/whatsapp/listeners/meta-message.listener.ts +579 -0
- package/nestjs/src/whatsapp/meta-auth.controller.ts +268 -0
- package/nestjs/src/whatsapp/meta-instagram-auth.controller.ts +244 -0
- package/nestjs/src/whatsapp/operator.service.ts +692 -0
- package/nestjs/src/whatsapp/processors/cost-sweep.processor.ts +130 -0
- package/nestjs/src/whatsapp/processors/grace.processor.ts +191 -0
- package/nestjs/src/whatsapp/processors/message-buffer.processor.ts +138 -0
- package/nestjs/src/whatsapp/processors/message-cleanup.processor.ts +148 -0
- package/nestjs/src/whatsapp/processors/meta-token-refresh.processor.ts +114 -0
- package/nestjs/src/whatsapp/processors/operator-expiry.processor.ts +105 -0
- package/nestjs/src/whatsapp/processors/profile-update.processor.ts +234 -0
- package/nestjs/src/whatsapp/processors/session-cleanup.processor.ts +178 -0
- package/nestjs/src/whatsapp/processors/session-labels.processor.ts +248 -0
- package/nestjs/src/whatsapp/whatsapp-agent.service.ts +2506 -0
- package/nestjs/src/whatsapp/whatsapp-recovery.service.ts +117 -0
- package/nestjs/src/whatsapp/whatsapp.controller.ts +398 -0
- package/nestjs/src/whatsapp/whatsapp.module.ts +51 -0
- package/nestjs/src/whatsapp/whatsapp.service.ts +592 -0
- package/nestjs/test/app.e2e-spec.ts +25 -0
- package/nestjs/test/jest-e2e.json +9 -0
- package/nestjs/tsconfig.build.json +4 -0
- package/nestjs/tsconfig.json +25 -0
- package/nginx.example.conf +18 -0
- package/package.json +47 -0
- package/scripts/addRecipient.ts +48 -0
- package/scripts/listRecipients.ts +31 -0
- package/scripts/migrate-agent-ref.ts +86 -0
- package/scripts/migrate-sessions.ts +183 -0
- package/scripts/promote.ts +27 -0
- package/scripts/retrigger.ts +84 -0
- package/scripts/seed.ts +435 -0
- package/scripts/testSend.ts +63 -0
- package/src/app.ts +85 -0
- package/src/config/agentPrompts.json +19 -0
- package/src/config/database.ts +21 -0
- package/src/config/env.ts +94 -0
- package/src/config/index.ts +86 -0
- package/src/controllers/webhook.controller.ts +150 -0
- package/src/errors/index.ts +9 -0
- package/src/hooks/webhookValidator.ts +55 -0
- package/src/index.ts +68 -0
- package/src/migrations/001_session-architecture.ts +138 -0
- package/src/migrations/002_agent-ref.ts +65 -0
- package/src/migrations/003_shift-schedule.ts +55 -0
- package/src/migrations/004_invert-shift-to-clinic-hours.ts +30 -0
- package/src/migrations/005_composite-baileys-chat-ids.ts +60 -0
- package/src/migrations/migration.model.ts +27 -0
- package/src/migrations/migration.routes.ts +40 -0
- package/src/migrations/migration.runner.ts +112 -0
- package/src/models/ConversationClaim.ts +17 -0
- package/src/models/ConversationEvaluation.ts +91 -0
- package/src/models/Summary.ts +77 -0
- package/src/models/Transcription.ts +68 -0
- package/src/models/WhatsAppRecipient.ts +37 -0
- package/src/modules/admin/admin.routes.ts +2385 -0
- package/src/modules/admin/elevenlabs-test-chat.routes.ts +193 -0
- package/src/modules/admin/whatsapp-test-chat.routes.ts +244 -0
- package/src/modules/agents/agent.model.ts +93 -0
- package/src/modules/agents/agent.routes.ts +65 -0
- package/src/modules/appointment-validation/appointment-validation.model.ts +163 -0
- package/src/modules/appointment-validation/appointment-validation.routes.ts +275 -0
- package/src/modules/appointment-validation/appointment-validation.service.ts +1028 -0
- package/src/modules/auth/auth.model.ts +42 -0
- package/src/modules/auth/auth.routes.ts +199 -0
- package/src/modules/auth/auth.service.ts +210 -0
- package/src/modules/auth/refresh-token.model.ts +26 -0
- package/src/modules/billing/billing-alert.model.ts +28 -0
- package/src/modules/billing/billing-period-snapshot.model.ts +68 -0
- package/src/modules/billing/billing.model.ts +42 -0
- package/src/modules/billing/billing.routes.ts +67 -0
- package/src/modules/billing/billing.service.ts +562 -0
- package/src/modules/billing/payment.model.ts +42 -0
- package/src/modules/calls/call.model.ts +102 -0
- package/src/modules/calls/call.routes.ts +118 -0
- package/src/modules/campaigns/campaign.model.ts +111 -0
- package/src/modules/campaigns/campaign.routes.ts +402 -0
- package/src/modules/campaigns/campaign.service.ts +99 -0
- package/src/modules/google-calendar/google-calendar.routes.ts +31 -0
- package/src/modules/inbound-call/inbound-call-config.model.ts +49 -0
- package/src/modules/inbound-call/inbound-call.routes.ts +289 -0
- package/src/modules/leads/lead.model.ts +40 -0
- package/src/modules/leads/lead.routes.ts +246 -0
- package/src/modules/logs/log.model.ts +27 -0
- package/src/modules/logs/log.routes.ts +102 -0
- package/src/modules/plans/plan.model.ts +45 -0
- package/src/modules/surveys/survey.model.ts +70 -0
- package/src/modules/surveys/survey.routes.ts +187 -0
- package/src/modules/tenants/tenant.model.ts +181 -0
- package/src/modules/tenants/tenant.routes.ts +78 -0
- package/src/modules/users/user.routes.ts +126 -0
- package/src/modules/webhooks/elevenlabs-tool.routes.ts +94 -0
- package/src/modules/webhooks/elevenlabs-tool.service.ts +491 -0
- package/src/modules/webhooks/elevenlabs.routes.ts +34 -0
- package/src/modules/webhooks/elevenlabs.service.ts +565 -0
- package/src/modules/webhooks/unipile.routes.ts +917 -0
- package/src/modules/whatsapp/appointment.model.ts +47 -0
- package/src/modules/whatsapp/operator-request.model.ts +58 -0
- package/src/modules/whatsapp/stt-usage.model.ts +30 -0
- package/src/modules/whatsapp/whatsapp-chat.model.ts +39 -0
- package/src/modules/whatsapp/whatsapp-contact-profile.model.ts +41 -0
- package/src/modules/whatsapp/whatsapp-message.model.ts +41 -0
- package/src/modules/whatsapp/whatsapp-session.model.ts +60 -0
- package/src/modules/whatsapp/whatsapp.routes.ts +1435 -0
- package/src/plugins/cors.ts +7 -0
- package/src/plugins/jwt.ts +18 -0
- package/src/plugins/rawBody.ts +12 -0
- package/src/plugins/rbac.ts +24 -0
- package/src/routes/admin.routes.ts +208 -0
- package/src/routes/health.routes.ts +12 -0
- package/src/routes/webhook.routes.ts +12 -0
- package/src/services/ai/base.ai.ts +132 -0
- package/src/services/ai/gemini.service.ts +41 -0
- package/src/services/ai/index.ts +24 -0
- package/src/services/ai/openai.service.ts +48 -0
- package/src/services/elevenlabs.service.ts +532 -0
- package/src/services/google-calendar.service.ts +656 -0
- package/src/services/inbound-call-schedule.service.ts +174 -0
- package/src/services/netgsm.service.ts +128 -0
- package/src/services/unipile.service.ts +200 -0
- package/src/services/whatsapp-agent.service.ts +2479 -0
- package/src/services/whatsapp.service.ts +245 -0
- package/src/templates/index.ts +71 -0
- package/src/templates/receptionist.ts +44 -0
- package/src/templates/survey.ts +44 -0
- package/src/types/index.ts +218 -0
- package/src/utils/logger.ts +83 -0
- package/tsconfig.json +19 -0
- package/web/.dockerignore +4 -0
- package/web/Dockerfile +18 -0
- package/web/README.md +73 -0
- package/web/components.json +23 -0
- package/web/eslint.config.js +23 -0
- package/web/index.html +14 -0
- package/web/nginx.conf +18 -0
- package/web/package-lock.json +10292 -0
- package/web/package.json +48 -0
- package/web/public/favicon.ico +0 -0
- package/web/public/vite.svg +1 -0
- package/web/src/App.tsx +191 -0
- package/web/src/assets/react.svg +1 -0
- package/web/src/components/Layout.tsx +261 -0
- package/web/src/components/LeadConversation.tsx +251 -0
- package/web/src/components/ProtectedRoute.tsx +20 -0
- package/web/src/components/conversation-review/CardAudioPlayer.tsx +200 -0
- package/web/src/components/conversation-review/CardEvaluation.tsx +351 -0
- package/web/src/components/conversation-review/CardMetadata.tsx +44 -0
- package/web/src/components/conversation-review/ConversationCard.tsx +95 -0
- package/web/src/components/conversation-review/ReviewContainer.tsx +120 -0
- package/web/src/components/conversation-review/ReviewFilters.tsx +88 -0
- package/web/src/components/empty-state.tsx +15 -0
- package/web/src/components/page-header.tsx +19 -0
- package/web/src/components/page-loader.tsx +32 -0
- package/web/src/components/pagination.tsx +38 -0
- package/web/src/components/stat-card.tsx +27 -0
- package/web/src/components/status-badge.tsx +125 -0
- package/web/src/components/ui/alert.tsx +66 -0
- package/web/src/components/ui/badge.tsx +48 -0
- package/web/src/components/ui/button.tsx +64 -0
- package/web/src/components/ui/card.tsx +92 -0
- package/web/src/components/ui/checkbox.tsx +32 -0
- package/web/src/components/ui/dialog.tsx +158 -0
- package/web/src/components/ui/dropdown-menu.tsx +255 -0
- package/web/src/components/ui/input.tsx +21 -0
- package/web/src/components/ui/label.tsx +22 -0
- package/web/src/components/ui/progress.tsx +29 -0
- package/web/src/components/ui/select.tsx +188 -0
- package/web/src/components/ui/separator.tsx +28 -0
- package/web/src/components/ui/sheet.tsx +123 -0
- package/web/src/components/ui/skeleton.tsx +13 -0
- package/web/src/components/ui/sonner.tsx +35 -0
- package/web/src/components/ui/table.tsx +116 -0
- package/web/src/components/ui/tabs.tsx +89 -0
- package/web/src/components/ui/textarea.tsx +18 -0
- package/web/src/components/ui/tooltip.tsx +57 -0
- package/web/src/components/whatsapp/ChatDetail.tsx +417 -0
- package/web/src/components/whatsapp/ChatHeader.tsx +78 -0
- package/web/src/components/whatsapp/ChatList.tsx +107 -0
- package/web/src/components/whatsapp/ChatListItem.tsx +60 -0
- package/web/src/components/whatsapp/MessageBubble.tsx +46 -0
- package/web/src/components/whatsapp/MessageInput.tsx +63 -0
- package/web/src/components/whatsapp/MessageStream.tsx +135 -0
- package/web/src/components/whatsapp/SessionDivider.tsx +65 -0
- package/web/src/components/whatsapp/SessionHistory.tsx +119 -0
- package/web/src/components/whatsapp/settings/AiSettingsTab.tsx +268 -0
- package/web/src/components/whatsapp/settings/CalendarTab.tsx +339 -0
- package/web/src/components/whatsapp/settings/ConnectionTab.tsx +236 -0
- package/web/src/components/whatsapp/settings/NotificationsTab.tsx +109 -0
- package/web/src/components/whatsapp/settings/OperatorTab.tsx +303 -0
- package/web/src/contexts/AuthContext.tsx +103 -0
- package/web/src/index.css +130 -0
- package/web/src/lib/api.ts +92 -0
- package/web/src/lib/utils.ts +6 -0
- package/web/src/main.tsx +10 -0
- package/web/src/pages/AppointmentDetail.tsx +206 -0
- package/web/src/pages/AppointmentValidation.tsx +157 -0
- package/web/src/pages/AppointmentValidationDetail.tsx +617 -0
- package/web/src/pages/AppointmentValidationNew.tsx +1005 -0
- package/web/src/pages/Appointments.tsx +283 -0
- package/web/src/pages/Billing.tsx +126 -0
- package/web/src/pages/CallDetail.tsx +293 -0
- package/web/src/pages/CallSettings.tsx +313 -0
- package/web/src/pages/Calls.tsx +188 -0
- package/web/src/pages/CampaignDetail.tsx +216 -0
- package/web/src/pages/CampaignNew.tsx +277 -0
- package/web/src/pages/CampaignResults.tsx +185 -0
- package/web/src/pages/Campaigns.tsx +171 -0
- package/web/src/pages/Dashboard.tsx +336 -0
- package/web/src/pages/InboundSchedule.tsx +246 -0
- package/web/src/pages/LeadDetail.tsx +183 -0
- package/web/src/pages/Leads.tsx +258 -0
- package/web/src/pages/Login.tsx +99 -0
- package/web/src/pages/Register.tsx +129 -0
- package/web/src/pages/Settings.tsx +133 -0
- package/web/src/pages/SurveyDetail.tsx +232 -0
- package/web/src/pages/SurveyPreview.tsx +179 -0
- package/web/src/pages/Surveys.tsx +207 -0
- package/web/src/pages/Users.tsx +199 -0
- package/web/src/pages/WhatsApp.tsx +147 -0
- package/web/src/pages/WhatsAppSettings.tsx +215 -0
- package/web/src/pages/admin/AdminBaileysMessageLog.tsx +331 -0
- package/web/src/pages/admin/AdminBaileysRawMessages.tsx +318 -0
- package/web/src/pages/admin/AdminConversationReview.tsx +116 -0
- package/web/src/pages/admin/AdminCredits.tsx +467 -0
- package/web/src/pages/admin/AdminElevenLabsAgentDetail.tsx +332 -0
- package/web/src/pages/admin/AdminElevenLabsAgents.tsx +164 -0
- package/web/src/pages/admin/AdminElevenLabsBatchCallDetail.tsx +214 -0
- package/web/src/pages/admin/AdminElevenLabsBatchCalls.tsx +293 -0
- package/web/src/pages/admin/AdminElevenLabsConversationDetail.tsx +230 -0
- package/web/src/pages/admin/AdminElevenLabsConversations.tsx +228 -0
- package/web/src/pages/admin/AdminElevenLabsInboundCalls.tsx +293 -0
- package/web/src/pages/admin/AdminElevenLabsPhoneNumbers.tsx +506 -0
- package/web/src/pages/admin/AdminElevenLabsTestChat.tsx +258 -0
- package/web/src/pages/admin/AdminElevenLabsWebhooks.tsx +289 -0
- package/web/src/pages/admin/AdminEvaluationDashboard.tsx +520 -0
- package/web/src/pages/admin/AdminLeads.tsx +339 -0
- package/web/src/pages/admin/AdminLogs.tsx +283 -0
- package/web/src/pages/admin/AdminMigrations.tsx +247 -0
- package/web/src/pages/admin/AdminPlans.tsx +313 -0
- package/web/src/pages/admin/AdminSystem.tsx +391 -0
- package/web/src/pages/admin/AdminTenantBillableItems.tsx +464 -0
- package/web/src/pages/admin/AdminTenantDetail.tsx +1317 -0
- package/web/src/pages/admin/AdminTenants.tsx +274 -0
- package/web/src/pages/admin/AdminWhatsApp.tsx +618 -0
- package/web/src/pages/admin/AdminWhatsAppTestChat.tsx +328 -0
- package/web/src/types/index.ts +242 -0
- package/web/tsconfig.app.json +32 -0
- package/web/tsconfig.json +13 -0
- package/web/tsconfig.node.json +26 -0
- package/web/vite.config.ts +23 -0
|
@@ -0,0 +1,2479 @@
|
|
|
1
|
+
import mongoose from 'mongoose';
|
|
2
|
+
import WebSocket from 'ws';
|
|
3
|
+
import elevenlabsService from './elevenlabs.service';
|
|
4
|
+
import unipileService from './unipile.service';
|
|
5
|
+
import WhatsAppChat from '../modules/whatsapp/whatsapp-chat.model';
|
|
6
|
+
import WhatsAppSession from '../modules/whatsapp/whatsapp-session.model';
|
|
7
|
+
import WhatsAppMessage from '../modules/whatsapp/whatsapp-message.model';
|
|
8
|
+
import type { IWhatsAppChat } from '../modules/whatsapp/whatsapp-chat.model';
|
|
9
|
+
import type { IWhatsAppSession } from '../modules/whatsapp/whatsapp-session.model';
|
|
10
|
+
import Appointment from '../modules/whatsapp/appointment.model';
|
|
11
|
+
import Tenant from '../modules/tenants/tenant.model';
|
|
12
|
+
import Agent from '../modules/agents/agent.model';
|
|
13
|
+
import type { ITenant } from '../modules/tenants/tenant.model';
|
|
14
|
+
import WhatsAppContactProfile from '../modules/whatsapp/whatsapp-contact-profile.model';
|
|
15
|
+
import OperatorRequest from '../modules/whatsapp/operator-request.model';
|
|
16
|
+
import googleCalendarService from './google-calendar.service';
|
|
17
|
+
import netgsmService from './netgsm.service';
|
|
18
|
+
import Lead from '../modules/leads/lead.model';
|
|
19
|
+
import { createAIService } from './ai';
|
|
20
|
+
import OpenAI from 'openai';
|
|
21
|
+
import config from '../config';
|
|
22
|
+
import logger from '../utils/logger';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Resolve the active ElevenLabs agent ID for a tenant's WhatsApp agent.
|
|
26
|
+
* Returns the elevenlabs_agent_id string if the agent exists and is active, or '' otherwise.
|
|
27
|
+
*/
|
|
28
|
+
async function resolveAgentId(tenant: ITenant | { settings?: ITenant['settings'] }): Promise<string> {
|
|
29
|
+
const agentObjId = tenant.settings?.whatsapp_agent?.agent_id;
|
|
30
|
+
if (!agentObjId) return '';
|
|
31
|
+
const agent = await Agent.findById(agentObjId).lean();
|
|
32
|
+
if (!agent || !agent.is_active) return '';
|
|
33
|
+
return agent.elevenlabs_agent_id;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Keepalive ping interval to prevent EL from dropping idle connections
|
|
37
|
+
const WS_PING_INTERVAL_MS = 30_000;
|
|
38
|
+
|
|
39
|
+
interface ActiveConnection {
|
|
40
|
+
ws: WebSocket;
|
|
41
|
+
chatId: string;
|
|
42
|
+
sessionId: string;
|
|
43
|
+
unipileChatId: string;
|
|
44
|
+
agentId: string;
|
|
45
|
+
lastActivity: number;
|
|
46
|
+
conversationId: string | null;
|
|
47
|
+
ready: boolean;
|
|
48
|
+
intentionallyClosed: boolean;
|
|
49
|
+
pingTimer: NodeJS.Timeout | null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Auto-resolve active sessions with no activity after this duration
|
|
53
|
+
const SESSION_INACTIVITY_TIMEOUT_MS = 2 * 60 * 60 * 1000; // 2 hours
|
|
54
|
+
|
|
55
|
+
// Wait this long after the last contact message before sending to AI (debounce)
|
|
56
|
+
const MESSAGE_BUFFER_MIN_MS = 10_000; // 10 seconds
|
|
57
|
+
const MESSAGE_BUFFER_MAX_MS = 50_000; // 50 seconds
|
|
58
|
+
|
|
59
|
+
class WhatsAppAgentService {
|
|
60
|
+
private connections: Map<string, ActiveConnection> = new Map(); // key: unipileChatId
|
|
61
|
+
private sessionTimers: Map<string, NodeJS.Timeout> = new Map(); // key: sessionId
|
|
62
|
+
private messageBufferTimers: Map<string, NodeJS.Timeout> = new Map(); // key: sessionId
|
|
63
|
+
private idleTimeoutMs: number = 2 * 60 * 60 * 1000; // 2 hours (matches session inactivity timeout)
|
|
64
|
+
private cleanupInterval: NodeJS.Timeout | null = null;
|
|
65
|
+
private sweepInterval: NodeJS.Timeout | null = null;
|
|
66
|
+
private inactivitySweepInterval: NodeJS.Timeout | null = null;
|
|
67
|
+
private costSweepInterval: NodeJS.Timeout | null = null;
|
|
68
|
+
|
|
69
|
+
async initialize(): Promise<void> {
|
|
70
|
+
this.cleanupInterval = setInterval(() => this.cleanupIdle(), 60_000);
|
|
71
|
+
this.sweepInterval = setInterval(() => {
|
|
72
|
+
this.sweepStuckSessions().catch(err => {
|
|
73
|
+
logger.error('Stuck session sweep failed', { error: (err as Error).message });
|
|
74
|
+
});
|
|
75
|
+
}, 5 * 60_000);
|
|
76
|
+
this.inactivitySweepInterval = setInterval(() => {
|
|
77
|
+
this.sweepInactiveSessions().catch(err => {
|
|
78
|
+
logger.error('Inactive session sweep failed', { error: (err as Error).message });
|
|
79
|
+
});
|
|
80
|
+
}, 5 * 60_000);
|
|
81
|
+
this.costSweepInterval = setInterval(() => {
|
|
82
|
+
this.sweepConversationCosts().catch(err => {
|
|
83
|
+
logger.error('Cost sweep failed', { error: (err as Error).message });
|
|
84
|
+
});
|
|
85
|
+
}, 10 * 60_000);
|
|
86
|
+
setInterval(() => {
|
|
87
|
+
this.sweepExpiredOperatorRequests().catch(err => {
|
|
88
|
+
logger.error('Operator request expiry sweep failed', { error: (err as Error).message });
|
|
89
|
+
});
|
|
90
|
+
}, 15 * 60_000);
|
|
91
|
+
await this.recoverSessions();
|
|
92
|
+
logger.info('WhatsAppAgentService initialized');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ─── Session Management ──────────────────────────────────
|
|
96
|
+
|
|
97
|
+
async startSession(params: {
|
|
98
|
+
session: IWhatsAppSession;
|
|
99
|
+
gracePeriodSeconds: number;
|
|
100
|
+
agentId: string;
|
|
101
|
+
tenantId: string;
|
|
102
|
+
}): Promise<void> {
|
|
103
|
+
const { session, gracePeriodSeconds, agentId, tenantId } = params;
|
|
104
|
+
const sessionId = session._id.toString();
|
|
105
|
+
|
|
106
|
+
// Cancel any existing timer for this session
|
|
107
|
+
this.cancelSession(sessionId);
|
|
108
|
+
|
|
109
|
+
const timer = setTimeout(async () => {
|
|
110
|
+
this.sessionTimers.delete(sessionId);
|
|
111
|
+
await this.onGracePeriodExpired(sessionId, agentId, tenantId);
|
|
112
|
+
}, gracePeriodSeconds * 1000);
|
|
113
|
+
|
|
114
|
+
this.sessionTimers.set(sessionId, timer);
|
|
115
|
+
logger.info('Session grace period started', {
|
|
116
|
+
sessionId,
|
|
117
|
+
chatId: session.chat_id,
|
|
118
|
+
gracePeriodSeconds,
|
|
119
|
+
deadline: session.grace_deadline?.toISOString(),
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
cancelSession(sessionId: string): void {
|
|
124
|
+
const timer = this.sessionTimers.get(sessionId);
|
|
125
|
+
if (timer) {
|
|
126
|
+
clearTimeout(timer);
|
|
127
|
+
this.sessionTimers.delete(sessionId);
|
|
128
|
+
logger.info('Session timer cancelled', { sessionId });
|
|
129
|
+
}
|
|
130
|
+
this.cancelMessageBuffer(sessionId);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private cancelMessageBuffer(sessionId: string): void {
|
|
134
|
+
const timer = this.messageBufferTimers.get(sessionId);
|
|
135
|
+
if (timer) {
|
|
136
|
+
clearTimeout(timer);
|
|
137
|
+
this.messageBufferTimers.delete(sessionId);
|
|
138
|
+
logger.debug('Message buffer timer cancelled', { sessionId });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async resolveSessionAsHuman(session: IWhatsAppSession): Promise<void> {
|
|
143
|
+
this.cancelSession(session._id.toString());
|
|
144
|
+
session.status = 'resolved';
|
|
145
|
+
session.resolved_by = 'human';
|
|
146
|
+
session.resolved_at = new Date();
|
|
147
|
+
await session.save();
|
|
148
|
+
|
|
149
|
+
await WhatsAppChat.updateOne(
|
|
150
|
+
{ _id: session.chat_id },
|
|
151
|
+
{ active_session_id: null }
|
|
152
|
+
);
|
|
153
|
+
logger.info('Session resolved by human response', { sessionId: session._id });
|
|
154
|
+
|
|
155
|
+
this.updateContactProfileSummary(session._id.toString()).catch(err => {
|
|
156
|
+
logger.error('Profile summary update failed after human resolve', { error: (err as Error).message });
|
|
157
|
+
});
|
|
158
|
+
this.generateSessionLabels(session._id.toString()).catch(err => {
|
|
159
|
+
logger.error('Session labelling failed after human resolve', { error: (err as Error).message });
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Find unanswered contact messages in a session and trigger AI response.
|
|
165
|
+
* Used by grace period expiry and the "immediate response" release option.
|
|
166
|
+
*/
|
|
167
|
+
async replayPendingMessages(params: {
|
|
168
|
+
tenantId: string;
|
|
169
|
+
agentId: string;
|
|
170
|
+
session: IWhatsAppSession;
|
|
171
|
+
}): Promise<{ pendingCount: number }> {
|
|
172
|
+
const { tenantId, agentId, session } = params;
|
|
173
|
+
const sessionId = session._id.toString();
|
|
174
|
+
|
|
175
|
+
// Skip AI if any human has ever messaged in this chat
|
|
176
|
+
if (await this.hasHumanMessageInChat(session.chat_id)) {
|
|
177
|
+
logger.info('Skipping AI replay — human has previously messaged in this chat', { sessionId });
|
|
178
|
+
return { pendingCount: 0 };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Skip AI if outside shift hours
|
|
182
|
+
const tenant = await Tenant.findById(tenantId).lean();
|
|
183
|
+
if (tenant && !this.isWithinAiShift(tenant)) {
|
|
184
|
+
logger.info('Skipping AI replay — outside AI shift hours', { sessionId });
|
|
185
|
+
return { pendingCount: 0 };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Simple session-scoped query — no backwards scanning
|
|
189
|
+
const pendingMessages = await WhatsAppMessage.find({
|
|
190
|
+
session_id: session._id,
|
|
191
|
+
sender: 'contact',
|
|
192
|
+
}).sort({ createdAt: 1 }).lean();
|
|
193
|
+
|
|
194
|
+
if (pendingMessages.length === 0) {
|
|
195
|
+
logger.info('No pending messages to replay', { sessionId });
|
|
196
|
+
return { pendingCount: 0 };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const combinedText = pendingMessages.map(m => m.text).join('\n');
|
|
200
|
+
logger.info('Replaying pending messages for AI response', {
|
|
201
|
+
sessionId,
|
|
202
|
+
messageCount: pendingMessages.length,
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
const chat = await WhatsAppChat.findById(session.chat_id);
|
|
206
|
+
if (!chat) return { pendingCount: 0 };
|
|
207
|
+
|
|
208
|
+
await this.triggerAiResponse({
|
|
209
|
+
tenantId,
|
|
210
|
+
agentId,
|
|
211
|
+
chat,
|
|
212
|
+
session,
|
|
213
|
+
messageText: combinedText,
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
return { pendingCount: pendingMessages.length };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Called when the message buffer timer expires (20s after last contact message).
|
|
221
|
+
* Collects all unanswered contact messages and sends them combined to AI.
|
|
222
|
+
*/
|
|
223
|
+
private async onMessageBufferExpired(
|
|
224
|
+
sessionId: string,
|
|
225
|
+
tenantId: string,
|
|
226
|
+
agentId: string,
|
|
227
|
+
): Promise<void> {
|
|
228
|
+
this.messageBufferTimers.delete(sessionId);
|
|
229
|
+
|
|
230
|
+
const session = await WhatsAppSession.findById(sessionId);
|
|
231
|
+
if (!session || session.status !== 'active' || session.taken_over_by) {
|
|
232
|
+
logger.debug('Buffer expired but session not active for AI', { sessionId });
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const chat = await WhatsAppChat.findById(session.chat_id);
|
|
237
|
+
if (!chat) return;
|
|
238
|
+
|
|
239
|
+
// Skip AI if any human has ever messaged in this chat
|
|
240
|
+
if (await this.hasHumanMessageInChat(session.chat_id)) {
|
|
241
|
+
logger.info('Buffer expired but skipping AI — human has previously messaged', { sessionId });
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Skip AI if outside shift hours
|
|
246
|
+
const tenant = await Tenant.findById(session.tenant_id).lean();
|
|
247
|
+
if (tenant && !this.isWithinAiShift(tenant)) {
|
|
248
|
+
logger.info('Buffer expired but outside AI shift hours — skipping AI', { sessionId });
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Find the last AI response in this session
|
|
253
|
+
const lastAiMsg = await WhatsAppMessage.findOne({
|
|
254
|
+
session_id: session._id,
|
|
255
|
+
sender: 'human',
|
|
256
|
+
sender_name: 'AI',
|
|
257
|
+
}).sort({ createdAt: -1 }).lean();
|
|
258
|
+
|
|
259
|
+
// Get all contact messages after the last AI response (or all if no AI response yet)
|
|
260
|
+
const query: Record<string, unknown> = {
|
|
261
|
+
session_id: session._id,
|
|
262
|
+
sender: 'contact',
|
|
263
|
+
};
|
|
264
|
+
if (lastAiMsg) {
|
|
265
|
+
query.createdAt = { $gt: lastAiMsg.createdAt };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const pendingMessages = await WhatsAppMessage.find(query).sort({ createdAt: 1 }).lean();
|
|
269
|
+
if (pendingMessages.length === 0) {
|
|
270
|
+
logger.debug('Buffer expired but no pending messages', { sessionId });
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const combinedText = pendingMessages.map(m => m.text).join('\n');
|
|
275
|
+
logger.info('Message buffer expired, sending combined message to AI', {
|
|
276
|
+
sessionId,
|
|
277
|
+
messageCount: pendingMessages.length,
|
|
278
|
+
combinedLength: combinedText.length,
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
await this.triggerAiResponse({
|
|
282
|
+
tenantId,
|
|
283
|
+
agentId,
|
|
284
|
+
chat,
|
|
285
|
+
session,
|
|
286
|
+
messageText: combinedText,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private async onGracePeriodExpired(
|
|
291
|
+
sessionId: string,
|
|
292
|
+
agentId: string,
|
|
293
|
+
tenantId: string
|
|
294
|
+
): Promise<void> {
|
|
295
|
+
const session = await WhatsAppSession.findById(sessionId);
|
|
296
|
+
if (!session) return;
|
|
297
|
+
|
|
298
|
+
if (session.status !== 'waiting') {
|
|
299
|
+
logger.info('Grace period expired but session already resolved', {
|
|
300
|
+
sessionId,
|
|
301
|
+
status: session.status,
|
|
302
|
+
});
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (!agentId) {
|
|
307
|
+
session.status = 'resolved';
|
|
308
|
+
session.resolved_by = 'manual';
|
|
309
|
+
session.resolved_at = new Date();
|
|
310
|
+
await session.save();
|
|
311
|
+
await WhatsAppChat.updateOne(
|
|
312
|
+
{ _id: session.chat_id },
|
|
313
|
+
{ active_session_id: null }
|
|
314
|
+
);
|
|
315
|
+
logger.info('Grace period expired but no agent configured', { sessionId });
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Skip AI if any human has ever messaged in this chat
|
|
320
|
+
if (await this.hasHumanMessageInChat(session.chat_id)) {
|
|
321
|
+
session.status = 'resolved';
|
|
322
|
+
session.resolved_by = 'manual';
|
|
323
|
+
session.resolved_at = new Date();
|
|
324
|
+
await session.save();
|
|
325
|
+
await WhatsAppChat.updateOne(
|
|
326
|
+
{ _id: session.chat_id },
|
|
327
|
+
{ active_session_id: null }
|
|
328
|
+
);
|
|
329
|
+
logger.info('Grace period expired but skipping AI — human has previously messaged', { sessionId });
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Skip AI if outside shift hours
|
|
334
|
+
const tenant = await Tenant.findById(tenantId).lean();
|
|
335
|
+
if (tenant && !this.isWithinAiShift(tenant)) {
|
|
336
|
+
session.status = 'resolved';
|
|
337
|
+
session.resolved_by = 'manual';
|
|
338
|
+
session.resolved_at = new Date();
|
|
339
|
+
await session.save();
|
|
340
|
+
await WhatsAppChat.updateOne(
|
|
341
|
+
{ _id: session.chat_id },
|
|
342
|
+
{ active_session_id: null }
|
|
343
|
+
);
|
|
344
|
+
logger.info('Grace period expired but outside AI shift hours — resolved as manual', { sessionId });
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Transition to active (AI takes over)
|
|
349
|
+
session.status = 'active';
|
|
350
|
+
await session.save();
|
|
351
|
+
|
|
352
|
+
logger.info('Grace period expired, session now active (AI)', { sessionId, agentId });
|
|
353
|
+
|
|
354
|
+
await this.replayPendingMessages({ tenantId, agentId, session });
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
private async recoverSessions(): Promise<void> {
|
|
358
|
+
const waitingSessions = await WhatsAppSession.find({ status: 'waiting' }).lean();
|
|
359
|
+
const now = Date.now();
|
|
360
|
+
|
|
361
|
+
for (const session of waitingSessions) {
|
|
362
|
+
const deadline = session.grace_deadline
|
|
363
|
+
? new Date(session.grace_deadline).getTime()
|
|
364
|
+
: 0;
|
|
365
|
+
const remaining = deadline - now;
|
|
366
|
+
|
|
367
|
+
const tenant = await Tenant.findById(session.tenant_id).lean();
|
|
368
|
+
const agentId = tenant ? await resolveAgentId(tenant) : '';
|
|
369
|
+
const sessionId = session._id.toString();
|
|
370
|
+
|
|
371
|
+
if (remaining <= 0) {
|
|
372
|
+
logger.info('Recovering expired session', { sessionId });
|
|
373
|
+
await this.onGracePeriodExpired(sessionId, agentId, session.tenant_id.toString());
|
|
374
|
+
} else {
|
|
375
|
+
logger.info('Recovering active session', { sessionId, remainingMs: remaining });
|
|
376
|
+
const timer = setTimeout(async () => {
|
|
377
|
+
this.sessionTimers.delete(sessionId);
|
|
378
|
+
await this.onGracePeriodExpired(sessionId, agentId, session.tenant_id.toString());
|
|
379
|
+
}, remaining);
|
|
380
|
+
this.sessionTimers.set(sessionId, timer);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (waitingSessions.length > 0) {
|
|
385
|
+
logger.info('Session recovery complete', { recovered: waitingSessions.length });
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Safety net: find sessions stuck in 'waiting' past their deadline
|
|
391
|
+
* that no longer have an in-memory timer.
|
|
392
|
+
*/
|
|
393
|
+
private async sweepStuckSessions(): Promise<void> {
|
|
394
|
+
const stuckSessions = await WhatsAppSession.find({
|
|
395
|
+
status: 'waiting',
|
|
396
|
+
grace_deadline: { $lt: new Date() },
|
|
397
|
+
}).lean();
|
|
398
|
+
|
|
399
|
+
let swept = 0;
|
|
400
|
+
for (const session of stuckSessions) {
|
|
401
|
+
const sessionId = session._id.toString();
|
|
402
|
+
if (this.sessionTimers.has(sessionId)) continue;
|
|
403
|
+
|
|
404
|
+
swept++;
|
|
405
|
+
logger.warn('Sweeping stuck session', {
|
|
406
|
+
sessionId,
|
|
407
|
+
deadline: session.grace_deadline,
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
const tenant = await Tenant.findById(session.tenant_id).lean();
|
|
411
|
+
const agentId = tenant ? await resolveAgentId(tenant) : '';
|
|
412
|
+
|
|
413
|
+
await this.onGracePeriodExpired(sessionId, agentId, session.tenant_id.toString());
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (swept > 0) {
|
|
417
|
+
logger.info('Stuck session sweep complete', { swept, total: stuckSessions.length });
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Auto-resolve active sessions that have had no activity for SESSION_INACTIVITY_TIMEOUT_MS.
|
|
423
|
+
* Uses updatedAt as the activity indicator (updated on every message count increment).
|
|
424
|
+
*/
|
|
425
|
+
private async sweepInactiveSessions(): Promise<void> {
|
|
426
|
+
const cutoff = new Date(Date.now() - SESSION_INACTIVITY_TIMEOUT_MS);
|
|
427
|
+
|
|
428
|
+
const inactiveSessions = await WhatsAppSession.find({
|
|
429
|
+
status: 'active',
|
|
430
|
+
awaiting_operator: { $ne: true },
|
|
431
|
+
updatedAt: { $lt: cutoff },
|
|
432
|
+
}).lean();
|
|
433
|
+
|
|
434
|
+
let resolved = 0;
|
|
435
|
+
for (const session of inactiveSessions) {
|
|
436
|
+
const sessionId = session._id.toString();
|
|
437
|
+
|
|
438
|
+
// Close the WS connection if still open
|
|
439
|
+
const chat = await WhatsAppChat.findById(session.chat_id);
|
|
440
|
+
if (chat) {
|
|
441
|
+
this.closeConnection(chat.unipile_chat_id);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Resolve the session
|
|
445
|
+
await WhatsAppSession.updateOne(
|
|
446
|
+
{ _id: session._id },
|
|
447
|
+
{
|
|
448
|
+
status: 'resolved',
|
|
449
|
+
resolved_by: 'timeout',
|
|
450
|
+
resolved_at: new Date(),
|
|
451
|
+
},
|
|
452
|
+
);
|
|
453
|
+
|
|
454
|
+
// Unlink from chat
|
|
455
|
+
await WhatsAppChat.updateOne(
|
|
456
|
+
{ _id: session.chat_id, active_session_id: session._id },
|
|
457
|
+
{ active_session_id: null },
|
|
458
|
+
);
|
|
459
|
+
|
|
460
|
+
resolved++;
|
|
461
|
+
logger.info('Auto-resolved inactive session', {
|
|
462
|
+
sessionId,
|
|
463
|
+
chatId: session.chat_id,
|
|
464
|
+
lastActivity: session.updatedAt,
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
this.updateContactProfileSummary(sessionId).catch(err => {
|
|
468
|
+
logger.error('Profile summary update failed after inactive sweep', { error: (err as Error).message });
|
|
469
|
+
});
|
|
470
|
+
this.generateSessionLabels(sessionId).catch(err => {
|
|
471
|
+
logger.error('Session labelling failed after inactive sweep', { error: (err as Error).message });
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (resolved > 0) {
|
|
476
|
+
logger.info('Inactive session sweep complete', { resolved, total: inactiveSessions.length });
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Fetch ElevenLabs credits for resolved sessions that have a conversation ID but no cost yet.
|
|
482
|
+
*/
|
|
483
|
+
private async sweepConversationCosts(): Promise<void> {
|
|
484
|
+
const sessions = await WhatsAppSession.find({
|
|
485
|
+
elevenlabs_conversation_id: { $ne: null },
|
|
486
|
+
elevenlabs_cost: null,
|
|
487
|
+
status: 'resolved',
|
|
488
|
+
}).limit(20).lean();
|
|
489
|
+
|
|
490
|
+
let updated = 0;
|
|
491
|
+
for (const session of sessions) {
|
|
492
|
+
try {
|
|
493
|
+
const conv = await elevenlabsService.getConversation(session.elevenlabs_conversation_id!);
|
|
494
|
+
const convAny = conv as unknown as { metadata?: { cost?: number } };
|
|
495
|
+
const cost = convAny.metadata?.cost;
|
|
496
|
+
|
|
497
|
+
if (cost != null) {
|
|
498
|
+
await WhatsAppSession.updateOne(
|
|
499
|
+
{ _id: session._id },
|
|
500
|
+
{ elevenlabs_cost: cost },
|
|
501
|
+
);
|
|
502
|
+
updated++;
|
|
503
|
+
}
|
|
504
|
+
} catch (err) {
|
|
505
|
+
const status = (err as { statusCode?: number }).statusCode
|
|
506
|
+
|| (err as { status?: number }).status;
|
|
507
|
+
if (status === 404) {
|
|
508
|
+
await WhatsAppSession.updateOne(
|
|
509
|
+
{ _id: session._id },
|
|
510
|
+
{ elevenlabs_cost: 0 },
|
|
511
|
+
);
|
|
512
|
+
logger.warn('Conversation not found on ElevenLabs, set cost to 0', {
|
|
513
|
+
sessionId: session._id,
|
|
514
|
+
conversationId: session.elevenlabs_conversation_id,
|
|
515
|
+
});
|
|
516
|
+
} else {
|
|
517
|
+
logger.warn('Failed to fetch conversation cost', {
|
|
518
|
+
sessionId: session._id,
|
|
519
|
+
conversationId: session.elevenlabs_conversation_id,
|
|
520
|
+
error: (err as Error).message,
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
if (updated > 0) {
|
|
527
|
+
logger.info('Conversation cost sweep complete', { updated, checked: sessions.length });
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// ─── AI Condition Checks ─────────────────────────────────
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Check if any real human (not AI bot) has ever sent a message in this chat.
|
|
535
|
+
* Used to permanently skip AI for chats where a human has already engaged.
|
|
536
|
+
*/
|
|
537
|
+
private async hasHumanMessageInChat(chatId: string | { toString(): string }): Promise<boolean> {
|
|
538
|
+
const msg = await WhatsAppMessage.findOne({
|
|
539
|
+
chat_id: chatId,
|
|
540
|
+
sender: 'human',
|
|
541
|
+
sender_name: { $ne: 'AI' },
|
|
542
|
+
}).select('_id').lean();
|
|
543
|
+
return !!msg;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Check whether the AI agent should be active right now.
|
|
548
|
+
* The schedule represents **clinic working hours** (when humans handle messages).
|
|
549
|
+
* The AI is active OUTSIDE those hours.
|
|
550
|
+
* If shift is not enabled, returns true (AI works 24/7).
|
|
551
|
+
* Fixed timezone: Europe/Istanbul.
|
|
552
|
+
*/
|
|
553
|
+
private isWithinAiShift(tenant: ITenant | { settings?: ITenant['settings'] }): boolean {
|
|
554
|
+
const shift = tenant.settings?.whatsapp_agent;
|
|
555
|
+
if (!shift?.ai_shift_enabled) return true;
|
|
556
|
+
|
|
557
|
+
const now = new Date();
|
|
558
|
+
|
|
559
|
+
// Get current hour, minute and day in Europe/Istanbul
|
|
560
|
+
const parts = new Intl.DateTimeFormat('en-US', {
|
|
561
|
+
timeZone: 'Europe/Istanbul',
|
|
562
|
+
hour: '2-digit',
|
|
563
|
+
minute: '2-digit',
|
|
564
|
+
hour12: false,
|
|
565
|
+
}).formatToParts(now);
|
|
566
|
+
|
|
567
|
+
const hour = parseInt(parts.find(p => p.type === 'hour')?.value || '0', 10);
|
|
568
|
+
const minute = parseInt(parts.find(p => p.type === 'minute')?.value || '0', 10);
|
|
569
|
+
|
|
570
|
+
const dayName = new Intl.DateTimeFormat('en-US', {
|
|
571
|
+
timeZone: 'Europe/Istanbul',
|
|
572
|
+
weekday: 'long',
|
|
573
|
+
}).format(now);
|
|
574
|
+
|
|
575
|
+
const dayMap: Record<string, number> = {
|
|
576
|
+
Sunday: 0, Monday: 1, Tuesday: 2, Wednesday: 3,
|
|
577
|
+
Thursday: 4, Friday: 5, Saturday: 6,
|
|
578
|
+
};
|
|
579
|
+
const currentDay = dayMap[dayName] ?? now.getDay();
|
|
580
|
+
|
|
581
|
+
// Per-day schedule with legacy fallback
|
|
582
|
+
// Schedule entries = clinic working hours (AI should be OFF during these)
|
|
583
|
+
const schedule = shift.ai_shift_schedule?.length
|
|
584
|
+
? shift.ai_shift_schedule
|
|
585
|
+
: (shift.ai_shift_days || []).map(day => ({
|
|
586
|
+
day, start: shift.ai_shift_start || '09:00', end: shift.ai_shift_end || '18:00',
|
|
587
|
+
}));
|
|
588
|
+
|
|
589
|
+
// No clinic hours defined for today → AI active all day
|
|
590
|
+
const entry = schedule.find(e => e.day === currentDay);
|
|
591
|
+
if (!entry) return true;
|
|
592
|
+
|
|
593
|
+
// Check if current time is within clinic hours
|
|
594
|
+
const currentMinutes = hour * 60 + minute;
|
|
595
|
+
const [startH, startM] = (entry.start || '09:00').split(':').map(Number);
|
|
596
|
+
const [endH, endM] = (entry.end || '18:00').split(':').map(Number);
|
|
597
|
+
const startMinutes = startH * 60 + startM;
|
|
598
|
+
const endMinutes = endH * 60 + endM;
|
|
599
|
+
|
|
600
|
+
let withinClinicHours: boolean;
|
|
601
|
+
if (startMinutes <= endMinutes) {
|
|
602
|
+
withinClinicHours = currentMinutes >= startMinutes && currentMinutes < endMinutes;
|
|
603
|
+
} else {
|
|
604
|
+
// Overnight clinic hours (e.g. 22:00-06:00)
|
|
605
|
+
withinClinicHours = currentMinutes >= startMinutes || currentMinutes < endMinutes;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// AI is active when clinic is CLOSED
|
|
609
|
+
return !withinClinicHours;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// ─── AI Response ──────────────────────────────────────────
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Build conversation context from current session messages only.
|
|
616
|
+
* Contact profile info is now sent via dynamic_variables at connection time.
|
|
617
|
+
*/
|
|
618
|
+
private async buildChatContext(
|
|
619
|
+
chatId: string,
|
|
620
|
+
currentSessionId: string,
|
|
621
|
+
): Promise<string | null> {
|
|
622
|
+
const messages = await WhatsAppMessage.find({ session_id: currentSessionId })
|
|
623
|
+
.sort({ createdAt: 1 })
|
|
624
|
+
.lean();
|
|
625
|
+
|
|
626
|
+
if (messages.length === 0) return null;
|
|
627
|
+
|
|
628
|
+
// Also fetch operator requests for this session to weave into timeline
|
|
629
|
+
const operatorRequests = await OperatorRequest.find({ session_id: currentSessionId })
|
|
630
|
+
.sort({ createdAt: 1 })
|
|
631
|
+
.lean();
|
|
632
|
+
|
|
633
|
+
// Build timeline entries: messages + operator request events
|
|
634
|
+
interface TimelineEntry { at: Date; text: string; }
|
|
635
|
+
const timeline: TimelineEntry[] = [];
|
|
636
|
+
|
|
637
|
+
for (const m of messages) {
|
|
638
|
+
const role = m.sender === 'contact' ? 'Patient' : 'Operator';
|
|
639
|
+
timeline.push({ at: m.createdAt, text: `${role}: ${m.text}` });
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
for (const req of operatorRequests) {
|
|
643
|
+
const mediaCount = req.forwarded_count || 1;
|
|
644
|
+
const failInfo = req.failed_count ? ` (${req.failed_count} iletilemedi)` : '';
|
|
645
|
+
timeline.push({
|
|
646
|
+
at: req.forwarded_at,
|
|
647
|
+
text: `[SİSTEM: Hastanın ${mediaCount} medyası doktora iletildi — Ref: ${req.ref_code}${failInfo}]`,
|
|
648
|
+
});
|
|
649
|
+
if (req.status === 'responded' && req.operator_response) {
|
|
650
|
+
timeline.push({
|
|
651
|
+
at: req.responded_at || req.updatedAt,
|
|
652
|
+
text: `[SİSTEM: Doktor yanıt verdi — Ref: ${req.ref_code}: "${req.operator_response}"]`,
|
|
653
|
+
});
|
|
654
|
+
} else if (req.status === 'expired') {
|
|
655
|
+
timeline.push({
|
|
656
|
+
at: req.updatedAt,
|
|
657
|
+
text: `[SİSTEM: Doktordan yanıt alınamadı — Ref: ${req.ref_code} süresi doldu]`,
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// Sort by timestamp
|
|
663
|
+
timeline.sort((a, b) => new Date(a.at).getTime() - new Date(b.at).getTime());
|
|
664
|
+
|
|
665
|
+
const lines = timeline.map(e => e.text);
|
|
666
|
+
|
|
667
|
+
// Check first contact message for ad context (patient clicked an ad to start the chat)
|
|
668
|
+
const firstContactMsg = messages.find(m => m.sender === 'contact');
|
|
669
|
+
const adCtx = firstContactMsg?.ad_context as { ad?: { title?: string; body?: string; source_url?: string }; source?: string; app?: string } | null;
|
|
670
|
+
if (adCtx) {
|
|
671
|
+
const adParts: string[] = [];
|
|
672
|
+
if (adCtx.ad?.title) adParts.push(`Reklam başlığı: ${adCtx.ad.title}`);
|
|
673
|
+
if (adCtx.ad?.body) adParts.push(`Reklam içeriği: ${adCtx.ad.body}`);
|
|
674
|
+
if (adCtx.source) adParts.push(`Kaynak: ${adCtx.source}`);
|
|
675
|
+
if (adCtx.app) adParts.push(`Platform: ${adCtx.app}`);
|
|
676
|
+
if (adCtx.ad?.source_url) adParts.push(`Reklam URL: ${adCtx.ad.source_url}`);
|
|
677
|
+
if (adParts.length > 0) {
|
|
678
|
+
lines.unshift(`[SİSTEM: Hasta şu reklama tıklayarak WhatsApp'a geldi]\n${adParts.join('\n')}`);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
return `Current conversation:\n${lines.join('\n')}`;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
private async sendChatContext(
|
|
686
|
+
conn: ActiveConnection,
|
|
687
|
+
chatId: string,
|
|
688
|
+
sessionId: string,
|
|
689
|
+
): Promise<void> {
|
|
690
|
+
const context = await this.buildChatContext(chatId, sessionId);
|
|
691
|
+
if (!context) return;
|
|
692
|
+
|
|
693
|
+
conn.ws.send(JSON.stringify({
|
|
694
|
+
type: 'contextual_update',
|
|
695
|
+
text: context,
|
|
696
|
+
}));
|
|
697
|
+
|
|
698
|
+
logger.info('Sent chat history context to ElevenLabs', {
|
|
699
|
+
sessionId,
|
|
700
|
+
chatId,
|
|
701
|
+
contextLength: context.length,
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
private async triggerAiResponse(params: {
|
|
706
|
+
tenantId: string;
|
|
707
|
+
agentId: string;
|
|
708
|
+
chat: IWhatsAppChat;
|
|
709
|
+
session: IWhatsAppSession;
|
|
710
|
+
messageText: string;
|
|
711
|
+
}): Promise<void> {
|
|
712
|
+
const { agentId, chat, session, messageText } = params;
|
|
713
|
+
const unipileChatId = chat.unipile_chat_id;
|
|
714
|
+
const sessionId = session._id.toString();
|
|
715
|
+
|
|
716
|
+
let conn = this.connections.get(unipileChatId);
|
|
717
|
+
const isAlive = conn && conn.ws.readyState === WebSocket.OPEN && conn.ready;
|
|
718
|
+
let isNewConnection = false;
|
|
719
|
+
let isReconnect = false;
|
|
720
|
+
|
|
721
|
+
if (!isAlive) {
|
|
722
|
+
if (conn) {
|
|
723
|
+
if (conn.pingTimer) { clearInterval(conn.pingTimer); conn.pingTimer = null; }
|
|
724
|
+
conn.intentionallyClosed = true;
|
|
725
|
+
try { conn.ws.close(); } catch { /* ignore */ }
|
|
726
|
+
this.connections.delete(unipileChatId);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// Detect reconnection: session already had an ElevenLabs conversation
|
|
730
|
+
isReconnect = !!session.elevenlabs_conversation_id;
|
|
731
|
+
|
|
732
|
+
try {
|
|
733
|
+
conn = await this.createConnection(agentId, chat, session, { isReconnect });
|
|
734
|
+
this.connections.set(unipileChatId, conn);
|
|
735
|
+
isNewConnection = true;
|
|
736
|
+
} catch (err) {
|
|
737
|
+
logger.error('Failed to create EL connection', {
|
|
738
|
+
error: (err as Error).message,
|
|
739
|
+
unipileChatId,
|
|
740
|
+
sessionId,
|
|
741
|
+
});
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
conn!.lastActivity = Date.now();
|
|
747
|
+
await WhatsAppSession.updateOne(
|
|
748
|
+
{ _id: session._id },
|
|
749
|
+
{ elevenlabs_last_interaction_at: new Date() }
|
|
750
|
+
);
|
|
751
|
+
|
|
752
|
+
// Send chat context via contextual_update for new connections
|
|
753
|
+
// (On reconnection with continuation enabled, this is redundant but harmless as backup)
|
|
754
|
+
if (isNewConnection) {
|
|
755
|
+
await this.sendChatContext(conn!, chat._id.toString(), sessionId);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
conn!.ws.send(JSON.stringify({
|
|
759
|
+
type: 'user_message',
|
|
760
|
+
text: messageText,
|
|
761
|
+
}));
|
|
762
|
+
|
|
763
|
+
logger.info('AI triggered', { sessionId, text: messageText.substring(0, 50) });
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
async handleIncomingMessage(params: {
|
|
767
|
+
tenantId: string;
|
|
768
|
+
agentId: string;
|
|
769
|
+
chat: IWhatsAppChat;
|
|
770
|
+
session: IWhatsAppSession;
|
|
771
|
+
messageText: string;
|
|
772
|
+
senderName: string;
|
|
773
|
+
unipileMessageId?: string;
|
|
774
|
+
contentType?: string;
|
|
775
|
+
}): Promise<void> {
|
|
776
|
+
const { tenantId, agentId, chat, session, messageText, senderName, unipileMessageId, contentType } = params;
|
|
777
|
+
const unipileChatId = chat.unipile_chat_id;
|
|
778
|
+
const sessionId = session._id.toString();
|
|
779
|
+
|
|
780
|
+
// Store the incoming message with session_id
|
|
781
|
+
await WhatsAppMessage.create({
|
|
782
|
+
chat_id: chat._id,
|
|
783
|
+
session_id: session._id,
|
|
784
|
+
tenant_id: tenantId,
|
|
785
|
+
unipile_message_id: unipileMessageId || null,
|
|
786
|
+
sender: 'contact',
|
|
787
|
+
sender_name: senderName,
|
|
788
|
+
text: messageText,
|
|
789
|
+
media_type: contentType || null,
|
|
790
|
+
sent_via_unipile: false,
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
// Update chat + session metadata
|
|
794
|
+
chat.last_message_at = new Date();
|
|
795
|
+
chat.last_message_preview = messageText.substring(0, 100);
|
|
796
|
+
chat.message_count += 1;
|
|
797
|
+
await chat.save();
|
|
798
|
+
|
|
799
|
+
session.message_count += 1;
|
|
800
|
+
await session.save();
|
|
801
|
+
|
|
802
|
+
// Skip AI if session is human-taken-over or no agent
|
|
803
|
+
if (session.taken_over_by || !agentId) {
|
|
804
|
+
logger.info('Skipping AI response', {
|
|
805
|
+
sessionId,
|
|
806
|
+
hasTakeover: !!session.taken_over_by,
|
|
807
|
+
hasAgent: !!agentId,
|
|
808
|
+
});
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// Skip AI if any human has ever messaged in this chat
|
|
813
|
+
if (await this.hasHumanMessageInChat(chat._id)) {
|
|
814
|
+
logger.info('Skipping AI — human has previously messaged in this chat', {
|
|
815
|
+
sessionId,
|
|
816
|
+
chatId: chat._id.toString(),
|
|
817
|
+
});
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// Skip AI if outside shift hours
|
|
822
|
+
const tenant = await Tenant.findById(tenantId).lean();
|
|
823
|
+
if (tenant && !this.isWithinAiShift(tenant)) {
|
|
824
|
+
logger.info('Skipping AI — outside AI shift hours', { sessionId });
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// Debounce: reset the message buffer timer so the AI waits for the
|
|
829
|
+
// contact to finish typing before responding to all messages at once.
|
|
830
|
+
this.cancelMessageBuffer(sessionId);
|
|
831
|
+
|
|
832
|
+
const bufferMs = Math.floor(Math.random() * (MESSAGE_BUFFER_MAX_MS - MESSAGE_BUFFER_MIN_MS + 1)) + MESSAGE_BUFFER_MIN_MS;
|
|
833
|
+
|
|
834
|
+
const bufferTimer = setTimeout(() => {
|
|
835
|
+
this.onMessageBufferExpired(sessionId, tenantId, agentId).catch(err => {
|
|
836
|
+
logger.error('Message buffer handler failed', {
|
|
837
|
+
error: (err as Error).message,
|
|
838
|
+
sessionId,
|
|
839
|
+
});
|
|
840
|
+
});
|
|
841
|
+
}, bufferMs);
|
|
842
|
+
|
|
843
|
+
this.messageBufferTimers.set(sessionId, bufferTimer);
|
|
844
|
+
|
|
845
|
+
logger.info('Message buffered, AI will respond after pause', {
|
|
846
|
+
sessionId,
|
|
847
|
+
bufferMs,
|
|
848
|
+
text: messageText.substring(0, 50),
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// ─── ElevenLabs Connection ────────────────────────────────
|
|
853
|
+
|
|
854
|
+
private async buildUserSummary(contactProfileId?: string | null): Promise<{ summary: string; hasMeaningfulContext: boolean }> {
|
|
855
|
+
if (!contactProfileId) return { summary: '', hasMeaningfulContext: false };
|
|
856
|
+
|
|
857
|
+
const profile = await WhatsAppContactProfile.findById(contactProfileId).lean();
|
|
858
|
+
if (!profile) return { summary: '', hasMeaningfulContext: false };
|
|
859
|
+
|
|
860
|
+
// Meaningful context = data beyond just a name (ai_summary, notes, tags, preferred_language)
|
|
861
|
+
const hasMeaningfulContext = !!(profile.ai_summary || profile.notes || (profile.tags && profile.tags.length > 0) || profile.preferred_language);
|
|
862
|
+
|
|
863
|
+
// No meaningful context → return empty so the fallback default is used
|
|
864
|
+
if (!hasMeaningfulContext) return { summary: '', hasMeaningfulContext: false };
|
|
865
|
+
|
|
866
|
+
const parts: string[] = [];
|
|
867
|
+
if (profile.display_name && profile.display_name !== 'Bilinmiyor') {
|
|
868
|
+
parts.push(`Ad: ${profile.display_name}`);
|
|
869
|
+
}
|
|
870
|
+
if (profile.preferred_language) {
|
|
871
|
+
parts.push(`Tercih edilen dil: ${profile.preferred_language}`);
|
|
872
|
+
}
|
|
873
|
+
if (profile.notes) {
|
|
874
|
+
parts.push(`Notlar: ${profile.notes.substring(0, 500)}`);
|
|
875
|
+
}
|
|
876
|
+
if (profile.ai_summary) {
|
|
877
|
+
parts.push(`Gecmis ozeti: ${profile.ai_summary}`);
|
|
878
|
+
}
|
|
879
|
+
if (profile.tags.length > 0) {
|
|
880
|
+
parts.push(`Etiketler: ${profile.tags.join(', ')}`);
|
|
881
|
+
}
|
|
882
|
+
return { summary: parts.join('\n'), hasMeaningfulContext };
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
private async createConnection(
|
|
886
|
+
agentId: string,
|
|
887
|
+
chat: IWhatsAppChat,
|
|
888
|
+
session: IWhatsAppSession,
|
|
889
|
+
options?: { isReconnect?: boolean }
|
|
890
|
+
): Promise<ActiveConnection> {
|
|
891
|
+
const sessionId = session._id.toString();
|
|
892
|
+
let isReconnect = options?.isReconnect || false;
|
|
893
|
+
logger.info('Creating new EL conversation', { agentId, sessionId, isReconnect });
|
|
894
|
+
|
|
895
|
+
// Build user summary from contact profile BEFORE connecting
|
|
896
|
+
const { summary: profileSummary } = await this.buildUserSummary(
|
|
897
|
+
chat.contact_profile_id?.toString() || null
|
|
898
|
+
);
|
|
899
|
+
const userSummary = profileSummary || 'Ilk defa iletisime gecen hasta. Hakkinda bilgi bulunmamaktadir.';
|
|
900
|
+
|
|
901
|
+
// On reconnection + tenant has chat_continuity_enabled, build continuation directive
|
|
902
|
+
let continuationDirective = '';
|
|
903
|
+
if (isReconnect) {
|
|
904
|
+
const tenant = await Tenant.findById(session.tenant_id).lean();
|
|
905
|
+
if (!tenant?.enabled_features?.includes('chat_continuity')) {
|
|
906
|
+
logger.info('Prompt continuation disabled for tenant, skipping directive', { sessionId });
|
|
907
|
+
isReconnect = false;
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
if (isReconnect) {
|
|
911
|
+
const chatHistory = await this.buildChatContext(chat._id.toString(), sessionId);
|
|
912
|
+
const lastAiMsg = await WhatsAppMessage.findOne({
|
|
913
|
+
session_id: session._id,
|
|
914
|
+
sender: 'human',
|
|
915
|
+
sender_name: 'AI',
|
|
916
|
+
}).sort({ createdAt: -1 }).lean();
|
|
917
|
+
|
|
918
|
+
const parts = [
|
|
919
|
+
'ÖNEMLİ — KONUŞMA DEVAMI:',
|
|
920
|
+
'Bu hasta ile önceden başlamış bir konuşma var. Teknik bir bağlantı yenilenmesi yaşandı.',
|
|
921
|
+
'Kendini TANITMA. Selam VERME. Hoşgeldin DEME. Konuşmaya kaldığın yerden DEVAM ET.',
|
|
922
|
+
lastAiMsg ? `En son gönderdiğin mesaj: "${lastAiMsg.text.substring(0, 300)}"` : '',
|
|
923
|
+
'',
|
|
924
|
+
chatHistory || '',
|
|
925
|
+
].filter(Boolean).join('\n');
|
|
926
|
+
|
|
927
|
+
continuationDirective = parts;
|
|
928
|
+
logger.info('Built continuation directive for reconnection', {
|
|
929
|
+
sessionId,
|
|
930
|
+
directiveLength: continuationDirective.length,
|
|
931
|
+
});
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
const result = await elevenlabsService.getSignedUrl({ agentId });
|
|
935
|
+
const signedUrl = (result as { signedUrl?: string }).signedUrl
|
|
936
|
+
|| (result as { signed_url?: string }).signed_url
|
|
937
|
+
|| String(result);
|
|
938
|
+
|
|
939
|
+
const separator = signedUrl.includes('?') ? '&' : '?';
|
|
940
|
+
const wsUrl = `${signedUrl}${separator}textOnly=true`;
|
|
941
|
+
|
|
942
|
+
const ws = new WebSocket(wsUrl);
|
|
943
|
+
const unipileChatId = chat.unipile_chat_id;
|
|
944
|
+
|
|
945
|
+
const conn: ActiveConnection = {
|
|
946
|
+
ws,
|
|
947
|
+
chatId: chat._id.toString(),
|
|
948
|
+
sessionId,
|
|
949
|
+
unipileChatId,
|
|
950
|
+
agentId,
|
|
951
|
+
lastActivity: Date.now(),
|
|
952
|
+
conversationId: null,
|
|
953
|
+
ready: false,
|
|
954
|
+
intentionallyClosed: false,
|
|
955
|
+
pingTimer: null,
|
|
956
|
+
};
|
|
957
|
+
|
|
958
|
+
// Wait for WS to open AND receive conversation_initiation_metadata
|
|
959
|
+
await new Promise<void>((resolve, reject) => {
|
|
960
|
+
const timeout = setTimeout(() => {
|
|
961
|
+
ws.close();
|
|
962
|
+
reject(new Error('ElevenLabs WS initialization timeout (15s)'));
|
|
963
|
+
}, 15_000);
|
|
964
|
+
|
|
965
|
+
ws.on('open', () => {
|
|
966
|
+
logger.info('ElevenLabs WS TCP connected, sending init data...', { unipileChatId, sessionId });
|
|
967
|
+
ws.send(JSON.stringify({
|
|
968
|
+
type: 'conversation_initiation_client_data',
|
|
969
|
+
conversation_config_override: {
|
|
970
|
+
agent: { first_message: '' },
|
|
971
|
+
},
|
|
972
|
+
dynamic_variables: {
|
|
973
|
+
user_summary: userSummary,
|
|
974
|
+
current_date: new Date().toISOString().split('T')[0],
|
|
975
|
+
continuation_directive: continuationDirective,
|
|
976
|
+
},
|
|
977
|
+
}));
|
|
978
|
+
logger.info('Sent conversation_initiation_client_data', {
|
|
979
|
+
sessionId,
|
|
980
|
+
userSummaryLength: userSummary.length,
|
|
981
|
+
isReconnect,
|
|
982
|
+
continuationDirectiveLength: continuationDirective.length,
|
|
983
|
+
});
|
|
984
|
+
});
|
|
985
|
+
|
|
986
|
+
ws.on('error', (err) => {
|
|
987
|
+
clearTimeout(timeout);
|
|
988
|
+
reject(err);
|
|
989
|
+
});
|
|
990
|
+
|
|
991
|
+
ws.on('message', (data: Buffer) => {
|
|
992
|
+
try {
|
|
993
|
+
const msg = JSON.parse(data.toString());
|
|
994
|
+
if (msg.type === 'conversation_initiation_metadata') {
|
|
995
|
+
const convId = msg.conversation_initiation_metadata_event?.conversation_id
|
|
996
|
+
|| msg.conversation_id;
|
|
997
|
+
if (convId) {
|
|
998
|
+
conn.conversationId = convId;
|
|
999
|
+
const updateOp: Record<string, unknown> = {
|
|
1000
|
+
$set: {
|
|
1001
|
+
elevenlabs_conversation_id: convId,
|
|
1002
|
+
elevenlabs_conversation_created_at: new Date(),
|
|
1003
|
+
elevenlabs_last_interaction_at: new Date(),
|
|
1004
|
+
},
|
|
1005
|
+
};
|
|
1006
|
+
// Preserve previous conversation ID if this is a reconnection
|
|
1007
|
+
if (session.elevenlabs_conversation_id && session.elevenlabs_conversation_id !== convId) {
|
|
1008
|
+
updateOp.$push = { elevenlabs_previous_conversation_ids: session.elevenlabs_conversation_id };
|
|
1009
|
+
}
|
|
1010
|
+
WhatsAppSession.updateOne({ _id: session._id }, updateOp).catch(() => {});
|
|
1011
|
+
}
|
|
1012
|
+
conn.ready = true;
|
|
1013
|
+
clearTimeout(timeout);
|
|
1014
|
+
logger.info('ElevenLabs conversation ready', {
|
|
1015
|
+
sessionId,
|
|
1016
|
+
conversationId: convId,
|
|
1017
|
+
});
|
|
1018
|
+
resolve();
|
|
1019
|
+
}
|
|
1020
|
+
} catch (err) {
|
|
1021
|
+
logger.error('Error parsing EL WS init message', { error: (err as Error).message });
|
|
1022
|
+
}
|
|
1023
|
+
});
|
|
1024
|
+
});
|
|
1025
|
+
|
|
1026
|
+
// Start keepalive: TCP ping + EL user_activity to reset inactivity timer
|
|
1027
|
+
conn.pingTimer = setInterval(() => {
|
|
1028
|
+
if (conn.ws.readyState === WebSocket.OPEN) {
|
|
1029
|
+
conn.ws.ping();
|
|
1030
|
+
conn.ws.send(JSON.stringify({ type: 'user_activity' }));
|
|
1031
|
+
}
|
|
1032
|
+
}, WS_PING_INTERVAL_MS);
|
|
1033
|
+
|
|
1034
|
+
// Persistent message handler for agent responses
|
|
1035
|
+
ws.on('message', async (data: Buffer) => {
|
|
1036
|
+
try {
|
|
1037
|
+
const msg = JSON.parse(data.toString());
|
|
1038
|
+
if (msg.type === 'conversation_initiation_metadata') return;
|
|
1039
|
+
|
|
1040
|
+
// Respond to EL ping with pong
|
|
1041
|
+
if (msg.type === 'ping') {
|
|
1042
|
+
const eventId = msg.ping_event?.event_id;
|
|
1043
|
+
if (eventId != null) {
|
|
1044
|
+
conn.ws.send(JSON.stringify({ type: 'pong', event_id: eventId }));
|
|
1045
|
+
}
|
|
1046
|
+
return;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
logger.info('EL WS event', { type: msg.type, sessionId, raw: JSON.stringify(msg).substring(0, 400) });
|
|
1050
|
+
|
|
1051
|
+
if (msg.type === 'client_tool_call') {
|
|
1052
|
+
const { tool_call_id, tool_name, parameters } = msg.client_tool_call;
|
|
1053
|
+
this.handleClientToolCall(conn, tool_call_id, tool_name, parameters)
|
|
1054
|
+
.catch(err => logger.error('Client tool call failed', {
|
|
1055
|
+
tool_name,
|
|
1056
|
+
sessionId,
|
|
1057
|
+
error: (err as Error).message,
|
|
1058
|
+
}));
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
if (msg.type === 'agent_response') {
|
|
1063
|
+
const responseText =
|
|
1064
|
+
msg.agent_response_event?.agent_response
|
|
1065
|
+
|| msg.agent_response;
|
|
1066
|
+
if (responseText) {
|
|
1067
|
+
await WhatsAppSession.updateOne(
|
|
1068
|
+
{ _id: session._id },
|
|
1069
|
+
{ elevenlabs_last_interaction_at: new Date() }
|
|
1070
|
+
);
|
|
1071
|
+
await this.sendResponse(chat, session, responseText);
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
} catch (err) {
|
|
1075
|
+
logger.error('Error handling EL WS message', {
|
|
1076
|
+
error: (err as Error).message,
|
|
1077
|
+
sessionId,
|
|
1078
|
+
});
|
|
1079
|
+
}
|
|
1080
|
+
});
|
|
1081
|
+
|
|
1082
|
+
ws.on('close', (code, reason) => {
|
|
1083
|
+
if (conn.pingTimer) {
|
|
1084
|
+
clearInterval(conn.pingTimer);
|
|
1085
|
+
conn.pingTimer = null;
|
|
1086
|
+
}
|
|
1087
|
+
logger.info('ElevenLabs WS closed', { unipileChatId, sessionId, code, reason: reason?.toString() });
|
|
1088
|
+
this.connections.delete(unipileChatId);
|
|
1089
|
+
// No eager reconnect — triggerAiResponse handles lazy reconnect on next message
|
|
1090
|
+
});
|
|
1091
|
+
|
|
1092
|
+
ws.on('error', (err) => {
|
|
1093
|
+
logger.error('ElevenLabs WS error', { error: err.message, sessionId });
|
|
1094
|
+
});
|
|
1095
|
+
|
|
1096
|
+
return conn;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
private async sendResponse(
|
|
1100
|
+
chat: IWhatsAppChat,
|
|
1101
|
+
session: IWhatsAppSession,
|
|
1102
|
+
text: string
|
|
1103
|
+
): Promise<void> {
|
|
1104
|
+
try {
|
|
1105
|
+
const tenant = await Tenant.findById(chat.tenant_id).lean();
|
|
1106
|
+
const provider = unipileService.getProviderForTenant(tenant);
|
|
1107
|
+
const result = await unipileService.sendMessage(provider, chat.unipile_chat_id, text);
|
|
1108
|
+
logger.info('AI response sent via bridge', { provider, sessionId: session._id, text: text.substring(0, 50) });
|
|
1109
|
+
|
|
1110
|
+
await WhatsAppMessage.create({
|
|
1111
|
+
chat_id: chat._id,
|
|
1112
|
+
session_id: session._id,
|
|
1113
|
+
tenant_id: chat.tenant_id,
|
|
1114
|
+
unipile_message_id: result.message_id || null,
|
|
1115
|
+
sender: 'human',
|
|
1116
|
+
sender_name: 'AI',
|
|
1117
|
+
text,
|
|
1118
|
+
sent_via_unipile: true,
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
await WhatsAppChat.updateOne(
|
|
1122
|
+
{ _id: chat._id },
|
|
1123
|
+
{
|
|
1124
|
+
$inc: { message_count: 1 },
|
|
1125
|
+
$set: { last_message_at: new Date(), last_message_preview: text.substring(0, 100) },
|
|
1126
|
+
},
|
|
1127
|
+
);
|
|
1128
|
+
|
|
1129
|
+
await WhatsAppSession.updateOne(
|
|
1130
|
+
{ _id: session._id },
|
|
1131
|
+
{ $inc: { message_count: 1 } },
|
|
1132
|
+
);
|
|
1133
|
+
} catch (err) {
|
|
1134
|
+
logger.error('Failed to send AI response via bridge', {
|
|
1135
|
+
sessionId: session._id,
|
|
1136
|
+
error: (err as Error).message,
|
|
1137
|
+
});
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
// ─── Contact Profile Update ──────────────────────────────
|
|
1142
|
+
|
|
1143
|
+
/**
|
|
1144
|
+
* After a session resolves, update the contact profile's ai_summary
|
|
1145
|
+
* by sending the session transcript + previous summary to a dedicated
|
|
1146
|
+
* ElevenLabs agent via simulateConversation REST API.
|
|
1147
|
+
* Fire-and-forget — errors are logged but never block session resolution.
|
|
1148
|
+
*/
|
|
1149
|
+
async updateContactProfileSummary(sessionId: string): Promise<void> {
|
|
1150
|
+
const session = await WhatsAppSession.findById(sessionId).lean();
|
|
1151
|
+
if (!session || session.message_count < 2) {
|
|
1152
|
+
throw new Error('Session not found or has fewer than 2 messages');
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
const chat = await WhatsAppChat.findById(session.chat_id).lean();
|
|
1156
|
+
if (!chat?.contact_profile_id) {
|
|
1157
|
+
throw new Error('Chat has no linked contact profile');
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
const profile = await WhatsAppContactProfile.findById(chat.contact_profile_id).lean();
|
|
1161
|
+
if (!profile) {
|
|
1162
|
+
throw new Error('Contact profile not found');
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
// Build the transcript from session messages
|
|
1166
|
+
const messages = await WhatsAppMessage.find({ session_id: session._id })
|
|
1167
|
+
.sort({ createdAt: 1 })
|
|
1168
|
+
.lean();
|
|
1169
|
+
|
|
1170
|
+
if (messages.length < 2) {
|
|
1171
|
+
throw new Error('Session has fewer than 2 messages');
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
const transcript = messages
|
|
1175
|
+
.map(m => {
|
|
1176
|
+
const role = m.sender === 'contact' ? 'HASTA' : 'ASISTAN';
|
|
1177
|
+
const ts = new Date(m.createdAt).toLocaleString('tr-TR', { timeZone: 'Europe/Istanbul' });
|
|
1178
|
+
return `[${ts}] [${role}]: ${m.text}`;
|
|
1179
|
+
})
|
|
1180
|
+
.join('\n');
|
|
1181
|
+
|
|
1182
|
+
const now = new Date().toLocaleString('tr-TR', { timeZone: 'Europe/Istanbul' });
|
|
1183
|
+
const previousSummary = profile.ai_summary || 'Henüz profil özeti yok.';
|
|
1184
|
+
|
|
1185
|
+
const systemMessage = [
|
|
1186
|
+
'Sen bir hasta profil özeti oluşturma asistanısın.',
|
|
1187
|
+
'Sana mevcut hasta profil özeti ve bir WhatsApp konuşma geçmişi verilecek.',
|
|
1188
|
+
'',
|
|
1189
|
+
'KURALLAR:',
|
|
1190
|
+
'- [HASTA] etiketli satırlar hastanın yazdıklarıdır — SADECE buradan bilgi çıkar.',
|
|
1191
|
+
'- [ASISTAN] etiketli satırlar bizim AI asistanımızın/operatörümüzün yazdıklarıdır — bunları hasta bilgisi olarak KULLANMA.',
|
|
1192
|
+
'- "Yarın", "bugün", "haftaya" gibi göreceli tarihleri mesajın gönderildiği tarihe göre gerçek tarihe çevir (ör: 19.02.2026 tarihli mesajda "yarın" = 20.02.2026).',
|
|
1193
|
+
'- Yanıtın SADECE güncellenmiş profil özeti olmalı. Başka açıklama, selamlama veya soru ekleme.',
|
|
1194
|
+
'- Türkçe yaz.',
|
|
1195
|
+
'- Özeti madde işaretleri ile yapılandır.',
|
|
1196
|
+
'- Şu kategorileri kullan (bilgi varsa):',
|
|
1197
|
+
' • Kişisel Bilgiler (ad, yaş, cinsiyet, meslek, konum vb.)',
|
|
1198
|
+
' • Sağlık Durumu (şikayetler, tanılar, tedaviler, ilaçlar)',
|
|
1199
|
+
' • Randevu Geçmişi (önceki/planlanan randevular, gerçek tarihler ile)',
|
|
1200
|
+
' • İletişim Tercihleri (iletişim tarzı, hassasiyetler)',
|
|
1201
|
+
' • Tercih Edilen Dil (hastanın mesajlarında kullandığı dil — ör: Türkçe, English, العربية, Русский)',
|
|
1202
|
+
' • Önemli Notlar (özel durumlar, talepler, dikkat edilecekler)',
|
|
1203
|
+
'- Önceki özette olan ama yeni konuşmada güncellenmeyen bilgileri koru.',
|
|
1204
|
+
'- Yeni konuşmadan çıkan bilgilerle çelişen eski bilgileri güncelle.',
|
|
1205
|
+
'- Spekülasyon yapma, sadece hastanın kendi söylediklerinden çıkarılabilecek bilgileri yaz.',
|
|
1206
|
+
'- Özet 500 kelimeyi geçmesin.',
|
|
1207
|
+
'- Özetin EN SON satırında, hastanın mesajlarında kullandığı dili şu formatta yaz: PREFERRED_LANGUAGE: <dil>',
|
|
1208
|
+
' Örnek: PREFERRED_LANGUAGE: English',
|
|
1209
|
+
' Eğer hasta Türkçe yazıyorsa: PREFERRED_LANGUAGE: Türkçe',
|
|
1210
|
+
' Bu satır her zaman olmalı.',
|
|
1211
|
+
].join('\n');
|
|
1212
|
+
|
|
1213
|
+
const userMessage = [
|
|
1214
|
+
`Bugünün tarihi: ${now}`,
|
|
1215
|
+
'',
|
|
1216
|
+
`--- Mevcut hasta profili ---`,
|
|
1217
|
+
previousSummary,
|
|
1218
|
+
'',
|
|
1219
|
+
`--- Konuşma geçmişi ---`,
|
|
1220
|
+
transcript,
|
|
1221
|
+
].join('\n');
|
|
1222
|
+
|
|
1223
|
+
const aiService = createAIService();
|
|
1224
|
+
const result = await aiService.generateSummary([], {
|
|
1225
|
+
systemMessage,
|
|
1226
|
+
summaryPrompt: userMessage,
|
|
1227
|
+
language: 'tr',
|
|
1228
|
+
maxTokens: 1000,
|
|
1229
|
+
});
|
|
1230
|
+
|
|
1231
|
+
const rawSummary = result.summary?.trim();
|
|
1232
|
+
if (!rawSummary) {
|
|
1233
|
+
throw new Error('AI did not return a summary response');
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
// Extract PREFERRED_LANGUAGE line and remove it from the summary text
|
|
1237
|
+
const langMatch = rawSummary.match(/PREFERRED_LANGUAGE:\s*(.+)/i);
|
|
1238
|
+
const preferredLanguage = langMatch ? langMatch[1].trim() : '';
|
|
1239
|
+
const summaryText = rawSummary.replace(/\n?PREFERRED_LANGUAGE:\s*.+/i, '').trim();
|
|
1240
|
+
|
|
1241
|
+
const updateFields: Record<string, string> = { ai_summary: summaryText };
|
|
1242
|
+
if (preferredLanguage) {
|
|
1243
|
+
updateFields.preferred_language = preferredLanguage;
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
await WhatsAppContactProfile.updateOne(
|
|
1247
|
+
{ _id: profile._id },
|
|
1248
|
+
{ $set: updateFields },
|
|
1249
|
+
);
|
|
1250
|
+
logger.info('Contact profile summary updated', {
|
|
1251
|
+
sessionId,
|
|
1252
|
+
profileId: profile._id,
|
|
1253
|
+
summaryLength: summaryText.length,
|
|
1254
|
+
model: result.model,
|
|
1255
|
+
});
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
/**
|
|
1259
|
+
* Generate session labels (sentiment, categories, summary) and create a Lead record.
|
|
1260
|
+
* Called after session resolves. Fire-and-forget.
|
|
1261
|
+
*/
|
|
1262
|
+
async generateSessionLabels(sessionId: string): Promise<void> {
|
|
1263
|
+
logger.info('generateSessionLabels called', { sessionId });
|
|
1264
|
+
const session = await WhatsAppSession.findById(sessionId);
|
|
1265
|
+
if (!session || session.message_count < 2) {
|
|
1266
|
+
logger.info('generateSessionLabels skipped — not enough messages', { sessionId, message_count: session?.message_count });
|
|
1267
|
+
return;
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
const messages = await WhatsAppMessage.find({ session_id: session._id })
|
|
1271
|
+
.sort({ createdAt: 1 }).lean();
|
|
1272
|
+
if (messages.length < 2) {
|
|
1273
|
+
logger.info('generateSessionLabels skipped — fewer than 2 messages found', { sessionId, found: messages.length });
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
const chat = await WhatsAppChat.findById(session.chat_id).lean();
|
|
1278
|
+
|
|
1279
|
+
const contactName = chat?.contact_name || 'Hasta';
|
|
1280
|
+
const transcript = messages
|
|
1281
|
+
.map(m => {
|
|
1282
|
+
const role = m.sender === 'contact' ? contactName : 'ASISTAN';
|
|
1283
|
+
return `[${role}]: ${m.text}`;
|
|
1284
|
+
})
|
|
1285
|
+
.join('\n');
|
|
1286
|
+
|
|
1287
|
+
const openai = new OpenAI({ apiKey: config.ai.openai.apiKey });
|
|
1288
|
+
|
|
1289
|
+
const response = await openai.chat.completions.create({
|
|
1290
|
+
model: 'gpt-5.4-mini',
|
|
1291
|
+
messages: [
|
|
1292
|
+
{ role: 'system', content: 'Sen bir saglik kliniginin WhatsApp konusmalarini analiz eden uzman bir asistansin. Yanitini YALNIZCA gecerli JSON olarak ver.' },
|
|
1293
|
+
{ role: 'user', content: `Asagidaki WhatsApp konusmasini dikkatle oku. ODAK NOKTASI: Hastanin (kullanicinin) talep ve niyetini anla. Asistanin/AI'in verdigi cevaplari degil, HASTANIN ne istedigini, ne sordugunu, ne talep ettigini analiz et.
|
|
1294
|
+
|
|
1295
|
+
KURALLAR:
|
|
1296
|
+
- Yanitini YALNIZCA gecerli JSON olarak ver. Baska hicbir sey yazma (markdown, backtick, aciklama yok).
|
|
1297
|
+
- Tum metin alanlari Turkce olmali.
|
|
1298
|
+
- null yerine bos string kullanma — bilinmeyen degerler icin null yaz.
|
|
1299
|
+
- Kategoriyi HASTANIN TALEBINE gore sec, asistanin cevabina gore degil.
|
|
1300
|
+
|
|
1301
|
+
JSON SEMASI:
|
|
1302
|
+
{
|
|
1303
|
+
"ozet": "Hastanin talebi ve gorusme sonucu (2-3 cumle): hasta ne istedi/sordu, ne oldu",
|
|
1304
|
+
"sentiment": "positive | neutral | negative",
|
|
1305
|
+
"categories": ["asagidaki listeden en uygun TEK kategoriyi sec"],
|
|
1306
|
+
"lead": {
|
|
1307
|
+
"name": "Hastanin ismi (mesajda veya rehber ismi: ${contactName} — eger '${contactName}' gercek bir isim degilse veya sadece numara ise null yaz)",
|
|
1308
|
+
"service_of_interest": "Hastanin sordugu/ilgilendigi spesifik hizmet veya tedavi (burun estetigi, dis beyazlatma, sac ekimi vb. — genel soruylaysa null)",
|
|
1309
|
+
"summary": "Hastanin talebi ve gorusme sonucu: hasta ne istedi/sordu, sonuc ne oldu (1-2 cumle)"
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
KATEGORI TANIMLARI — hastanin asil talebine gore sec:
|
|
1314
|
+
- appointment_inquiry: Hasta randevu almak, degistirmek veya iptal etmek istiyor; muayene/tedavi icin gun/saat soruyor; "ne zaman gelebilirim" tarzi sorular
|
|
1315
|
+
- callback_required: Hasta bir konuda geri donus bekliyor; asistan "sizi ararız/bilgi veririz" demis; hasta "doktor beni arasin" veya "telefonla konusmak istiyorum" demis; henuz sonuclanan bir islem yok
|
|
1316
|
+
- other: Hastanin talebi yukaridaki kategorilere uymuyor — fiyat sorusu, genel bilgi, adres/ulasim, tesekkur, selamlasma vb.
|
|
1317
|
+
|
|
1318
|
+
SENTIMENT — hastanin tavrini ve gorusmenin sonucunu birlikte degerlendir:
|
|
1319
|
+
- positive: Hasta memnun ayrildi, istedigini aldi, randevu kesinlesti, sorulari cevaplandi
|
|
1320
|
+
- neutral: Standart bilgi alis-verisi, hasta ne memnun ne memnuniyetsiz
|
|
1321
|
+
- negative: Hasta sikayetci, kizgin, istedigini alamadi, gorusme olumsuz sonuclandi
|
|
1322
|
+
|
|
1323
|
+
Konusma:
|
|
1324
|
+
${transcript}` },
|
|
1325
|
+
],
|
|
1326
|
+
max_completion_tokens: 500,
|
|
1327
|
+
temperature: 0.3,
|
|
1328
|
+
});
|
|
1329
|
+
|
|
1330
|
+
const content = response.choices[0].message.content || '';
|
|
1331
|
+
|
|
1332
|
+
const update: Record<string, unknown> = {};
|
|
1333
|
+
try {
|
|
1334
|
+
const parsed = JSON.parse(content);
|
|
1335
|
+
update.llm_summary = parsed.ozet || content;
|
|
1336
|
+
if (['positive', 'neutral', 'negative'].includes(parsed.sentiment)) {
|
|
1337
|
+
update.sentiment = parsed.sentiment;
|
|
1338
|
+
}
|
|
1339
|
+
const cats = Array.isArray(parsed.categories) && parsed.categories.length > 0 ? parsed.categories : ['other'];
|
|
1340
|
+
update.categories = cats;
|
|
1341
|
+
|
|
1342
|
+
// Create Lead record
|
|
1343
|
+
if (parsed.lead && chat) {
|
|
1344
|
+
try {
|
|
1345
|
+
await Lead.create({
|
|
1346
|
+
tenant_id: session.tenant_id,
|
|
1347
|
+
source_type: 'whatsapp',
|
|
1348
|
+
source_id: session._id,
|
|
1349
|
+
chat_id: chat._id,
|
|
1350
|
+
name: parsed.lead.name || null,
|
|
1351
|
+
phone: chat.contact_phone || '',
|
|
1352
|
+
service_of_interest: parsed.lead.service_of_interest || null,
|
|
1353
|
+
channel: 'whatsapp',
|
|
1354
|
+
categories: cats,
|
|
1355
|
+
summary: parsed.lead.summary || null,
|
|
1356
|
+
});
|
|
1357
|
+
logger.info('Lead created from WhatsApp session', { sessionId, phone: chat.contact_phone });
|
|
1358
|
+
} catch (err: unknown) {
|
|
1359
|
+
const code = (err as { code?: number }).code;
|
|
1360
|
+
if (code === 11000) {
|
|
1361
|
+
logger.info('Lead already exists for session', { sessionId });
|
|
1362
|
+
} else {
|
|
1363
|
+
throw err;
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
} catch {
|
|
1368
|
+
update.llm_summary = content || null;
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
await WhatsAppSession.updateOne({ _id: session._id }, { $set: update });
|
|
1372
|
+
logger.info('Session labels generated', {
|
|
1373
|
+
sessionId,
|
|
1374
|
+
sentiment: update.sentiment,
|
|
1375
|
+
categories: update.categories,
|
|
1376
|
+
});
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
// ─── Connection Management ────────────────────────────────
|
|
1380
|
+
|
|
1381
|
+
closeConnection(unipileChatId: string): void {
|
|
1382
|
+
const conn = this.connections.get(unipileChatId);
|
|
1383
|
+
if (conn) {
|
|
1384
|
+
if (conn.pingTimer) { clearInterval(conn.pingTimer); conn.pingTimer = null; }
|
|
1385
|
+
conn.intentionallyClosed = true;
|
|
1386
|
+
conn.ws.close();
|
|
1387
|
+
this.connections.delete(unipileChatId);
|
|
1388
|
+
this.cancelMessageBuffer(conn.sessionId);
|
|
1389
|
+
logger.info('Closed EL connection for chat', { unipileChatId });
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
// ─── Client Tool Call Handling ───────────────────────────
|
|
1394
|
+
|
|
1395
|
+
private async handleClientToolCall(
|
|
1396
|
+
conn: ActiveConnection,
|
|
1397
|
+
toolCallId: string,
|
|
1398
|
+
toolName: string,
|
|
1399
|
+
parameters: Record<string, unknown>,
|
|
1400
|
+
): Promise<void> {
|
|
1401
|
+
let result: string;
|
|
1402
|
+
let isError = false;
|
|
1403
|
+
try {
|
|
1404
|
+
switch (toolName) {
|
|
1405
|
+
case 'forward_to_operator':
|
|
1406
|
+
result = await this.toolForwardToOperator(conn, parameters);
|
|
1407
|
+
break;
|
|
1408
|
+
case 'forward_conversation_to_operator':
|
|
1409
|
+
result = await this.toolForwardConversationToOperator(conn, parameters);
|
|
1410
|
+
break;
|
|
1411
|
+
case 'send_example_photo':
|
|
1412
|
+
result = await this.toolSendExamplePhoto(conn);
|
|
1413
|
+
break;
|
|
1414
|
+
case 'call_patient':
|
|
1415
|
+
result = await this.toolCallPatient(conn, parameters);
|
|
1416
|
+
break;
|
|
1417
|
+
case 'check_availability':
|
|
1418
|
+
result = await this.toolCheckAvailability(conn, parameters);
|
|
1419
|
+
break;
|
|
1420
|
+
case 'reserve_slot':
|
|
1421
|
+
result = await this.toolReserveSlot(conn, parameters);
|
|
1422
|
+
break;
|
|
1423
|
+
case 'list_reservations':
|
|
1424
|
+
result = await this.toolListReservations(conn, parameters);
|
|
1425
|
+
break;
|
|
1426
|
+
case 'cancel_reservation':
|
|
1427
|
+
result = await this.toolCancelReservation(conn, parameters);
|
|
1428
|
+
break;
|
|
1429
|
+
case 'check_patient_history':
|
|
1430
|
+
result = await this.toolCheckPatientHistory(conn, parameters);
|
|
1431
|
+
break;
|
|
1432
|
+
case 'reschedule_reservation':
|
|
1433
|
+
result = await this.toolRescheduleReservation(conn, parameters);
|
|
1434
|
+
break;
|
|
1435
|
+
default:
|
|
1436
|
+
result = JSON.stringify({ error: `Unknown tool: ${toolName}` });
|
|
1437
|
+
isError = true;
|
|
1438
|
+
}
|
|
1439
|
+
} catch (err) {
|
|
1440
|
+
result = JSON.stringify({ error: (err as Error).message });
|
|
1441
|
+
isError = true;
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
conn.ws.send(JSON.stringify({
|
|
1445
|
+
type: 'client_tool_result',
|
|
1446
|
+
tool_call_id: toolCallId,
|
|
1447
|
+
result,
|
|
1448
|
+
is_error: isError,
|
|
1449
|
+
}));
|
|
1450
|
+
|
|
1451
|
+
logger.info('Client tool call completed', {
|
|
1452
|
+
toolName,
|
|
1453
|
+
toolCallId,
|
|
1454
|
+
sessionId: conn.sessionId,
|
|
1455
|
+
resultPreview: result.substring(0, 200),
|
|
1456
|
+
});
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
private async toolSendExamplePhoto(conn: ActiveConnection): Promise<string> {
|
|
1460
|
+
const session = await WhatsAppSession.findById(conn.sessionId);
|
|
1461
|
+
if (!session) return JSON.stringify({ error: 'Session not found' });
|
|
1462
|
+
|
|
1463
|
+
const chat = await WhatsAppChat.findById(conn.chatId);
|
|
1464
|
+
if (!chat) return JSON.stringify({ error: 'Chat not found' });
|
|
1465
|
+
|
|
1466
|
+
const tenant = await Tenant.findById(session.tenant_id);
|
|
1467
|
+
if (!tenant) return JSON.stringify({ error: 'Tenant not found' });
|
|
1468
|
+
|
|
1469
|
+
const opSettings = tenant.settings?.whatsapp_agent;
|
|
1470
|
+
if (!opSettings?.operator_example_photo_enabled || !opSettings.operator_example_photo_base64) {
|
|
1471
|
+
return JSON.stringify({ error: 'Rehber fotoğraf yapılandırılmamış' });
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
const dataUrl = opSettings.operator_example_photo_base64;
|
|
1475
|
+
// Strip "data:image/...;base64," prefix
|
|
1476
|
+
const rawBase64 = dataUrl.replace(/^data:image\/[^;]+;base64,/, '');
|
|
1477
|
+
const caption = opSettings.operator_example_photo_caption || '';
|
|
1478
|
+
|
|
1479
|
+
const provider = unipileService.getProviderForTenant(tenant);
|
|
1480
|
+
|
|
1481
|
+
try {
|
|
1482
|
+
await unipileService.sendImage(provider, chat.unipile_chat_id, rawBase64, caption);
|
|
1483
|
+
|
|
1484
|
+
// Pre-create message record so the Baileys echo webhook dedup catches it
|
|
1485
|
+
// and doesn't store it as an incoming 'contact' message
|
|
1486
|
+
await WhatsAppMessage.create({
|
|
1487
|
+
chat_id: chat._id,
|
|
1488
|
+
session_id: session._id,
|
|
1489
|
+
tenant_id: session.tenant_id,
|
|
1490
|
+
unipile_message_id: null,
|
|
1491
|
+
sender: 'ai',
|
|
1492
|
+
sender_name: 'AI',
|
|
1493
|
+
text: caption || '[Rehber fotoğraf]',
|
|
1494
|
+
media_type: 'imageMessage',
|
|
1495
|
+
sent_via_unipile: true,
|
|
1496
|
+
});
|
|
1497
|
+
} catch (err) {
|
|
1498
|
+
logger.error('Failed to send example photo', { error: (err as Error).message });
|
|
1499
|
+
return JSON.stringify({ sent: false, error: (err as Error).message });
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
logger.info('Sent example photo to patient', { sessionId: conn.sessionId, chatId: conn.chatId });
|
|
1503
|
+
return JSON.stringify({ sent: true });
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
private async toolForwardToOperator(
|
|
1507
|
+
conn: ActiveConnection,
|
|
1508
|
+
parameters: Record<string, unknown>,
|
|
1509
|
+
): Promise<string> {
|
|
1510
|
+
const session = await WhatsAppSession.findById(conn.sessionId);
|
|
1511
|
+
if (!session || session.status === 'resolved') {
|
|
1512
|
+
return JSON.stringify({ error: 'Session not found or already resolved' });
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
const chat = await WhatsAppChat.findById(conn.chatId);
|
|
1516
|
+
if (!chat) {
|
|
1517
|
+
return JSON.stringify({ error: 'Chat not found' });
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
const tenant = await Tenant.findById(session.tenant_id);
|
|
1521
|
+
if (!tenant) {
|
|
1522
|
+
return JSON.stringify({ error: 'Tenant not found' });
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
const opSettings = tenant.settings?.whatsapp_agent;
|
|
1526
|
+
if (!opSettings?.operator_workflow_enabled || !opSettings.operator_phone) {
|
|
1527
|
+
return JSON.stringify({ error: 'Operator workflow not configured' });
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
const operatorPhone = opSettings.operator_phone;
|
|
1531
|
+
const prefix = opSettings.operator_ref_prefix || 'R';
|
|
1532
|
+
const timeoutHours = opSettings.operator_request_timeout_hours || 24;
|
|
1533
|
+
|
|
1534
|
+
// Find ALL media messages from the patient in this session (oldest first)
|
|
1535
|
+
// Only include image/video/document types (skip stickers, contacts, locations etc.)
|
|
1536
|
+
const FORWARDABLE_TYPES = ['imageMessage', 'videoMessage', 'documentMessage'];
|
|
1537
|
+
const allMedia = await WhatsAppMessage.find({
|
|
1538
|
+
session_id: session._id,
|
|
1539
|
+
sender: 'contact',
|
|
1540
|
+
media_type: { $in: FORWARDABLE_TYPES },
|
|
1541
|
+
}).sort({ createdAt: 1 }).lean();
|
|
1542
|
+
|
|
1543
|
+
logger.info('Media messages found for forwarding', {
|
|
1544
|
+
sessionId: session._id,
|
|
1545
|
+
count: allMedia.length,
|
|
1546
|
+
types: allMedia.map(m => m.media_type),
|
|
1547
|
+
ids: allMedia.map(m => m.unipile_message_id),
|
|
1548
|
+
});
|
|
1549
|
+
|
|
1550
|
+
// Use session-level REF code (generated at session creation)
|
|
1551
|
+
const refCode = session.ref_code || await this.generateRefCode(tenant._id.toString(), prefix);
|
|
1552
|
+
|
|
1553
|
+
// Create OperatorRequest with array fields
|
|
1554
|
+
await OperatorRequest.create({
|
|
1555
|
+
tenant_id: tenant._id,
|
|
1556
|
+
session_id: session._id,
|
|
1557
|
+
chat_id: chat._id,
|
|
1558
|
+
ref_code: refCode,
|
|
1559
|
+
request_type: 'operator_review',
|
|
1560
|
+
status: 'pending',
|
|
1561
|
+
forwarded_media_types: allMedia.map(m => m.media_type || 'unknown'),
|
|
1562
|
+
forwarded_at: new Date(),
|
|
1563
|
+
forwarded_to_phone: operatorPhone,
|
|
1564
|
+
patient_message_ids: allMedia.map(m => m._id),
|
|
1565
|
+
forwarded_count: allMedia.length,
|
|
1566
|
+
failed_count: 0,
|
|
1567
|
+
expires_at: new Date(Date.now() + timeoutHours * 60 * 60 * 1000),
|
|
1568
|
+
});
|
|
1569
|
+
|
|
1570
|
+
// Mark session as awaiting operator
|
|
1571
|
+
session.awaiting_operator = true;
|
|
1572
|
+
await session.save();
|
|
1573
|
+
|
|
1574
|
+
const note = (parameters.message as string) || '';
|
|
1575
|
+
const provider = unipileService.getProviderForTenant(tenant);
|
|
1576
|
+
const total = allMedia.length;
|
|
1577
|
+
let forwardedOk = 0;
|
|
1578
|
+
let failedCount = 0;
|
|
1579
|
+
|
|
1580
|
+
try {
|
|
1581
|
+
if (total > 0) {
|
|
1582
|
+
for (let i = 0; i < total; i++) {
|
|
1583
|
+
const media = allMedia[i];
|
|
1584
|
+
const caption = i === 0
|
|
1585
|
+
? `REF: ${refCode}\nHasta: ${chat.contact_phone}\nHasta adı: ${chat.contact_name}${total > 1 ? `\nFotoğraf: 1/${total}` : ''}${note ? '\nNot: ' + note : ''}`
|
|
1586
|
+
: `REF: ${refCode} (${i + 1}/${total})`;
|
|
1587
|
+
|
|
1588
|
+
try {
|
|
1589
|
+
await unipileService.forwardMedia(
|
|
1590
|
+
provider,
|
|
1591
|
+
chat.unipile_chat_id,
|
|
1592
|
+
media.unipile_message_id!,
|
|
1593
|
+
operatorPhone,
|
|
1594
|
+
caption,
|
|
1595
|
+
);
|
|
1596
|
+
forwardedOk++;
|
|
1597
|
+
} catch (err) {
|
|
1598
|
+
failedCount++;
|
|
1599
|
+
logger.warn('Failed to forward media to operator', {
|
|
1600
|
+
refCode, index: i + 1, total,
|
|
1601
|
+
messageId: media.unipile_message_id,
|
|
1602
|
+
error: (err as Error).message,
|
|
1603
|
+
});
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
// Small delay between sends to avoid WA rate limiting
|
|
1607
|
+
if (i < total - 1) {
|
|
1608
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
} else {
|
|
1612
|
+
// No media — just send text to operator
|
|
1613
|
+
const accountId = tenant.settings.whatsapp_agent.unipile_account_id;
|
|
1614
|
+
if (accountId) {
|
|
1615
|
+
const operatorJid = operatorPhone.replace(/[^0-9]/g, '') + '@s.whatsapp.net';
|
|
1616
|
+
const operatorChatId = `${accountId}:${operatorJid}`;
|
|
1617
|
+
const caption = `REF: ${refCode}\nHasta: ${chat.contact_phone}\nHasta adı: ${chat.contact_name}${note ? '\nNot: ' + note : ''}`;
|
|
1618
|
+
await unipileService.sendMessage(provider, operatorChatId, caption);
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
} catch (err) {
|
|
1622
|
+
logger.error('Failed to forward to operator', { refCode, error: (err as Error).message });
|
|
1623
|
+
return JSON.stringify({ ref_code: refCode, forwarded: false, error: (err as Error).message });
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
// Update failed count on the request
|
|
1627
|
+
if (failedCount > 0) {
|
|
1628
|
+
await OperatorRequest.updateOne(
|
|
1629
|
+
{ ref_code: refCode, tenant_id: tenant._id },
|
|
1630
|
+
{ $set: { failed_count: failedCount } },
|
|
1631
|
+
);
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
logger.info('Forwarded to operator via tool call', {
|
|
1635
|
+
refCode, sessionId: conn.sessionId, total_media: total,
|
|
1636
|
+
forwarded_ok: forwardedOk, failed: failedCount,
|
|
1637
|
+
});
|
|
1638
|
+
|
|
1639
|
+
return JSON.stringify({
|
|
1640
|
+
ref_code: refCode,
|
|
1641
|
+
forwarded: true,
|
|
1642
|
+
total_media: total,
|
|
1643
|
+
forwarded_ok: forwardedOk,
|
|
1644
|
+
failed: failedCount,
|
|
1645
|
+
...(failedCount > 0 ? { failed_reason: `${failedCount} medya iletilemedi (önbellek süresi dolmuş olabilir)` } : {}),
|
|
1646
|
+
});
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
private async toolForwardConversationToOperator(
|
|
1650
|
+
conn: ActiveConnection,
|
|
1651
|
+
parameters: Record<string, unknown>,
|
|
1652
|
+
): Promise<string> {
|
|
1653
|
+
const session = await WhatsAppSession.findById(conn.sessionId);
|
|
1654
|
+
if (!session || session.status === 'resolved') {
|
|
1655
|
+
return JSON.stringify({ error: 'Session not found or already resolved' });
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
const chat = await WhatsAppChat.findById(conn.chatId);
|
|
1659
|
+
if (!chat) return JSON.stringify({ error: 'Chat not found' });
|
|
1660
|
+
|
|
1661
|
+
const tenant = await Tenant.findById(session.tenant_id);
|
|
1662
|
+
if (!tenant) return JSON.stringify({ error: 'Tenant not found' });
|
|
1663
|
+
|
|
1664
|
+
if (!tenant.enabled_features.includes('conversation_forwarding')) {
|
|
1665
|
+
return JSON.stringify({ error: 'Conversation forwarding not enabled' });
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
const forwardPhone = tenant.settings?.whatsapp_agent?.conversation_forwarding_phone;
|
|
1669
|
+
if (!forwardPhone) {
|
|
1670
|
+
return JSON.stringify({ error: 'Conversation forwarding phone not configured' });
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
const summary = (parameters.summary as string) || '';
|
|
1674
|
+
const rawMessages = parameters.patient_messages;
|
|
1675
|
+
const patientMessages: string[] = Array.isArray(rawMessages)
|
|
1676
|
+
? rawMessages
|
|
1677
|
+
: (typeof rawMessages === 'string' && rawMessages ? [rawMessages] : []);
|
|
1678
|
+
|
|
1679
|
+
if (!summary && patientMessages.length === 0) {
|
|
1680
|
+
return JSON.stringify({ error: 'summary or patient_messages required' });
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
const refCode = session.ref_code || await this.generateRefCode(tenant._id.toString(), tenant.settings?.whatsapp_agent?.operator_ref_prefix || 'R');
|
|
1684
|
+
const timeoutHours = tenant.settings?.whatsapp_agent?.operator_request_timeout_hours || 24;
|
|
1685
|
+
|
|
1686
|
+
await OperatorRequest.create({
|
|
1687
|
+
tenant_id: tenant._id,
|
|
1688
|
+
session_id: session._id,
|
|
1689
|
+
chat_id: chat._id,
|
|
1690
|
+
ref_code: refCode,
|
|
1691
|
+
request_type: 'conversation_review',
|
|
1692
|
+
status: 'pending',
|
|
1693
|
+
forwarded_at: new Date(),
|
|
1694
|
+
forwarded_to_phone: forwardPhone,
|
|
1695
|
+
forwarded_count: patientMessages.length,
|
|
1696
|
+
expires_at: new Date(Date.now() + timeoutHours * 60 * 60 * 1000),
|
|
1697
|
+
});
|
|
1698
|
+
|
|
1699
|
+
session.awaiting_operator = true;
|
|
1700
|
+
if (!session.ref_code) session.ref_code = refCode;
|
|
1701
|
+
await session.save();
|
|
1702
|
+
|
|
1703
|
+
// Build message for the doctor
|
|
1704
|
+
const lines = [`REF: ${refCode}`, `Hasta: ${chat.contact_phone}`, `Hasta adı: ${chat.contact_name}`];
|
|
1705
|
+
if (summary) lines.push('', `Özet: ${summary}`);
|
|
1706
|
+
if (patientMessages.length > 0) {
|
|
1707
|
+
lines.push('', 'Hasta mesajları:');
|
|
1708
|
+
for (const msg of patientMessages) {
|
|
1709
|
+
lines.push(`- ${msg}`);
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
const messageText = lines.join('\n');
|
|
1713
|
+
|
|
1714
|
+
const provider = unipileService.getProviderForTenant(tenant);
|
|
1715
|
+
const accountId = tenant.settings.whatsapp_agent.unipile_account_id;
|
|
1716
|
+
|
|
1717
|
+
try {
|
|
1718
|
+
if (accountId) {
|
|
1719
|
+
const forwardJid = forwardPhone.replace(/[^0-9]/g, '') + '@s.whatsapp.net';
|
|
1720
|
+
const forwardChatId = `${accountId}:${forwardJid}`;
|
|
1721
|
+
await unipileService.sendMessage(provider, forwardChatId, messageText);
|
|
1722
|
+
}
|
|
1723
|
+
} catch (err) {
|
|
1724
|
+
logger.error('Failed to forward conversation to operator', { refCode, error: (err as Error).message });
|
|
1725
|
+
return JSON.stringify({ ref_code: refCode, forwarded: false, error: (err as Error).message });
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
logger.info('Forwarded conversation to operator', {
|
|
1729
|
+
refCode, sessionId: conn.sessionId, messageCount: patientMessages.length,
|
|
1730
|
+
});
|
|
1731
|
+
|
|
1732
|
+
return JSON.stringify({
|
|
1733
|
+
ref_code: refCode,
|
|
1734
|
+
forwarded: true,
|
|
1735
|
+
message_count: patientMessages.length,
|
|
1736
|
+
});
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
async generateRefCode(tenantId: string, prefix: string): Promise<string> {
|
|
1740
|
+
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
|
1741
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
1742
|
+
const rand = Array.from({ length: 4 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
|
|
1743
|
+
const code = `${prefix}-${rand}`;
|
|
1744
|
+
const [inRequest, inSession] = await Promise.all([
|
|
1745
|
+
OperatorRequest.exists({ ref_code: code, tenant_id: tenantId }),
|
|
1746
|
+
WhatsAppSession.exists({ ref_code: code, tenant_id: tenantId }),
|
|
1747
|
+
]);
|
|
1748
|
+
if (!inRequest && !inSession) return code;
|
|
1749
|
+
}
|
|
1750
|
+
return `${prefix}-${Date.now().toString(36).slice(-4).toUpperCase()}`;
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
// ─── Outbound Call Tool ──────────────────────────────────
|
|
1754
|
+
|
|
1755
|
+
private async toolCallPatient(
|
|
1756
|
+
conn: ActiveConnection,
|
|
1757
|
+
parameters: Record<string, unknown>,
|
|
1758
|
+
): Promise<string> {
|
|
1759
|
+
const session = await WhatsAppSession.findById(conn.sessionId);
|
|
1760
|
+
if (!session || session.status === 'resolved') {
|
|
1761
|
+
return JSON.stringify({ error: 'Session not found or already resolved' });
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
const chat = await WhatsAppChat.findById(conn.chatId);
|
|
1765
|
+
if (!chat) {
|
|
1766
|
+
return JSON.stringify({ error: 'Chat not found' });
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
const tenant = await Tenant.findById(session.tenant_id);
|
|
1770
|
+
if (!tenant) {
|
|
1771
|
+
return JSON.stringify({ error: 'Tenant not found' });
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
const callSettings = tenant.settings?.whatsapp_agent;
|
|
1775
|
+
if (!callSettings?.outbound_call_enabled) {
|
|
1776
|
+
return JSON.stringify({ error: 'Outbound calling not enabled' });
|
|
1777
|
+
}
|
|
1778
|
+
if (!callSettings.outbound_call_phone_number_id) {
|
|
1779
|
+
return JSON.stringify({ error: 'Outbound call not fully configured (missing phone_number_id)' });
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
// Resolve the voice agent — use dedicated call agent or fall back to chat agent
|
|
1783
|
+
let voiceAgentId: string | null = null;
|
|
1784
|
+
if (callSettings.outbound_call_agent_id) {
|
|
1785
|
+
const callAgent = await Agent.findById(callSettings.outbound_call_agent_id).lean();
|
|
1786
|
+
if (callAgent?.is_active) {
|
|
1787
|
+
voiceAgentId = callAgent.elevenlabs_agent_id;
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
if (!voiceAgentId) {
|
|
1791
|
+
voiceAgentId = conn.agentId;
|
|
1792
|
+
}
|
|
1793
|
+
if (!voiceAgentId) {
|
|
1794
|
+
return JSON.stringify({ error: 'No agent available for voice call' });
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
// Format patient phone as E.164 for Twilio (e.g. +905551234567)
|
|
1798
|
+
const rawPhone = chat.contact_phone.replace(/[^0-9+]/g, '');
|
|
1799
|
+
const patientPhone = rawPhone.startsWith('+') ? rawPhone : `+${rawPhone}`;
|
|
1800
|
+
if (patientPhone.length < 8) {
|
|
1801
|
+
return JSON.stringify({ error: 'Patient phone number not available' });
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
// Build full conversation context (messages + operator events) for the voice agent
|
|
1805
|
+
const reason = (parameters.reason as string) || '';
|
|
1806
|
+
const chatContext = await this.buildChatContext(conn.chatId, conn.sessionId) || '';
|
|
1807
|
+
|
|
1808
|
+
try {
|
|
1809
|
+
const result = await elevenlabsService.sipTrunkOutboundCall({
|
|
1810
|
+
agentId: voiceAgentId,
|
|
1811
|
+
agentPhoneNumberId: callSettings.outbound_call_phone_number_id,
|
|
1812
|
+
toNumber: patientPhone,
|
|
1813
|
+
conversationInitiationClientData: {
|
|
1814
|
+
dynamicVariables: {
|
|
1815
|
+
patient_name: chat.contact_name || 'Hasta',
|
|
1816
|
+
patient_phone: patientPhone,
|
|
1817
|
+
conversation_history: chatContext,
|
|
1818
|
+
call_reason: reason,
|
|
1819
|
+
},
|
|
1820
|
+
},
|
|
1821
|
+
});
|
|
1822
|
+
|
|
1823
|
+
logger.info('Outbound call initiated via tool', {
|
|
1824
|
+
sessionId: conn.sessionId,
|
|
1825
|
+
patientPhone,
|
|
1826
|
+
conversationId: result?.conversationId,
|
|
1827
|
+
sipCallId: result?.sipCallId,
|
|
1828
|
+
voiceAgentId,
|
|
1829
|
+
message: result?.message,
|
|
1830
|
+
});
|
|
1831
|
+
|
|
1832
|
+
// Parse SIP failure from message (e.g. "INVITE failed: sip status: 486: User Busy (SIP 486)")
|
|
1833
|
+
const sipFailure = this.parseSipFailure(result?.message || '');
|
|
1834
|
+
if (sipFailure) {
|
|
1835
|
+
return JSON.stringify({
|
|
1836
|
+
success: false,
|
|
1837
|
+
reason: sipFailure,
|
|
1838
|
+
conversation_id: result?.conversationId || null,
|
|
1839
|
+
});
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
return JSON.stringify({
|
|
1843
|
+
success: true,
|
|
1844
|
+
conversation_id: result?.conversationId || null,
|
|
1845
|
+
});
|
|
1846
|
+
} catch (err) {
|
|
1847
|
+
logger.error('Failed to initiate outbound call', {
|
|
1848
|
+
sessionId: conn.sessionId,
|
|
1849
|
+
patientPhone,
|
|
1850
|
+
error: (err as Error).message,
|
|
1851
|
+
});
|
|
1852
|
+
return JSON.stringify({ error: 'Call failed: ' + (err as Error).message });
|
|
1853
|
+
}
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
/**
|
|
1857
|
+
* Parse SIP error from the outbound call response message.
|
|
1858
|
+
* Returns a human-readable Turkish reason if it's a SIP failure, null if call succeeded.
|
|
1859
|
+
*/
|
|
1860
|
+
private parseSipFailure(message: string): string | null {
|
|
1861
|
+
if (!message) return null;
|
|
1862
|
+
|
|
1863
|
+
const sipMatch = message.match(/sip\s*status:\s*(\d{3})/i);
|
|
1864
|
+
if (!sipMatch) return null;
|
|
1865
|
+
|
|
1866
|
+
const code = parseInt(sipMatch[1], 10);
|
|
1867
|
+
// 2xx = success
|
|
1868
|
+
if (code >= 200 && code < 300) return null;
|
|
1869
|
+
|
|
1870
|
+
const reasons: Record<number, string> = {
|
|
1871
|
+
486: 'Hasta meşgul, telefonu kullanıyor',
|
|
1872
|
+
487: 'Arama iptal edildi',
|
|
1873
|
+
480: 'Hasta şu an ulaşılamıyor',
|
|
1874
|
+
408: 'Hasta yanıt vermedi, zaman aşımı',
|
|
1875
|
+
603: 'Hasta aramayı reddetti',
|
|
1876
|
+
404: 'Numara bulunamadı',
|
|
1877
|
+
503: 'Telefon hizmeti geçici olarak kullanılamıyor',
|
|
1878
|
+
504: 'Bağlantı zaman aşımına uğradı',
|
|
1879
|
+
};
|
|
1880
|
+
|
|
1881
|
+
return reasons[code] || `Arama başarısız oldu (SIP ${code})`;
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
// ─── Google Calendar Tools ──────────────────────────────
|
|
1885
|
+
|
|
1886
|
+
private async toolCheckAvailability(
|
|
1887
|
+
conn: ActiveConnection,
|
|
1888
|
+
parameters: Record<string, unknown>,
|
|
1889
|
+
): Promise<string> {
|
|
1890
|
+
const session = await WhatsAppSession.findById(conn.sessionId);
|
|
1891
|
+
if (!session) return JSON.stringify({ error: 'Session not found' });
|
|
1892
|
+
|
|
1893
|
+
const tenant = await Tenant.findById(session.tenant_id);
|
|
1894
|
+
if (!tenant) return JSON.stringify({ error: 'Tenant not found' });
|
|
1895
|
+
|
|
1896
|
+
const gcal = tenant.settings?.whatsapp_agent?.google_calendar;
|
|
1897
|
+
if (!gcal?.connected || !gcal?.calendar_id) {
|
|
1898
|
+
return JSON.stringify({ error: 'Google Calendar not configured' });
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
const date = parameters.date as string;
|
|
1902
|
+
if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
|
1903
|
+
return JSON.stringify({ error: 'Invalid date format. Use YYYY-MM-DD' });
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
const today = new Date().toISOString().split('T')[0];
|
|
1907
|
+
if (date < today) {
|
|
1908
|
+
return JSON.stringify({ error: `Geçmiş tarih için randevu oluşturulamaz. Bugünün tarihi: ${today}` });
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
const result = await googleCalendarService.checkAvailability(
|
|
1912
|
+
tenant._id.toString(), date,
|
|
1913
|
+
);
|
|
1914
|
+
|
|
1915
|
+
if (result.slots.length === 0) {
|
|
1916
|
+
return JSON.stringify({
|
|
1917
|
+
date,
|
|
1918
|
+
available_slots: [],
|
|
1919
|
+
message: result.message || 'Bu tarihte müsait randevu yok',
|
|
1920
|
+
});
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
return JSON.stringify({
|
|
1924
|
+
date,
|
|
1925
|
+
available_slots: result.slots,
|
|
1926
|
+
appointment_duration_minutes: gcal.appointment_duration_minutes,
|
|
1927
|
+
});
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
private async toolReserveSlot(
|
|
1931
|
+
conn: ActiveConnection,
|
|
1932
|
+
parameters: Record<string, unknown>,
|
|
1933
|
+
): Promise<string> {
|
|
1934
|
+
const session = await WhatsAppSession.findById(conn.sessionId);
|
|
1935
|
+
if (!session) return JSON.stringify({ error: 'Session not found' });
|
|
1936
|
+
|
|
1937
|
+
const tenant = await Tenant.findById(session.tenant_id);
|
|
1938
|
+
if (!tenant) return JSON.stringify({ error: 'Tenant not found' });
|
|
1939
|
+
|
|
1940
|
+
const gcal = tenant.settings?.whatsapp_agent?.google_calendar;
|
|
1941
|
+
if (!gcal?.connected || !gcal?.calendar_id) {
|
|
1942
|
+
return JSON.stringify({ error: 'Google Calendar not configured' });
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
const chat = await WhatsAppChat.findById(conn.chatId);
|
|
1946
|
+
const patientName = (parameters.patient_name as string)
|
|
1947
|
+
|| chat?.contact_name
|
|
1948
|
+
|| 'Bilinmeyen Hasta';
|
|
1949
|
+
const patientPhone = chat?.contact_phone || '';
|
|
1950
|
+
const isoStart = parameters.iso_start as string;
|
|
1951
|
+
const isoEnd = parameters.iso_end as string;
|
|
1952
|
+
const notes = (parameters.notes as string) || '';
|
|
1953
|
+
|
|
1954
|
+
if (!isoStart || !isoEnd) {
|
|
1955
|
+
return JSON.stringify({ error: 'iso_start and iso_end are required' });
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
// Pre-generate appointment ID so we can include the detail link in the calendar event
|
|
1959
|
+
const appointmentId = new mongoose.Types.ObjectId();
|
|
1960
|
+
|
|
1961
|
+
const result = await googleCalendarService.reserveSlot(
|
|
1962
|
+
tenant._id.toString(),
|
|
1963
|
+
{ iso_start: isoStart, iso_end: isoEnd, patient_name: patientName, patient_phone: patientPhone, notes, appointment_id: appointmentId.toString() },
|
|
1964
|
+
);
|
|
1965
|
+
|
|
1966
|
+
// Persist appointment locally for analytics
|
|
1967
|
+
Appointment.create({
|
|
1968
|
+
_id: appointmentId,
|
|
1969
|
+
tenant_id: session.tenant_id,
|
|
1970
|
+
chat_id: session.chat_id,
|
|
1971
|
+
session_id: session._id,
|
|
1972
|
+
google_event_id: result.event_id,
|
|
1973
|
+
patient_name: patientName,
|
|
1974
|
+
patient_phone: patientPhone,
|
|
1975
|
+
start_time: new Date(result.start),
|
|
1976
|
+
end_time: new Date(result.end),
|
|
1977
|
+
duration_minutes: gcal.appointment_duration_minutes,
|
|
1978
|
+
notes,
|
|
1979
|
+
status: 'scheduled',
|
|
1980
|
+
}).catch(err => logger.error('Failed to persist appointment', { error: (err as Error).message }));
|
|
1981
|
+
|
|
1982
|
+
// SMS notification to doctor (fire-and-forget)
|
|
1983
|
+
const smsSettings = tenant.settings?.whatsapp_agent;
|
|
1984
|
+
if (smsSettings?.sms_notification_enabled && smsSettings.sms_notification_phones?.length) {
|
|
1985
|
+
const startDt = new Date(result.start);
|
|
1986
|
+
const tz = gcal.timezone || 'Europe/Istanbul';
|
|
1987
|
+
netgsmService.notifyAppointmentCreated({
|
|
1988
|
+
phones: smsSettings.sms_notification_phones,
|
|
1989
|
+
patientName,
|
|
1990
|
+
patientPhone,
|
|
1991
|
+
date: startDt.toLocaleDateString('tr-TR', { day: 'numeric', month: 'long', year: 'numeric', weekday: 'long', timeZone: tz }),
|
|
1992
|
+
time: startDt.toLocaleTimeString('tr-TR', { hour: '2-digit', minute: '2-digit', timeZone: tz }),
|
|
1993
|
+
notes,
|
|
1994
|
+
detailUrl: `${config.google.frontendUrl}/appointments/${appointmentId}`,
|
|
1995
|
+
}).catch(err => logger.error('SMS notification failed (create)', { error: (err as Error).message }));
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
return JSON.stringify({
|
|
1999
|
+
reserved: true,
|
|
2000
|
+
event_id: result.event_id,
|
|
2001
|
+
start: result.start,
|
|
2002
|
+
end: result.end,
|
|
2003
|
+
patient_name: patientName,
|
|
2004
|
+
});
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
private async toolListReservations(
|
|
2008
|
+
conn: ActiveConnection,
|
|
2009
|
+
_parameters: Record<string, unknown>,
|
|
2010
|
+
): Promise<string> {
|
|
2011
|
+
const session = await WhatsAppSession.findById(conn.sessionId);
|
|
2012
|
+
if (!session) return JSON.stringify({ error: 'Session not found' });
|
|
2013
|
+
|
|
2014
|
+
const tenant = await Tenant.findById(session.tenant_id);
|
|
2015
|
+
if (!tenant) return JSON.stringify({ error: 'Tenant not found' });
|
|
2016
|
+
|
|
2017
|
+
const gcal = tenant.settings?.whatsapp_agent?.google_calendar;
|
|
2018
|
+
if (!gcal?.connected || !gcal?.calendar_id) {
|
|
2019
|
+
return JSON.stringify({ error: 'Google Calendar not configured' });
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
const chat = await WhatsAppChat.findById(conn.chatId);
|
|
2023
|
+
const patientPhone = chat?.contact_phone || '';
|
|
2024
|
+
if (!patientPhone) {
|
|
2025
|
+
return JSON.stringify({ error: 'Patient phone not available' });
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
const reservations = await googleCalendarService.listReservations(
|
|
2029
|
+
tenant._id.toString(), patientPhone,
|
|
2030
|
+
);
|
|
2031
|
+
|
|
2032
|
+
return JSON.stringify({
|
|
2033
|
+
reservations,
|
|
2034
|
+
count: reservations.length,
|
|
2035
|
+
});
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
private async toolCancelReservation(
|
|
2039
|
+
conn: ActiveConnection,
|
|
2040
|
+
parameters: Record<string, unknown>,
|
|
2041
|
+
): Promise<string> {
|
|
2042
|
+
const session = await WhatsAppSession.findById(conn.sessionId);
|
|
2043
|
+
if (!session) return JSON.stringify({ error: 'Session not found' });
|
|
2044
|
+
|
|
2045
|
+
const tenant = await Tenant.findById(session.tenant_id);
|
|
2046
|
+
if (!tenant) return JSON.stringify({ error: 'Tenant not found' });
|
|
2047
|
+
|
|
2048
|
+
const gcal = tenant.settings?.whatsapp_agent?.google_calendar;
|
|
2049
|
+
if (!gcal?.connected || !gcal?.calendar_id) {
|
|
2050
|
+
return JSON.stringify({ error: 'Google Calendar not configured' });
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
const chat = await WhatsAppChat.findById(conn.chatId);
|
|
2054
|
+
const patientPhone = chat?.contact_phone || '';
|
|
2055
|
+
const eventId = parameters.event_id as string;
|
|
2056
|
+
|
|
2057
|
+
if (!eventId) {
|
|
2058
|
+
return JSON.stringify({ error: 'event_id is required' });
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
const cancelled = await googleCalendarService.cancelReservation(
|
|
2062
|
+
tenant._id.toString(), eventId, patientPhone,
|
|
2063
|
+
);
|
|
2064
|
+
|
|
2065
|
+
if (cancelled) {
|
|
2066
|
+
// Update local DB
|
|
2067
|
+
const appt = await Appointment.findOneAndUpdate(
|
|
2068
|
+
{ google_event_id: eventId, tenant_id: session.tenant_id },
|
|
2069
|
+
{ status: 'cancelled', cancelled_at: new Date() },
|
|
2070
|
+
).lean();
|
|
2071
|
+
|
|
2072
|
+
// SMS notification to doctor (fire-and-forget)
|
|
2073
|
+
const smsSettings = tenant.settings?.whatsapp_agent;
|
|
2074
|
+
if (appt && smsSettings?.sms_notification_enabled && smsSettings.sms_notification_phones?.length) {
|
|
2075
|
+
const tz = gcal.timezone || 'Europe/Istanbul';
|
|
2076
|
+
netgsmService.notifyAppointmentCancelled({
|
|
2077
|
+
phones: smsSettings.sms_notification_phones,
|
|
2078
|
+
patientName: appt.patient_name,
|
|
2079
|
+
patientPhone,
|
|
2080
|
+
date: appt.start_time.toLocaleDateString('tr-TR', { day: 'numeric', month: 'long', weekday: 'long', timeZone: tz }),
|
|
2081
|
+
time: appt.start_time.toLocaleTimeString('tr-TR', { hour: '2-digit', minute: '2-digit', timeZone: tz }),
|
|
2082
|
+
}).catch(err => logger.error('SMS notification failed (cancel)', { error: (err as Error).message }));
|
|
2083
|
+
}
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
return JSON.stringify({ cancelled, event_id: eventId });
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
private async toolRescheduleReservation(
|
|
2090
|
+
conn: ActiveConnection,
|
|
2091
|
+
parameters: Record<string, unknown>,
|
|
2092
|
+
): Promise<string> {
|
|
2093
|
+
const session = await WhatsAppSession.findById(conn.sessionId);
|
|
2094
|
+
if (!session) return JSON.stringify({ error: 'Session not found' });
|
|
2095
|
+
|
|
2096
|
+
const tenant = await Tenant.findById(session.tenant_id);
|
|
2097
|
+
if (!tenant) return JSON.stringify({ error: 'Tenant not found' });
|
|
2098
|
+
|
|
2099
|
+
const gcal = tenant.settings?.whatsapp_agent?.google_calendar;
|
|
2100
|
+
if (!gcal?.connected || !gcal?.calendar_id) {
|
|
2101
|
+
return JSON.stringify({ error: 'Google Calendar not configured' });
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
const chat = await WhatsAppChat.findById(conn.chatId);
|
|
2105
|
+
const patientPhone = chat?.contact_phone || '';
|
|
2106
|
+
const patientName = (parameters.patient_name as string) || chat?.contact_name || 'Bilinmeyen Hasta';
|
|
2107
|
+
const oldEventId = parameters.old_event_id as string;
|
|
2108
|
+
const newIsoStart = parameters.new_iso_start as string;
|
|
2109
|
+
const newIsoEnd = parameters.new_iso_end as string;
|
|
2110
|
+
const notes = (parameters.notes as string) || '';
|
|
2111
|
+
|
|
2112
|
+
if (!oldEventId) {
|
|
2113
|
+
return JSON.stringify({ error: 'old_event_id is required' });
|
|
2114
|
+
}
|
|
2115
|
+
if (!newIsoStart || !newIsoEnd) {
|
|
2116
|
+
return JSON.stringify({ error: 'new_iso_start and new_iso_end are required' });
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
// Fetch old appointment info for SMS before cancelling
|
|
2120
|
+
const oldAppt = await Appointment.findOne(
|
|
2121
|
+
{ google_event_id: oldEventId, tenant_id: session.tenant_id },
|
|
2122
|
+
).lean();
|
|
2123
|
+
|
|
2124
|
+
// Step 1: Cancel old reservation
|
|
2125
|
+
try {
|
|
2126
|
+
await googleCalendarService.cancelReservation(
|
|
2127
|
+
tenant._id.toString(), oldEventId, patientPhone,
|
|
2128
|
+
);
|
|
2129
|
+
|
|
2130
|
+
Appointment.updateOne(
|
|
2131
|
+
{ google_event_id: oldEventId, tenant_id: session.tenant_id },
|
|
2132
|
+
{ status: 'cancelled', cancelled_at: new Date() },
|
|
2133
|
+
).catch(err => logger.error('Failed to update appointment status', { error: (err as Error).message }));
|
|
2134
|
+
|
|
2135
|
+
logger.info('Reschedule: old reservation cancelled', { oldEventId, sessionId: conn.sessionId });
|
|
2136
|
+
} catch (err) {
|
|
2137
|
+
return JSON.stringify({ error: `Eski randevu iptal edilemedi: ${(err as Error).message}` });
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
// Step 2: Create new reservation
|
|
2141
|
+
const appointmentId = new mongoose.Types.ObjectId();
|
|
2142
|
+
|
|
2143
|
+
try {
|
|
2144
|
+
const result = await googleCalendarService.reserveSlot(
|
|
2145
|
+
tenant._id.toString(),
|
|
2146
|
+
{ iso_start: newIsoStart, iso_end: newIsoEnd, patient_name: patientName, patient_phone: patientPhone, notes, appointment_id: appointmentId.toString() },
|
|
2147
|
+
);
|
|
2148
|
+
|
|
2149
|
+
Appointment.create({
|
|
2150
|
+
_id: appointmentId,
|
|
2151
|
+
tenant_id: session.tenant_id,
|
|
2152
|
+
chat_id: session.chat_id,
|
|
2153
|
+
session_id: session._id,
|
|
2154
|
+
google_event_id: result.event_id,
|
|
2155
|
+
patient_name: patientName,
|
|
2156
|
+
patient_phone: patientPhone,
|
|
2157
|
+
start_time: new Date(result.start),
|
|
2158
|
+
end_time: new Date(result.end),
|
|
2159
|
+
duration_minutes: gcal.appointment_duration_minutes,
|
|
2160
|
+
notes,
|
|
2161
|
+
status: 'scheduled',
|
|
2162
|
+
}).catch(err => logger.error('Failed to persist rescheduled appointment', { error: (err as Error).message }));
|
|
2163
|
+
|
|
2164
|
+
// SMS notification to doctor (fire-and-forget)
|
|
2165
|
+
const smsSettings = tenant.settings?.whatsapp_agent;
|
|
2166
|
+
if (smsSettings?.sms_notification_enabled && smsSettings.sms_notification_phones?.length) {
|
|
2167
|
+
const tz = gcal.timezone || 'Europe/Istanbul';
|
|
2168
|
+
const newStartDt = new Date(result.start);
|
|
2169
|
+
netgsmService.notifyAppointmentRescheduled({
|
|
2170
|
+
phones: smsSettings.sms_notification_phones,
|
|
2171
|
+
patientName,
|
|
2172
|
+
patientPhone,
|
|
2173
|
+
oldDate: oldAppt
|
|
2174
|
+
? oldAppt.start_time.toLocaleDateString('tr-TR', { day: 'numeric', month: 'long', weekday: 'long', timeZone: tz })
|
|
2175
|
+
: 'Bilinmiyor',
|
|
2176
|
+
oldTime: oldAppt
|
|
2177
|
+
? oldAppt.start_time.toLocaleTimeString('tr-TR', { hour: '2-digit', minute: '2-digit', timeZone: tz })
|
|
2178
|
+
: '',
|
|
2179
|
+
newDate: newStartDt.toLocaleDateString('tr-TR', { day: 'numeric', month: 'long', weekday: 'long', timeZone: tz }),
|
|
2180
|
+
newTime: newStartDt.toLocaleTimeString('tr-TR', { hour: '2-digit', minute: '2-digit', timeZone: tz }),
|
|
2181
|
+
detailUrl: `${config.google.frontendUrl}/appointments/${appointmentId}`,
|
|
2182
|
+
}).catch(err => logger.error('SMS notification failed (reschedule)', { error: (err as Error).message }));
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2185
|
+
return JSON.stringify({
|
|
2186
|
+
rescheduled: true,
|
|
2187
|
+
old_event_id: oldEventId,
|
|
2188
|
+
new_event_id: result.event_id,
|
|
2189
|
+
start: result.start,
|
|
2190
|
+
end: result.end,
|
|
2191
|
+
patient_name: patientName,
|
|
2192
|
+
});
|
|
2193
|
+
} catch (err) {
|
|
2194
|
+
return JSON.stringify({ error: `Eski randevu iptal edildi ancak yeni randevu oluşturulamadı: ${(err as Error).message}. Lütfen tekrar deneyin.` });
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
private async toolCheckPatientHistory(
|
|
2199
|
+
conn: ActiveConnection,
|
|
2200
|
+
_parameters: Record<string, unknown>,
|
|
2201
|
+
): Promise<string> {
|
|
2202
|
+
const session = await WhatsAppSession.findById(conn.sessionId);
|
|
2203
|
+
if (!session) return JSON.stringify({ error: 'Session not found' });
|
|
2204
|
+
|
|
2205
|
+
const chat = await WhatsAppChat.findById(conn.chatId);
|
|
2206
|
+
const patientPhone = chat?.contact_phone || '';
|
|
2207
|
+
if (!patientPhone) {
|
|
2208
|
+
return JSON.stringify({ error: 'Patient phone not available' });
|
|
2209
|
+
}
|
|
2210
|
+
|
|
2211
|
+
const tenantId = session.tenant_id;
|
|
2212
|
+
|
|
2213
|
+
// Find all chats for this patient phone under this tenant
|
|
2214
|
+
const patientChats = await WhatsAppChat.find(
|
|
2215
|
+
{ tenant_id: tenantId, contact_phone: patientPhone },
|
|
2216
|
+
{ _id: 1 },
|
|
2217
|
+
).lean();
|
|
2218
|
+
const chatIds = patientChats.map(c => c._id);
|
|
2219
|
+
|
|
2220
|
+
// Parallel queries
|
|
2221
|
+
const [contactProfile, appointments, operatorRequests, sessions] = await Promise.all([
|
|
2222
|
+
// Contact profile
|
|
2223
|
+
WhatsAppContactProfile.findOne(
|
|
2224
|
+
{ tenant_id: tenantId, phone: patientPhone },
|
|
2225
|
+
{ display_name: 1, notes: 1, tags: 1, ai_summary: 1, preferred_language: 1, metadata: 1, first_seen_at: 1, last_seen_at: 1, total_sessions: 1, total_messages: 1 },
|
|
2226
|
+
).lean(),
|
|
2227
|
+
|
|
2228
|
+
// Appointments: last 10 (any status)
|
|
2229
|
+
Appointment.find(
|
|
2230
|
+
{ tenant_id: tenantId, patient_phone: patientPhone },
|
|
2231
|
+
{ start_time: 1, end_time: 1, duration_minutes: 1, status: 1, notes: 1, cancelled_at: 1, createdAt: 1 },
|
|
2232
|
+
).sort({ start_time: -1 }).limit(10).lean(),
|
|
2233
|
+
|
|
2234
|
+
// Operator requests (photo reviews etc.): last 10
|
|
2235
|
+
OperatorRequest.find(
|
|
2236
|
+
{ tenant_id: tenantId, chat_id: { $in: chatIds } },
|
|
2237
|
+
{ ref_code: 1, request_type: 1, status: 1, operator_response: 1, forwarded_at: 1, responded_at: 1 },
|
|
2238
|
+
).sort({ createdAt: -1 }).limit(10).lean(),
|
|
2239
|
+
|
|
2240
|
+
// Past sessions: last 10
|
|
2241
|
+
WhatsAppSession.find(
|
|
2242
|
+
{ tenant_id: tenantId, chat_id: { $in: chatIds } },
|
|
2243
|
+
{ status: 1, started_at: 1, resolved_at: 1, resolved_by: 1, message_count: 1 },
|
|
2244
|
+
).sort({ started_at: -1 }).limit(10).lean(),
|
|
2245
|
+
]);
|
|
2246
|
+
|
|
2247
|
+
const now = new Date();
|
|
2248
|
+
|
|
2249
|
+
return JSON.stringify({
|
|
2250
|
+
patient_phone: patientPhone,
|
|
2251
|
+
contact_profile: contactProfile ? {
|
|
2252
|
+
display_name: contactProfile.display_name,
|
|
2253
|
+
notes: contactProfile.notes || undefined,
|
|
2254
|
+
tags: contactProfile.tags.length > 0 ? contactProfile.tags : undefined,
|
|
2255
|
+
ai_summary: contactProfile.ai_summary || undefined,
|
|
2256
|
+
preferred_language: contactProfile.preferred_language || undefined,
|
|
2257
|
+
metadata: contactProfile.metadata && Object.keys(contactProfile.metadata).length > 0 ? contactProfile.metadata : undefined,
|
|
2258
|
+
first_seen: contactProfile.first_seen_at?.toISOString().split('T')[0],
|
|
2259
|
+
last_seen: contactProfile.last_seen_at?.toISOString().split('T')[0],
|
|
2260
|
+
total_sessions: contactProfile.total_sessions,
|
|
2261
|
+
total_messages: contactProfile.total_messages,
|
|
2262
|
+
} : null,
|
|
2263
|
+
appointments: appointments.map(a => ({
|
|
2264
|
+
date: a.start_time.toISOString().split('T')[0],
|
|
2265
|
+
time: a.start_time.toISOString().split('T')[1].substring(0, 5),
|
|
2266
|
+
duration_minutes: a.duration_minutes,
|
|
2267
|
+
status: a.status,
|
|
2268
|
+
notes: a.notes || undefined,
|
|
2269
|
+
is_upcoming: a.start_time > now && a.status === 'scheduled',
|
|
2270
|
+
})),
|
|
2271
|
+
operator_requests: operatorRequests.map(r => ({
|
|
2272
|
+
ref_code: r.ref_code,
|
|
2273
|
+
type: r.request_type,
|
|
2274
|
+
status: r.status,
|
|
2275
|
+
response: r.operator_response || undefined,
|
|
2276
|
+
date: r.forwarded_at.toISOString().split('T')[0],
|
|
2277
|
+
})),
|
|
2278
|
+
total_sessions: sessions.length,
|
|
2279
|
+
total_appointments: appointments.length,
|
|
2280
|
+
total_cancelled: appointments.filter(a => a.status === 'cancelled').length,
|
|
2281
|
+
upcoming_appointments: appointments.filter(a => a.start_time > now && a.status === 'scheduled').length,
|
|
2282
|
+
});
|
|
2283
|
+
}
|
|
2284
|
+
|
|
2285
|
+
// ─── Operator Workflow ────────────────────────────────────
|
|
2286
|
+
|
|
2287
|
+
/**
|
|
2288
|
+
* Inject the operator's (doctor's) response into the patient's AI session.
|
|
2289
|
+
* Creates a new WS connection if the previous one timed out during the wait.
|
|
2290
|
+
*/
|
|
2291
|
+
async injectOperatorResponse(params: {
|
|
2292
|
+
tenantId: string;
|
|
2293
|
+
sessionId: string;
|
|
2294
|
+
chatId: string;
|
|
2295
|
+
operatorResponse: string;
|
|
2296
|
+
refCode: string;
|
|
2297
|
+
hasDocument?: boolean;
|
|
2298
|
+
}): Promise<void> {
|
|
2299
|
+
const { tenantId, sessionId, chatId, operatorResponse, refCode, hasDocument } = params;
|
|
2300
|
+
|
|
2301
|
+
// Reload session and chat from DB
|
|
2302
|
+
const session = await WhatsAppSession.findById(sessionId);
|
|
2303
|
+
if (!session || session.status === 'resolved') {
|
|
2304
|
+
logger.warn('Session not found or already resolved for operator response', { sessionId, refCode });
|
|
2305
|
+
return;
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2308
|
+
const chat = await WhatsAppChat.findById(chatId);
|
|
2309
|
+
if (!chat) {
|
|
2310
|
+
logger.warn('Chat not found for operator response', { chatId, refCode });
|
|
2311
|
+
return;
|
|
2312
|
+
}
|
|
2313
|
+
|
|
2314
|
+
// Resolve agent ID
|
|
2315
|
+
const tenant = await Tenant.findById(tenantId).lean();
|
|
2316
|
+
if (!tenant) return;
|
|
2317
|
+
const agentId = await resolveAgentId(tenant);
|
|
2318
|
+
if (!agentId) {
|
|
2319
|
+
logger.warn('No active agent for operator response injection', { tenantId, refCode });
|
|
2320
|
+
return;
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
const unipileChatId = chat.unipile_chat_id;
|
|
2324
|
+
let conn = this.connections.get(unipileChatId);
|
|
2325
|
+
const isAlive = conn && conn.ws.readyState === WebSocket.OPEN && conn.ready;
|
|
2326
|
+
let isNewConnection = false;
|
|
2327
|
+
|
|
2328
|
+
if (!isAlive) {
|
|
2329
|
+
// Connection died while waiting for doctor — recreate it
|
|
2330
|
+
if (conn) {
|
|
2331
|
+
if (conn.pingTimer) { clearInterval(conn.pingTimer); conn.pingTimer = null; }
|
|
2332
|
+
conn.intentionallyClosed = true;
|
|
2333
|
+
try { conn.ws.close(); } catch { /* ignore */ }
|
|
2334
|
+
this.connections.delete(unipileChatId);
|
|
2335
|
+
}
|
|
2336
|
+
try {
|
|
2337
|
+
conn = await this.createConnection(agentId, chat, session, { isReconnect: true });
|
|
2338
|
+
this.connections.set(unipileChatId, conn);
|
|
2339
|
+
isNewConnection = true;
|
|
2340
|
+
} catch (err) {
|
|
2341
|
+
logger.error('Failed to create EL connection for operator response', {
|
|
2342
|
+
error: (err as Error).message,
|
|
2343
|
+
sessionId,
|
|
2344
|
+
refCode,
|
|
2345
|
+
});
|
|
2346
|
+
return;
|
|
2347
|
+
}
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
conn!.lastActivity = Date.now();
|
|
2351
|
+
await WhatsAppSession.updateOne(
|
|
2352
|
+
{ _id: session._id },
|
|
2353
|
+
{ elevenlabs_last_interaction_at: new Date() }
|
|
2354
|
+
);
|
|
2355
|
+
|
|
2356
|
+
// Send chat context as backup (continuation directive handles it in system prompt if enabled)
|
|
2357
|
+
if (isNewConnection) {
|
|
2358
|
+
await this.sendChatContext(conn!, chat._id.toString(), sessionId);
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
// Inject the operator's response
|
|
2362
|
+
const docNote = hasDocument
|
|
2363
|
+
? ' Ayrıca hastaya bir belge/dosya da gönderildi — hastaya belgeyi kontrol etmesini söyle.'
|
|
2364
|
+
: '';
|
|
2365
|
+
conn!.ws.send(JSON.stringify({
|
|
2366
|
+
type: 'contextual_update',
|
|
2367
|
+
text: `Estetisyen yanıt verdi (Ref: ${refCode}): ${operatorResponse}.${docNote} Bu bilgiyi hastaya doğal ve sıcak bir şekilde ilet.`,
|
|
2368
|
+
}));
|
|
2369
|
+
|
|
2370
|
+
// Send user_message to prod AI to generate a response
|
|
2371
|
+
conn!.ws.send(JSON.stringify({
|
|
2372
|
+
type: 'user_message',
|
|
2373
|
+
text: 'Estetisyenden yanıt geldi.',
|
|
2374
|
+
}));
|
|
2375
|
+
|
|
2376
|
+
// Clear awaiting_operator flag
|
|
2377
|
+
session.awaiting_operator = false;
|
|
2378
|
+
await session.save();
|
|
2379
|
+
|
|
2380
|
+
logger.info('Injected operator response into AI session', {
|
|
2381
|
+
sessionId,
|
|
2382
|
+
refCode,
|
|
2383
|
+
isNewConnection,
|
|
2384
|
+
responsePreview: operatorResponse.substring(0, 100),
|
|
2385
|
+
});
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2388
|
+
/**
|
|
2389
|
+
* Sweep expired operator requests: set to 'expired', clear awaiting_operator,
|
|
2390
|
+
* and notify the AI to inform the patient.
|
|
2391
|
+
*/
|
|
2392
|
+
private async sweepExpiredOperatorRequests(): Promise<void> {
|
|
2393
|
+
const expiredRequests = await OperatorRequest.find({
|
|
2394
|
+
status: 'pending',
|
|
2395
|
+
expires_at: { $lt: new Date() },
|
|
2396
|
+
}).lean();
|
|
2397
|
+
|
|
2398
|
+
for (const request of expiredRequests) {
|
|
2399
|
+
await OperatorRequest.updateOne(
|
|
2400
|
+
{ _id: request._id },
|
|
2401
|
+
{ status: 'expired' },
|
|
2402
|
+
);
|
|
2403
|
+
|
|
2404
|
+
// Clear awaiting_operator on the session
|
|
2405
|
+
const session = await WhatsAppSession.findById(request.session_id);
|
|
2406
|
+
if (session && session.status === 'active') {
|
|
2407
|
+
session.awaiting_operator = false;
|
|
2408
|
+
await session.save();
|
|
2409
|
+
|
|
2410
|
+
// Try to notify AI
|
|
2411
|
+
const chat = await WhatsAppChat.findById(request.chat_id);
|
|
2412
|
+
if (chat) {
|
|
2413
|
+
const conn = this.connections.get(chat.unipile_chat_id);
|
|
2414
|
+
if (conn && conn.ws.readyState === WebSocket.OPEN && conn.ready) {
|
|
2415
|
+
conn.ws.send(JSON.stringify({
|
|
2416
|
+
type: 'contextual_update',
|
|
2417
|
+
text: `Doktordan yanıt alınamadı (Ref: ${request.ref_code} süresi doldu). Hastaya durumu bildir ve daha sonra dönüş yapılacağını söyle.`,
|
|
2418
|
+
}));
|
|
2419
|
+
conn.ws.send(JSON.stringify({
|
|
2420
|
+
type: 'user_message',
|
|
2421
|
+
text: 'Doktordan henüz yanıt gelmedi.',
|
|
2422
|
+
}));
|
|
2423
|
+
}
|
|
2424
|
+
}
|
|
2425
|
+
}
|
|
2426
|
+
|
|
2427
|
+
logger.info('Expired operator request', {
|
|
2428
|
+
refCode: request.ref_code,
|
|
2429
|
+
sessionId: request.session_id,
|
|
2430
|
+
});
|
|
2431
|
+
}
|
|
2432
|
+
|
|
2433
|
+
if (expiredRequests.length > 0) {
|
|
2434
|
+
logger.info('Operator request expiry sweep complete', { expired: expiredRequests.length });
|
|
2435
|
+
}
|
|
2436
|
+
}
|
|
2437
|
+
|
|
2438
|
+
private cleanupIdle(): void {
|
|
2439
|
+
const now = Date.now();
|
|
2440
|
+
for (const [unipileChatId, conn] of this.connections) {
|
|
2441
|
+
if (now - conn.lastActivity > this.idleTimeoutMs) {
|
|
2442
|
+
logger.info('Closing idle EL connection', { unipileChatId, sessionId: conn.sessionId });
|
|
2443
|
+
if (conn.pingTimer) { clearInterval(conn.pingTimer); conn.pingTimer = null; }
|
|
2444
|
+
conn.intentionallyClosed = true;
|
|
2445
|
+
conn.ws.close();
|
|
2446
|
+
this.connections.delete(unipileChatId);
|
|
2447
|
+
}
|
|
2448
|
+
}
|
|
2449
|
+
}
|
|
2450
|
+
|
|
2451
|
+
getActiveConnectionCount(): number {
|
|
2452
|
+
return this.connections.size;
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2455
|
+
shutdown(): void {
|
|
2456
|
+
if (this.cleanupInterval) clearInterval(this.cleanupInterval);
|
|
2457
|
+
if (this.sweepInterval) clearInterval(this.sweepInterval);
|
|
2458
|
+
if (this.inactivitySweepInterval) clearInterval(this.inactivitySweepInterval);
|
|
2459
|
+
if (this.costSweepInterval) clearInterval(this.costSweepInterval);
|
|
2460
|
+
for (const [, timer] of this.sessionTimers) {
|
|
2461
|
+
clearTimeout(timer);
|
|
2462
|
+
}
|
|
2463
|
+
this.sessionTimers.clear();
|
|
2464
|
+
for (const [, timer] of this.messageBufferTimers) {
|
|
2465
|
+
clearTimeout(timer);
|
|
2466
|
+
}
|
|
2467
|
+
this.messageBufferTimers.clear();
|
|
2468
|
+
for (const [, conn] of this.connections) {
|
|
2469
|
+
if (conn.pingTimer) clearInterval(conn.pingTimer);
|
|
2470
|
+
conn.intentionallyClosed = true;
|
|
2471
|
+
conn.ws.close();
|
|
2472
|
+
}
|
|
2473
|
+
this.connections.clear();
|
|
2474
|
+
logger.info('WhatsAppAgentService shut down');
|
|
2475
|
+
}
|
|
2476
|
+
}
|
|
2477
|
+
|
|
2478
|
+
const whatsappAgentService = new WhatsAppAgentService();
|
|
2479
|
+
export default whatsappAgentService;
|