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,2506 @@
|
|
|
1
|
+
import { Inject, Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
|
|
2
|
+
import { ConfigService } from '@nestjs/config';
|
|
3
|
+
import { InjectQueue } from '@nestjs/bullmq';
|
|
4
|
+
import { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
|
|
5
|
+
import type { Queue } from 'bullmq';
|
|
6
|
+
import WebSocket from 'ws';
|
|
7
|
+
import type Redis from 'ioredis';
|
|
8
|
+
import { PrismaService } from '../database/prisma.service.js';
|
|
9
|
+
import { EncryptionService } from '../integrations/encryption/encryption.service.js';
|
|
10
|
+
import { ElevenLabsService } from '../integrations/elevenlabs/elevenlabs.service.js';
|
|
11
|
+
import { MetaWhatsAppService } from '../integrations/meta-business/meta-whatsapp.service.js';
|
|
12
|
+
import { MetaInstagramService } from '../integrations/meta-business/meta-instagram.service.js';
|
|
13
|
+
import { MinioService } from '../integrations/minio/minio.service.js';
|
|
14
|
+
import { ElevenLabsWsAdapter } from '../tools/adapters/elevenlabs-ws.adapter.js';
|
|
15
|
+
import { REDIS_CLIENT } from '../redis/redis.module.js';
|
|
16
|
+
import type { ToolContext } from '../tools/tool.interfaces.js';
|
|
17
|
+
|
|
18
|
+
/* ------------------------------------------------------------------ */
|
|
19
|
+
/* Types */
|
|
20
|
+
/* ------------------------------------------------------------------ */
|
|
21
|
+
|
|
22
|
+
export interface IncomingMessagePayload {
|
|
23
|
+
/** Tenant ID resolved from webhook/phone number */
|
|
24
|
+
tenantId: string;
|
|
25
|
+
/** Clinic ID resolved from phone number mapping */
|
|
26
|
+
clinicId: string;
|
|
27
|
+
/** WhatsApp or Instagram external chat ID */
|
|
28
|
+
externalChatId: string;
|
|
29
|
+
/** Channel: whatsapp | instagram */
|
|
30
|
+
channel: 'whatsapp' | 'instagram';
|
|
31
|
+
/** Phone number ID (Meta Business) */
|
|
32
|
+
phoneNumberId?: string;
|
|
33
|
+
/** External account ID (for isSender echo detection) */
|
|
34
|
+
externalAccountId?: string;
|
|
35
|
+
/** Whether this message was sent by our own account */
|
|
36
|
+
isSender?: boolean;
|
|
37
|
+
/** Sender info */
|
|
38
|
+
sender: {
|
|
39
|
+
phone: string;
|
|
40
|
+
name?: string;
|
|
41
|
+
profilePicUrl?: string;
|
|
42
|
+
};
|
|
43
|
+
/** Message content */
|
|
44
|
+
message: {
|
|
45
|
+
externalId: string;
|
|
46
|
+
text: string;
|
|
47
|
+
mediaType?: string;
|
|
48
|
+
mediaKey?: string;
|
|
49
|
+
mediaMime?: string;
|
|
50
|
+
mediaFilename?: string;
|
|
51
|
+
/** Content type from the platform (e.g. 'audioMessage', 'audio') */
|
|
52
|
+
contentType?: string;
|
|
53
|
+
quotedText?: string;
|
|
54
|
+
adContext?: Record<string, any>;
|
|
55
|
+
/** Structured metadata for non-text messages (location, contacts, reaction, button reply, etc.) */
|
|
56
|
+
metadata?: Record<string, any>;
|
|
57
|
+
};
|
|
58
|
+
/** Timestamp from the platform */
|
|
59
|
+
timestamp: Date;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Resolved agent with all config needed for any AI provider */
|
|
63
|
+
interface ResolvedAgent {
|
|
64
|
+
agent_id: string;
|
|
65
|
+
agent_elevenlabs_id: string;
|
|
66
|
+
agent_name: string;
|
|
67
|
+
agent_system_prompt: string;
|
|
68
|
+
agent_ai_provider: string | null;
|
|
69
|
+
agent_ai_model: string | null;
|
|
70
|
+
agent_language: string;
|
|
71
|
+
agent_greeting: string;
|
|
72
|
+
agent_temperature: number;
|
|
73
|
+
agent_max_tokens: number;
|
|
74
|
+
agent_enabled_tools: string[];
|
|
75
|
+
agent_channels: string[];
|
|
76
|
+
agent_extra_config: any;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** In-memory ElevenLabs WS connection state */
|
|
80
|
+
interface WsConnection {
|
|
81
|
+
ws: WebSocket;
|
|
82
|
+
agentId: string;
|
|
83
|
+
tenantId: string;
|
|
84
|
+
sessionId: string;
|
|
85
|
+
chatId: string;
|
|
86
|
+
conversationId?: string;
|
|
87
|
+
isReady: boolean;
|
|
88
|
+
readyPromise: Promise<void>;
|
|
89
|
+
readyResolve: () => void;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/* ------------------------------------------------------------------ */
|
|
93
|
+
/* System message patterns — Turkish + English */
|
|
94
|
+
/* ------------------------------------------------------------------ */
|
|
95
|
+
|
|
96
|
+
const SYSTEM_MESSAGE_PATTERNS: RegExp[] = [
|
|
97
|
+
/sesli arama/i,
|
|
98
|
+
/görüntülü arama/i,
|
|
99
|
+
/voice call/i,
|
|
100
|
+
/video call/i,
|
|
101
|
+
/Bu mesaj silindi/i,
|
|
102
|
+
/This message was deleted/i,
|
|
103
|
+
/kaybolan mesaj/i,
|
|
104
|
+
/disappearing message/i,
|
|
105
|
+
/uçtan uca şifrel/i,
|
|
106
|
+
/end-to-end encrypt/i,
|
|
107
|
+
/canlı konum/i,
|
|
108
|
+
/live location/i,
|
|
109
|
+
/ödeme/i,
|
|
110
|
+
/grup.*(oluştur|kurdu|değiştir|ekledi|çıkardı)/i,
|
|
111
|
+
/bir kez görüntüle/i,
|
|
112
|
+
/view once/i,
|
|
113
|
+
/WhatsApp.*(güncelle|update)/i,
|
|
114
|
+
/missed.*call/i,
|
|
115
|
+
/cevapsız.*arama/i,
|
|
116
|
+
/güvenlik kodu değişti/i,
|
|
117
|
+
/security code changed/i,
|
|
118
|
+
/numara değişti/i,
|
|
119
|
+
/changed.*(number|phone)/i,
|
|
120
|
+
/kişiyi engelle/i,
|
|
121
|
+
/blocked this contact/i,
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The main AI chat engine.
|
|
126
|
+
* Manages WhatsApp/Instagram chat sessions, ElevenLabs AI WebSocket
|
|
127
|
+
* connections, grace periods, and message buffering.
|
|
128
|
+
*/
|
|
129
|
+
@Injectable()
|
|
130
|
+
export class WhatsAppAgentService implements OnModuleDestroy {
|
|
131
|
+
private readonly logger = new Logger(WhatsAppAgentService.name);
|
|
132
|
+
private readonly messageEncryptionKey: string;
|
|
133
|
+
private readonly tokenEncryptionKey: string;
|
|
134
|
+
private readonly patientSummaryKey: string;
|
|
135
|
+
|
|
136
|
+
/** In-memory WebSocket connections keyed by chatId */
|
|
137
|
+
private readonly wsConnections = new Map<string, WsConnection>();
|
|
138
|
+
|
|
139
|
+
constructor(
|
|
140
|
+
private readonly prisma: PrismaService,
|
|
141
|
+
private readonly encryption: EncryptionService,
|
|
142
|
+
private readonly config: ConfigService,
|
|
143
|
+
private readonly eventEmitter: EventEmitter2,
|
|
144
|
+
private readonly elevenlabsService: ElevenLabsService,
|
|
145
|
+
private readonly metaWhatsApp: MetaWhatsAppService,
|
|
146
|
+
private readonly metaInstagram: MetaInstagramService,
|
|
147
|
+
private readonly minio: MinioService,
|
|
148
|
+
private readonly wsAdapter: ElevenLabsWsAdapter,
|
|
149
|
+
@Inject(REDIS_CLIENT) private readonly redis: Redis,
|
|
150
|
+
@InjectQueue('whatsapp-grace')
|
|
151
|
+
private readonly graceQueue: Queue,
|
|
152
|
+
@InjectQueue('whatsapp-message-buffer')
|
|
153
|
+
private readonly messageBufferQueue: Queue,
|
|
154
|
+
@InjectQueue('whatsapp-session-cleanup')
|
|
155
|
+
private readonly sessionCleanupQueue: Queue,
|
|
156
|
+
@InjectQueue('whatsapp-profile-update')
|
|
157
|
+
private readonly profileUpdateQueue: Queue,
|
|
158
|
+
@InjectQueue('whatsapp-session-labels')
|
|
159
|
+
private readonly sessionLabelsQueue: Queue,
|
|
160
|
+
) {
|
|
161
|
+
this.messageEncryptionKey = this.config.getOrThrow<string>(
|
|
162
|
+
'MESSAGE_ENCRYPTION_KEY',
|
|
163
|
+
);
|
|
164
|
+
this.tokenEncryptionKey = this.config.get<string>(
|
|
165
|
+
'META_TOKEN_ENCRYPTION_KEY',
|
|
166
|
+
'',
|
|
167
|
+
);
|
|
168
|
+
this.patientSummaryKey = this.config.get<string>(
|
|
169
|
+
'PATIENT_SUMMARY_ENCRYPTION_KEY',
|
|
170
|
+
'',
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
onModuleDestroy() {
|
|
175
|
+
// Close all open WebSocket connections on shutdown
|
|
176
|
+
for (const [chatId] of this.wsConnections) {
|
|
177
|
+
this.closeConnection(chatId);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/* ================================================================== */
|
|
182
|
+
/* INCOMING MESSAGE — main entry point from webhooks */
|
|
183
|
+
/* ================================================================== */
|
|
184
|
+
|
|
185
|
+
async handleIncomingMessage(payload: IncomingMessagePayload): Promise<void> {
|
|
186
|
+
const {
|
|
187
|
+
tenantId,
|
|
188
|
+
clinicId,
|
|
189
|
+
externalChatId,
|
|
190
|
+
channel,
|
|
191
|
+
sender,
|
|
192
|
+
message,
|
|
193
|
+
} = payload;
|
|
194
|
+
|
|
195
|
+
this.logger.debug(
|
|
196
|
+
`Incoming ${channel} message from ${sender.phone} in chat ${externalChatId}`,
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
// ── 1. System message filtering ──────────────────────────────────
|
|
200
|
+
if (this.isSystemMessage(message.text)) {
|
|
201
|
+
this.logger.debug(
|
|
202
|
+
`System message detected, storing without session trigger: "${message.text.substring(0, 60)}"`,
|
|
203
|
+
);
|
|
204
|
+
// Store the message for audit trail but do not trigger a session
|
|
205
|
+
await this.storeSystemMessage(tenantId, externalChatId, message, sender);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ── 2. Echo deduplication ────────────────────────────────────────
|
|
210
|
+
if (payload.isSender) {
|
|
211
|
+
const echoHandled = await this.handleEchoMessage(
|
|
212
|
+
tenantId,
|
|
213
|
+
externalChatId,
|
|
214
|
+
message.externalId,
|
|
215
|
+
);
|
|
216
|
+
if (echoHandled) return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── 3. Resolve phone number → clinic ─────────────────────────────
|
|
220
|
+
const clinic = await this.prisma.clinic.findUnique({
|
|
221
|
+
where: { clinic_id: clinicId },
|
|
222
|
+
select: {
|
|
223
|
+
clinic_id: true,
|
|
224
|
+
clinic_tenant_id: true,
|
|
225
|
+
clinic_blacklisted_numbers: true,
|
|
226
|
+
clinic_grace_period_seconds: true,
|
|
227
|
+
clinic_fixed_first_message: true,
|
|
228
|
+
clinic_language: true,
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
if (!clinic) {
|
|
233
|
+
this.logger.warn(`Clinic ${clinicId} not found, dropping message`);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ── 3b. Voice transcription (STT) ───────────────────────────────
|
|
238
|
+
const contentType = message.contentType || message.mediaType;
|
|
239
|
+
if (
|
|
240
|
+
contentType &&
|
|
241
|
+
(contentType === 'audioMessage' || contentType === 'audio') &&
|
|
242
|
+
message.mediaKey
|
|
243
|
+
) {
|
|
244
|
+
const sttEnabled = await this.isTenantFeatureEnabled(
|
|
245
|
+
tenantId,
|
|
246
|
+
'voice_transcription',
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
if (sttEnabled) {
|
|
250
|
+
try {
|
|
251
|
+
// Download audio from Meta CDN
|
|
252
|
+
const metaConn = await this.prisma.clinicMetaConnection.findFirst({
|
|
253
|
+
where: {
|
|
254
|
+
meta_clinic_id: clinicId,
|
|
255
|
+
meta_type: 'whatsapp',
|
|
256
|
+
meta_is_active: true,
|
|
257
|
+
},
|
|
258
|
+
select: {
|
|
259
|
+
meta_encrypted_access_token: true,
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
if (metaConn?.meta_encrypted_access_token) {
|
|
264
|
+
const accessToken = this.tokenEncryptionKey
|
|
265
|
+
? this.encryption.decrypt(
|
|
266
|
+
metaConn.meta_encrypted_access_token,
|
|
267
|
+
this.tokenEncryptionKey,
|
|
268
|
+
)
|
|
269
|
+
: metaConn.meta_encrypted_access_token;
|
|
270
|
+
|
|
271
|
+
const audioBuffer = await this.metaWhatsApp.downloadMedia(
|
|
272
|
+
message.mediaKey,
|
|
273
|
+
accessToken,
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
const sttResult = await this.elevenlabsService.transcribe(
|
|
277
|
+
audioBuffer,
|
|
278
|
+
clinic.clinic_language || 'tr',
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
if (sttResult.text?.trim()) {
|
|
282
|
+
message.text = sttResult.text;
|
|
283
|
+
message.contentType = 'transcribedAudio';
|
|
284
|
+
message.mediaType = 'transcribedAudio';
|
|
285
|
+
|
|
286
|
+
// Log STT usage (fire-and-forget)
|
|
287
|
+
this.prisma.sttUsage
|
|
288
|
+
.create({
|
|
289
|
+
data: {
|
|
290
|
+
stt_tenant_id: tenantId,
|
|
291
|
+
stt_clinic_id: clinicId,
|
|
292
|
+
stt_duration_seconds: 0, // Meta doesn't provide duration in webhook
|
|
293
|
+
stt_character_count: sttResult.text.length,
|
|
294
|
+
stt_language: clinic.clinic_language || 'tr',
|
|
295
|
+
},
|
|
296
|
+
})
|
|
297
|
+
.catch((err) =>
|
|
298
|
+
this.logger.warn(
|
|
299
|
+
`Failed to log STT usage: ${(err as Error).message}`,
|
|
300
|
+
),
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
this.logger.debug(
|
|
304
|
+
`STT transcribed audio for chat ${externalChatId}: "${sttResult.text.substring(0, 60)}"`,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
} catch (err: any) {
|
|
309
|
+
this.logger.warn(
|
|
310
|
+
`STT transcription failed for chat ${externalChatId}: ${err.message}. Keeping original media label.`,
|
|
311
|
+
);
|
|
312
|
+
// Keep the original media label text — do not modify message
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ── 4. Patient find-or-create ────────────────────────────────────
|
|
318
|
+
const patient = await this.findOrCreatePatient(
|
|
319
|
+
tenantId,
|
|
320
|
+
clinicId,
|
|
321
|
+
sender.phone,
|
|
322
|
+
sender.name,
|
|
323
|
+
channel,
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
// ── 5. Chat find-or-create ───────────────────────────────────────
|
|
327
|
+
const chat = await this.findOrCreateChat(
|
|
328
|
+
tenantId,
|
|
329
|
+
clinicId,
|
|
330
|
+
externalChatId,
|
|
331
|
+
channel,
|
|
332
|
+
patient.patient_id,
|
|
333
|
+
sender,
|
|
334
|
+
payload.phoneNumberId,
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
// ── 6. Blacklist check ───────────────────────────────────────────
|
|
338
|
+
if (clinic.clinic_blacklisted_numbers.includes(sender.phone)) {
|
|
339
|
+
this.logger.debug(`Blacklisted number ${sender.phone} — storing but not triggering`);
|
|
340
|
+
await this.storeEncryptedMessage(
|
|
341
|
+
chat.chat_id,
|
|
342
|
+
chat.chat_active_session_id,
|
|
343
|
+
tenantId,
|
|
344
|
+
message,
|
|
345
|
+
sender,
|
|
346
|
+
);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ── 7. Encrypt and store message ─────────────────────────────────
|
|
351
|
+
const dbMessage = await this.storeEncryptedMessage(
|
|
352
|
+
chat.chat_id,
|
|
353
|
+
chat.chat_active_session_id,
|
|
354
|
+
tenantId,
|
|
355
|
+
message,
|
|
356
|
+
sender,
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
// Update chat metadata
|
|
360
|
+
const chatUpdates: any = {
|
|
361
|
+
chat_last_message_at: new Date(),
|
|
362
|
+
chat_last_message_preview: message.text.substring(0, 100),
|
|
363
|
+
chat_message_count: { increment: 1 },
|
|
364
|
+
chat_contact_name: sender.name || chat.chat_contact_name,
|
|
365
|
+
};
|
|
366
|
+
// For Instagram, update contact_phone with display name if it's still a raw scoped ID
|
|
367
|
+
if (channel === 'instagram' && sender.name && /^\d+$/.test(chat.chat_contact_phone)) {
|
|
368
|
+
chatUpdates.chat_contact_phone = sender.name;
|
|
369
|
+
}
|
|
370
|
+
await this.prisma.whatsappChat.update({
|
|
371
|
+
where: { chat_id: chat.chat_id },
|
|
372
|
+
data: chatUpdates,
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
// ── 8. Session routing ───────────────────────────────────────────
|
|
376
|
+
if (chat.chat_active_session_id) {
|
|
377
|
+
const session = await this.prisma.whatsappSession.findUnique({
|
|
378
|
+
where: { session_id: chat.chat_active_session_id },
|
|
379
|
+
select: {
|
|
380
|
+
session_id: true,
|
|
381
|
+
session_status: true,
|
|
382
|
+
session_taken_over_by_id: true,
|
|
383
|
+
},
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
if (session) {
|
|
387
|
+
if (session.session_status === 'waiting') {
|
|
388
|
+
// Timer already running — just store (message already stored above)
|
|
389
|
+
this.logger.debug(
|
|
390
|
+
`Session ${session.session_id} is waiting, message stored`,
|
|
391
|
+
);
|
|
392
|
+
} else if (session.session_status === 'active') {
|
|
393
|
+
// Taken over by human? Don't trigger AI
|
|
394
|
+
if (session.session_taken_over_by_id) {
|
|
395
|
+
this.logger.debug(
|
|
396
|
+
`Session ${session.session_id} taken over by human, skipping AI`,
|
|
397
|
+
);
|
|
398
|
+
} else {
|
|
399
|
+
// Enqueue message-buffer job (debounce for multi-message bursts)
|
|
400
|
+
await this.bufferMessage(session.session_id, dbMessage.msg_id);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
// If resolved, fall through to new session creation below is skipped
|
|
404
|
+
// because chat_active_session_id exists
|
|
405
|
+
}
|
|
406
|
+
} else {
|
|
407
|
+
// No active session — start a new one
|
|
408
|
+
const session = await this.startSession(
|
|
409
|
+
chat.chat_id,
|
|
410
|
+
tenantId,
|
|
411
|
+
clinicId,
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
// Link message to the new session
|
|
415
|
+
await this.prisma.whatsappMessage.update({
|
|
416
|
+
where: { msg_id: dbMessage.msg_id },
|
|
417
|
+
data: { msg_session_id: session.session_id },
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
// Update chat active session
|
|
421
|
+
await this.prisma.whatsappChat.update({
|
|
422
|
+
where: { chat_id: chat.chat_id },
|
|
423
|
+
data: { chat_active_session_id: session.session_id },
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
// Send fixed first message if configured (fire-and-forget)
|
|
427
|
+
if (clinic.clinic_fixed_first_message) {
|
|
428
|
+
const receiverIdFromChat = externalChatId.split('@')[1];
|
|
429
|
+
this.sendFixedFirstMessage(
|
|
430
|
+
clinicId,
|
|
431
|
+
sender.phone,
|
|
432
|
+
clinic.clinic_fixed_first_message,
|
|
433
|
+
session.session_id,
|
|
434
|
+
chat.chat_id,
|
|
435
|
+
tenantId,
|
|
436
|
+
receiverIdFromChat,
|
|
437
|
+
).catch((err) =>
|
|
438
|
+
this.logger.error(
|
|
439
|
+
`Failed to send fixed first message: ${err.message}`,
|
|
440
|
+
),
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// ── 9. Emit event for real-time dashboard ────────────────────────
|
|
446
|
+
this.eventEmitter.emit('whatsapp.message.received', {
|
|
447
|
+
chatId: chat.chat_id,
|
|
448
|
+
messageId: dbMessage.msg_id,
|
|
449
|
+
tenantId,
|
|
450
|
+
clinicId,
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/* ================================================================== */
|
|
455
|
+
/* START SESSION */
|
|
456
|
+
/* ================================================================== */
|
|
457
|
+
|
|
458
|
+
async startSession(chatId: string, tenantId: string, clinicId: string) {
|
|
459
|
+
const clinic = await this.prisma.clinic.findUnique({
|
|
460
|
+
where: { clinic_id: clinicId },
|
|
461
|
+
select: { clinic_grace_period_seconds: true },
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
const gracePeriodMs = (clinic?.clinic_grace_period_seconds ?? 180) * 1000;
|
|
465
|
+
const graceDeadline = new Date(Date.now() + gracePeriodMs);
|
|
466
|
+
|
|
467
|
+
const session = await this.prisma.whatsappSession.create({
|
|
468
|
+
data: {
|
|
469
|
+
session_chat_id: chatId,
|
|
470
|
+
session_tenant_id: tenantId,
|
|
471
|
+
session_clinic_id: clinicId,
|
|
472
|
+
session_status: 'waiting',
|
|
473
|
+
session_grace_deadline: graceDeadline,
|
|
474
|
+
},
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
this.logger.debug(
|
|
478
|
+
`Created session ${session.session_id} (waiting) for chat ${chatId}, grace=${gracePeriodMs}ms`,
|
|
479
|
+
);
|
|
480
|
+
|
|
481
|
+
// Schedule grace period expiry job
|
|
482
|
+
await this.graceQueue.add(
|
|
483
|
+
'grace-expire',
|
|
484
|
+
{ sessionId: session.session_id },
|
|
485
|
+
{
|
|
486
|
+
delay: gracePeriodMs,
|
|
487
|
+
jobId: `grace-${session.session_id}`,
|
|
488
|
+
removeOnComplete: true,
|
|
489
|
+
removeOnFail: 100,
|
|
490
|
+
},
|
|
491
|
+
);
|
|
492
|
+
|
|
493
|
+
return session;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/* ================================================================== */
|
|
497
|
+
/* ACTIVATE SESSION — transition waiting → active, connect to AI */
|
|
498
|
+
/* ================================================================== */
|
|
499
|
+
|
|
500
|
+
async activateSession(sessionId: string): Promise<void> {
|
|
501
|
+
const session = await this.prisma.whatsappSession.findUnique({
|
|
502
|
+
where: { session_id: sessionId },
|
|
503
|
+
include: {
|
|
504
|
+
chat: {
|
|
505
|
+
select: {
|
|
506
|
+
chat_id: true,
|
|
507
|
+
chat_external_id: true,
|
|
508
|
+
chat_channel: true,
|
|
509
|
+
chat_contact_phone: true,
|
|
510
|
+
chat_contact_name: true,
|
|
511
|
+
chat_phone_id: true,
|
|
512
|
+
},
|
|
513
|
+
},
|
|
514
|
+
clinic: {
|
|
515
|
+
select: {
|
|
516
|
+
clinic_id: true,
|
|
517
|
+
clinic_tenant_id: true,
|
|
518
|
+
clinic_timezone: true,
|
|
519
|
+
},
|
|
520
|
+
},
|
|
521
|
+
},
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
if (!session || session.session_status !== 'waiting') {
|
|
525
|
+
this.logger.warn(
|
|
526
|
+
`Cannot activate session ${sessionId}: not found or not in waiting status`,
|
|
527
|
+
);
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Transition to active
|
|
532
|
+
await this.prisma.whatsappSession.update({
|
|
533
|
+
where: { session_id: sessionId },
|
|
534
|
+
data: { session_status: 'active' },
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
this.logger.debug(`Session ${sessionId} activated`);
|
|
538
|
+
|
|
539
|
+
// Store WS connection metadata in Redis for awareness across processes
|
|
540
|
+
const redisKey = `ws:conn:${session.chat.chat_id}`;
|
|
541
|
+
await this.redis.hmset(redisKey, {
|
|
542
|
+
sessionId,
|
|
543
|
+
tenantId: session.session_tenant_id,
|
|
544
|
+
clinicId: session.session_clinic_id,
|
|
545
|
+
chatId: session.chat.chat_id,
|
|
546
|
+
activatedAt: new Date().toISOString(),
|
|
547
|
+
});
|
|
548
|
+
await this.redis.expire(redisKey, 3600); // 1 hour TTL
|
|
549
|
+
|
|
550
|
+
// Resolve agent for this clinic and trigger initial AI response
|
|
551
|
+
const agent = await this.resolveAgent(
|
|
552
|
+
session.session_clinic_id,
|
|
553
|
+
session.session_tenant_id,
|
|
554
|
+
);
|
|
555
|
+
|
|
556
|
+
if (agent) {
|
|
557
|
+
// Collect all buffered messages from the waiting period
|
|
558
|
+
const bufferedMessages = await this.prisma.whatsappMessage.findMany({
|
|
559
|
+
where: {
|
|
560
|
+
msg_session_id: sessionId,
|
|
561
|
+
msg_sender: 'contact',
|
|
562
|
+
},
|
|
563
|
+
orderBy: { msg_created_at: 'asc' },
|
|
564
|
+
select: { msg_text: true, msg_media_type: true },
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
const texts: string[] = [];
|
|
568
|
+
for (const msg of bufferedMessages) {
|
|
569
|
+
try {
|
|
570
|
+
const decrypted = this.encryption.decrypt(
|
|
571
|
+
msg.msg_text,
|
|
572
|
+
this.messageEncryptionKey,
|
|
573
|
+
);
|
|
574
|
+
if (decrypted.trim()) {
|
|
575
|
+
texts.push(decrypted);
|
|
576
|
+
} else if (msg.msg_media_type) {
|
|
577
|
+
texts.push(`[${msg.msg_media_type}]`);
|
|
578
|
+
}
|
|
579
|
+
} catch {
|
|
580
|
+
texts.push('[mesaj okunamadi]');
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const combinedText = texts.join('\n');
|
|
585
|
+
|
|
586
|
+
if (combinedText.trim()) {
|
|
587
|
+
// Fire-and-forget the AI response trigger
|
|
588
|
+
this.triggerAiResponse(
|
|
589
|
+
sessionId,
|
|
590
|
+
session.chat.chat_id,
|
|
591
|
+
combinedText,
|
|
592
|
+
).catch((err) =>
|
|
593
|
+
this.logger.error(
|
|
594
|
+
`Failed to trigger AI for session ${sessionId}: ${err.message}`,
|
|
595
|
+
err.stack,
|
|
596
|
+
),
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
} else {
|
|
600
|
+
this.logger.warn(
|
|
601
|
+
`No active agent found for clinic ${session.session_clinic_id}, session ${sessionId} won't connect to AI`,
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
this.eventEmitter.emit('whatsapp.session.activated', {
|
|
606
|
+
sessionId,
|
|
607
|
+
chatId: session.session_chat_id,
|
|
608
|
+
tenantId: session.session_tenant_id,
|
|
609
|
+
clinicId: session.session_clinic_id,
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
this.eventEmitter.emit('chat.session.status', {
|
|
613
|
+
sessionId,
|
|
614
|
+
chatId: session.session_chat_id,
|
|
615
|
+
tenantId: session.session_tenant_id,
|
|
616
|
+
status: 'active',
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/* ================================================================== */
|
|
621
|
+
/* RESOLVE SESSION */
|
|
622
|
+
/* ================================================================== */
|
|
623
|
+
|
|
624
|
+
async resolveSession(
|
|
625
|
+
sessionId: string,
|
|
626
|
+
resolvedBy: 'human' | 'ai_timeout' | 'timeout' | 'manual',
|
|
627
|
+
): Promise<void> {
|
|
628
|
+
const session = await this.prisma.whatsappSession.findUnique({
|
|
629
|
+
where: { session_id: sessionId },
|
|
630
|
+
include: {
|
|
631
|
+
chat: {
|
|
632
|
+
select: {
|
|
633
|
+
chat_id: true,
|
|
634
|
+
chat_patient_id: true,
|
|
635
|
+
},
|
|
636
|
+
},
|
|
637
|
+
},
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
if (!session) {
|
|
641
|
+
this.logger.warn(`Session ${sessionId} not found for resolve`);
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
if (session.session_status === 'resolved') {
|
|
646
|
+
this.logger.debug(`Session ${sessionId} already resolved`);
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// Close ElevenLabs WebSocket if connected
|
|
651
|
+
this.closeConnection(session.session_chat_id);
|
|
652
|
+
|
|
653
|
+
// Fetch ElevenLabs conversation cost if we have a conversation ID
|
|
654
|
+
let elCost: number | undefined;
|
|
655
|
+
if (session.session_el_conversation_id) {
|
|
656
|
+
try {
|
|
657
|
+
const costData = await this.elevenlabsService.getConversationCost(
|
|
658
|
+
session.session_el_conversation_id,
|
|
659
|
+
);
|
|
660
|
+
elCost = costData?.total_cost_usd ?? costData?.cost;
|
|
661
|
+
} catch (err: any) {
|
|
662
|
+
this.logger.warn(
|
|
663
|
+
`Failed to fetch conversation cost for ${session.session_el_conversation_id}: ${err.message}`,
|
|
664
|
+
);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Update session status
|
|
669
|
+
await this.prisma.whatsappSession.update({
|
|
670
|
+
where: { session_id: sessionId },
|
|
671
|
+
data: {
|
|
672
|
+
session_status: 'resolved',
|
|
673
|
+
session_resolved_by: resolvedBy,
|
|
674
|
+
session_resolved_at: new Date(),
|
|
675
|
+
...(elCost !== undefined ? { session_el_cost: elCost } : {}),
|
|
676
|
+
},
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
// Clear chat active session
|
|
680
|
+
await this.prisma.whatsappChat.update({
|
|
681
|
+
where: { chat_id: session.session_chat_id },
|
|
682
|
+
data: { chat_active_session_id: null },
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
// Delete Redis WS state
|
|
686
|
+
await this.redis.del(`ws:conn:${session.session_chat_id}`);
|
|
687
|
+
|
|
688
|
+
this.logger.debug(
|
|
689
|
+
`Session ${sessionId} resolved (by: ${resolvedBy})`,
|
|
690
|
+
);
|
|
691
|
+
|
|
692
|
+
// Update patient stats
|
|
693
|
+
if (session.chat.chat_patient_id) {
|
|
694
|
+
this.prisma.patient
|
|
695
|
+
.update({
|
|
696
|
+
where: { patient_id: session.chat.chat_patient_id },
|
|
697
|
+
data: {
|
|
698
|
+
patient_last_contact_at: new Date(),
|
|
699
|
+
patient_total_sessions: { increment: 1 },
|
|
700
|
+
},
|
|
701
|
+
})
|
|
702
|
+
.catch((err) =>
|
|
703
|
+
this.logger.error(
|
|
704
|
+
`Failed to update patient stats: ${err.message}`,
|
|
705
|
+
),
|
|
706
|
+
);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// Schedule post-session jobs
|
|
710
|
+
await Promise.all([
|
|
711
|
+
this.profileUpdateQueue.add(
|
|
712
|
+
'update-profile',
|
|
713
|
+
{
|
|
714
|
+
sessionId,
|
|
715
|
+
tenantId: session.session_tenant_id,
|
|
716
|
+
chatId: session.session_chat_id,
|
|
717
|
+
},
|
|
718
|
+
{ removeOnComplete: true, removeOnFail: 100 },
|
|
719
|
+
),
|
|
720
|
+
this.sessionLabelsQueue.add(
|
|
721
|
+
'extract-labels',
|
|
722
|
+
{
|
|
723
|
+
sessionId,
|
|
724
|
+
tenantId: session.session_tenant_id,
|
|
725
|
+
},
|
|
726
|
+
{ removeOnComplete: true, removeOnFail: 100 },
|
|
727
|
+
),
|
|
728
|
+
]);
|
|
729
|
+
|
|
730
|
+
this.eventEmitter.emit('chat.session.status', {
|
|
731
|
+
sessionId,
|
|
732
|
+
chatId: session.session_chat_id,
|
|
733
|
+
tenantId: session.session_tenant_id,
|
|
734
|
+
status: 'resolved',
|
|
735
|
+
resolvedBy,
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
this.eventEmitter.emit('whatsapp.session.resolved', {
|
|
739
|
+
sessionId,
|
|
740
|
+
chatId: session.session_chat_id,
|
|
741
|
+
tenantId: session.session_tenant_id,
|
|
742
|
+
resolvedBy,
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/* ================================================================== */
|
|
747
|
+
/* TRIGGER AI RESPONSE — connect to ElevenLabs and send message */
|
|
748
|
+
/* ================================================================== */
|
|
749
|
+
|
|
750
|
+
async triggerAiResponse(
|
|
751
|
+
sessionId: string,
|
|
752
|
+
chatId: string,
|
|
753
|
+
combinedText: string,
|
|
754
|
+
): Promise<void> {
|
|
755
|
+
// Load session with all needed relations
|
|
756
|
+
const session = await this.prisma.whatsappSession.findUnique({
|
|
757
|
+
where: { session_id: sessionId },
|
|
758
|
+
include: {
|
|
759
|
+
chat: {
|
|
760
|
+
select: {
|
|
761
|
+
chat_id: true,
|
|
762
|
+
chat_external_id: true,
|
|
763
|
+
chat_contact_phone: true,
|
|
764
|
+
chat_contact_name: true,
|
|
765
|
+
chat_phone_id: true,
|
|
766
|
+
chat_channel: true,
|
|
767
|
+
chat_patient_id: true,
|
|
768
|
+
},
|
|
769
|
+
},
|
|
770
|
+
clinic: {
|
|
771
|
+
select: {
|
|
772
|
+
clinic_id: true,
|
|
773
|
+
clinic_tenant_id: true,
|
|
774
|
+
clinic_timezone: true,
|
|
775
|
+
},
|
|
776
|
+
},
|
|
777
|
+
},
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
if (!session) {
|
|
781
|
+
this.logger.warn(`Session ${sessionId} not found for AI trigger`);
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
if (session.session_status !== 'active') {
|
|
786
|
+
this.logger.debug(
|
|
787
|
+
`Session ${sessionId} not active (${session.session_status}), skipping AI trigger`,
|
|
788
|
+
);
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// If session is taken over by a human, skip AI
|
|
793
|
+
if (session.session_taken_over_by_id) {
|
|
794
|
+
this.logger.debug(
|
|
795
|
+
`Session ${sessionId} is taken over, skipping AI trigger`,
|
|
796
|
+
);
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// Resolve the agent for this clinic
|
|
801
|
+
const agent = await this.resolveAgent(
|
|
802
|
+
session.session_clinic_id,
|
|
803
|
+
session.session_tenant_id,
|
|
804
|
+
);
|
|
805
|
+
|
|
806
|
+
if (!agent) {
|
|
807
|
+
this.logger.warn(
|
|
808
|
+
`No agent found for clinic ${session.session_clinic_id}, cannot trigger AI`,
|
|
809
|
+
);
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// Get or create the WS connection
|
|
814
|
+
const conn = await this.getOrCreateConnection(
|
|
815
|
+
chatId,
|
|
816
|
+
agent.agent_elevenlabs_id,
|
|
817
|
+
session.session_tenant_id,
|
|
818
|
+
sessionId,
|
|
819
|
+
);
|
|
820
|
+
|
|
821
|
+
if (!conn) {
|
|
822
|
+
this.logger.error(
|
|
823
|
+
`Failed to establish WS connection for chat ${chatId}`,
|
|
824
|
+
);
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// Wait for the connection to be ready
|
|
829
|
+
try {
|
|
830
|
+
await conn.readyPromise;
|
|
831
|
+
} catch (err: any) {
|
|
832
|
+
this.logger.error(
|
|
833
|
+
`WS connection for chat ${chatId} failed to become ready: ${err.message}`,
|
|
834
|
+
);
|
|
835
|
+
this.closeConnection(chatId);
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// Build and send chat context
|
|
840
|
+
const chatContext = await this.buildChatContext(sessionId);
|
|
841
|
+
if (chatContext) {
|
|
842
|
+
this.wsSend(conn, {
|
|
843
|
+
type: 'contextual_update',
|
|
844
|
+
text: chatContext,
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// Send the user message
|
|
849
|
+
this.wsSend(conn, {
|
|
850
|
+
type: 'user_message',
|
|
851
|
+
text: combinedText,
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
// Update session last interaction
|
|
855
|
+
await this.prisma.whatsappSession.update({
|
|
856
|
+
where: { session_id: sessionId },
|
|
857
|
+
data: { session_el_last_interaction: new Date() },
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/* ================================================================== */
|
|
862
|
+
/* BUILD CHAT CONTEXT */
|
|
863
|
+
/* ================================================================== */
|
|
864
|
+
|
|
865
|
+
async buildChatContext(sessionId: string): Promise<string> {
|
|
866
|
+
// Fetch all messages in the session
|
|
867
|
+
const messages = await this.prisma.whatsappMessage.findMany({
|
|
868
|
+
where: { msg_session_id: sessionId },
|
|
869
|
+
orderBy: { msg_created_at: 'asc' },
|
|
870
|
+
select: {
|
|
871
|
+
msg_sender: true,
|
|
872
|
+
msg_text: true,
|
|
873
|
+
msg_media_type: true,
|
|
874
|
+
msg_created_at: true,
|
|
875
|
+
},
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
// Fetch operator requests for the session
|
|
879
|
+
const operatorRequests = await this.prisma.operatorRequest.findMany({
|
|
880
|
+
where: { opreq_session_id: sessionId },
|
|
881
|
+
orderBy: { opreq_created_at: 'asc' },
|
|
882
|
+
select: {
|
|
883
|
+
opreq_ref_code: true,
|
|
884
|
+
opreq_status: true,
|
|
885
|
+
opreq_request_message: true,
|
|
886
|
+
opreq_operator_response: true,
|
|
887
|
+
opreq_forwarded_at: true,
|
|
888
|
+
opreq_responded_at: true,
|
|
889
|
+
opreq_created_at: true,
|
|
890
|
+
},
|
|
891
|
+
});
|
|
892
|
+
|
|
893
|
+
// Build timeline entries
|
|
894
|
+
interface TimelineEntry {
|
|
895
|
+
timestamp: Date;
|
|
896
|
+
text: string;
|
|
897
|
+
}
|
|
898
|
+
const timeline: TimelineEntry[] = [];
|
|
899
|
+
|
|
900
|
+
// Add message entries
|
|
901
|
+
for (const msg of messages) {
|
|
902
|
+
let text: string;
|
|
903
|
+
try {
|
|
904
|
+
text = this.encryption.decrypt(msg.msg_text, this.messageEncryptionKey);
|
|
905
|
+
} catch {
|
|
906
|
+
text = msg.msg_media_type ? `[${msg.msg_media_type}]` : '[mesaj okunamadi]';
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
if (!text.trim() && msg.msg_media_type) {
|
|
910
|
+
text = `[${msg.msg_media_type}]`;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
const senderLabel = this.getSenderLabel(msg.msg_sender);
|
|
914
|
+
timeline.push({
|
|
915
|
+
timestamp: msg.msg_created_at,
|
|
916
|
+
text: `${senderLabel}: ${text}`,
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// Add operator request events
|
|
921
|
+
for (const req of operatorRequests) {
|
|
922
|
+
timeline.push({
|
|
923
|
+
timestamp: req.opreq_created_at,
|
|
924
|
+
text: `[SISTEM: Doktora yonlendirildi - REF: ${req.opreq_ref_code}${
|
|
925
|
+
req.opreq_request_message ? ` - Not: ${req.opreq_request_message}` : ''
|
|
926
|
+
}]`,
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
if (req.opreq_status === 'responded' && req.opreq_responded_at) {
|
|
930
|
+
timeline.push({
|
|
931
|
+
timestamp: req.opreq_responded_at,
|
|
932
|
+
text: `[SISTEM: Doktor yaniti alindi - REF: ${req.opreq_ref_code} - Yanit: ${req.opreq_operator_response ?? ''}]`,
|
|
933
|
+
});
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
if (req.opreq_status === 'expired') {
|
|
937
|
+
timeline.push({
|
|
938
|
+
timestamp: req.opreq_responded_at ?? req.opreq_created_at,
|
|
939
|
+
text: `[SISTEM: Doktor yanit suresi doldu - REF: ${req.opreq_ref_code}]`,
|
|
940
|
+
});
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// Sort by timestamp
|
|
945
|
+
timeline.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
|
946
|
+
|
|
947
|
+
// Format the context string
|
|
948
|
+
return timeline.map((entry) => entry.text).join('\n');
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/* ================================================================== */
|
|
952
|
+
/* TAKEOVER — human takes over from AI */
|
|
953
|
+
/* ================================================================== */
|
|
954
|
+
|
|
955
|
+
async takeOver(chatId: string, userId: string): Promise<void> {
|
|
956
|
+
const chat = await this.prisma.whatsappChat.findUnique({
|
|
957
|
+
where: { chat_id: chatId },
|
|
958
|
+
select: { chat_active_session_id: true, chat_tenant_id: true },
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
if (!chat?.chat_active_session_id) {
|
|
962
|
+
this.logger.warn(`No active session on chat ${chatId} for takeover`);
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
const sessionId = chat.chat_active_session_id;
|
|
967
|
+
|
|
968
|
+
// Close AI WebSocket connection
|
|
969
|
+
this.closeConnection(chatId);
|
|
970
|
+
|
|
971
|
+
// Cancel any pending message buffer jobs for this session
|
|
972
|
+
try {
|
|
973
|
+
const jobs = await this.messageBufferQueue.getJobs([
|
|
974
|
+
'waiting',
|
|
975
|
+
'delayed',
|
|
976
|
+
'active',
|
|
977
|
+
]);
|
|
978
|
+
for (const job of jobs) {
|
|
979
|
+
if (job.data?.sessionId === sessionId) {
|
|
980
|
+
await job.remove();
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
} catch (err: any) {
|
|
984
|
+
this.logger.warn(
|
|
985
|
+
`Failed to cancel buffer jobs for session ${sessionId}: ${err.message}`,
|
|
986
|
+
);
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
await this.prisma.whatsappSession.update({
|
|
990
|
+
where: { session_id: sessionId },
|
|
991
|
+
data: {
|
|
992
|
+
session_taken_over_by_id: userId,
|
|
993
|
+
session_taken_over_at: new Date(),
|
|
994
|
+
},
|
|
995
|
+
});
|
|
996
|
+
|
|
997
|
+
this.logger.log(
|
|
998
|
+
`Session ${sessionId} taken over by user ${userId}`,
|
|
999
|
+
);
|
|
1000
|
+
|
|
1001
|
+
this.eventEmitter.emit('whatsapp.session.takeover', {
|
|
1002
|
+
sessionId,
|
|
1003
|
+
chatId,
|
|
1004
|
+
userId,
|
|
1005
|
+
tenantId: chat.chat_tenant_id,
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
this.eventEmitter.emit('chat.session.status', {
|
|
1009
|
+
sessionId,
|
|
1010
|
+
chatId,
|
|
1011
|
+
tenantId: chat.chat_tenant_id,
|
|
1012
|
+
status: 'active',
|
|
1013
|
+
takenOverBy: userId,
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
/* ================================================================== */
|
|
1018
|
+
/* RELEASE — release session back to AI */
|
|
1019
|
+
/* ================================================================== */
|
|
1020
|
+
|
|
1021
|
+
async releaseSession(chatId: string): Promise<void> {
|
|
1022
|
+
const chat = await this.prisma.whatsappChat.findUnique({
|
|
1023
|
+
where: { chat_id: chatId },
|
|
1024
|
+
select: {
|
|
1025
|
+
chat_active_session_id: true,
|
|
1026
|
+
chat_tenant_id: true,
|
|
1027
|
+
},
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
if (!chat?.chat_active_session_id) {
|
|
1031
|
+
this.logger.warn(`No active session on chat ${chatId} for release`);
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
const sessionId = chat.chat_active_session_id;
|
|
1036
|
+
|
|
1037
|
+
await this.prisma.whatsappSession.update({
|
|
1038
|
+
where: { session_id: sessionId },
|
|
1039
|
+
data: {
|
|
1040
|
+
session_taken_over_by_id: null,
|
|
1041
|
+
session_taken_over_at: null,
|
|
1042
|
+
},
|
|
1043
|
+
});
|
|
1044
|
+
|
|
1045
|
+
this.logger.log(`Session ${sessionId} released back to AI`);
|
|
1046
|
+
|
|
1047
|
+
// AI resumes on the next patient message. No need to proactively
|
|
1048
|
+
// reconnect here — the next incoming message will trigger a buffer
|
|
1049
|
+
// job which calls triggerAiResponse.
|
|
1050
|
+
|
|
1051
|
+
this.eventEmitter.emit('whatsapp.session.released', {
|
|
1052
|
+
sessionId,
|
|
1053
|
+
chatId,
|
|
1054
|
+
});
|
|
1055
|
+
|
|
1056
|
+
this.eventEmitter.emit('chat.session.status', {
|
|
1057
|
+
sessionId,
|
|
1058
|
+
chatId,
|
|
1059
|
+
tenantId: chat.chat_tenant_id,
|
|
1060
|
+
status: 'active',
|
|
1061
|
+
takenOverBy: null,
|
|
1062
|
+
});
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
/* ================================================================== */
|
|
1066
|
+
/* SEND HUMAN MESSAGE */
|
|
1067
|
+
/* ================================================================== */
|
|
1068
|
+
|
|
1069
|
+
async sendHumanMessage(
|
|
1070
|
+
chatId: string,
|
|
1071
|
+
userId: string,
|
|
1072
|
+
text: string,
|
|
1073
|
+
): Promise<void> {
|
|
1074
|
+
const chat = await this.prisma.whatsappChat.findUnique({
|
|
1075
|
+
where: { chat_id: chatId },
|
|
1076
|
+
include: {
|
|
1077
|
+
clinic: {
|
|
1078
|
+
select: {
|
|
1079
|
+
clinic_id: true,
|
|
1080
|
+
clinic_tenant_id: true,
|
|
1081
|
+
},
|
|
1082
|
+
},
|
|
1083
|
+
},
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
if (!chat) {
|
|
1087
|
+
this.logger.warn(`Chat ${chatId} not found for sending message`);
|
|
1088
|
+
return;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
// Extract recipient and receiver from external ID (format: sender@receiver)
|
|
1092
|
+
const [recipientId, receiverId] = chat.chat_external_id.split('@');
|
|
1093
|
+
|
|
1094
|
+
// Send message via Meta Business API (WhatsApp or Instagram)
|
|
1095
|
+
const metaResult = chat.chat_channel === 'instagram'
|
|
1096
|
+
? await this.sendViaInstagram(chat.clinic.clinic_id, recipientId, text, receiverId)
|
|
1097
|
+
: await this.sendViaMeta(chat.clinic.clinic_id, recipientId, text, receiverId);
|
|
1098
|
+
|
|
1099
|
+
// Resolve user name
|
|
1100
|
+
let senderName = '';
|
|
1101
|
+
try {
|
|
1102
|
+
const user = await this.prisma.user.findUnique({
|
|
1103
|
+
where: { user_id: userId },
|
|
1104
|
+
select: { user_first_name: true, user_last_name: true },
|
|
1105
|
+
});
|
|
1106
|
+
if (user) {
|
|
1107
|
+
senderName = [user.user_first_name, user.user_last_name]
|
|
1108
|
+
.filter(Boolean)
|
|
1109
|
+
.join(' ');
|
|
1110
|
+
}
|
|
1111
|
+
} catch {
|
|
1112
|
+
// Non-critical
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// Persist the message
|
|
1116
|
+
const encryptedText = this.encryption.encrypt(
|
|
1117
|
+
text,
|
|
1118
|
+
this.messageEncryptionKey,
|
|
1119
|
+
);
|
|
1120
|
+
|
|
1121
|
+
const msg = await this.prisma.whatsappMessage.create({
|
|
1122
|
+
data: {
|
|
1123
|
+
msg_chat_id: chatId,
|
|
1124
|
+
msg_session_id: chat.chat_active_session_id,
|
|
1125
|
+
msg_tenant_id: chat.chat_tenant_id,
|
|
1126
|
+
msg_external_id: metaResult?.messages?.[0]?.id ?? null,
|
|
1127
|
+
msg_sender: 'human',
|
|
1128
|
+
msg_sender_name: senderName,
|
|
1129
|
+
msg_sender_user_id: userId,
|
|
1130
|
+
msg_text: encryptedText,
|
|
1131
|
+
},
|
|
1132
|
+
});
|
|
1133
|
+
|
|
1134
|
+
// Update chat metadata
|
|
1135
|
+
await this.prisma.whatsappChat.update({
|
|
1136
|
+
where: { chat_id: chatId },
|
|
1137
|
+
data: {
|
|
1138
|
+
chat_last_message_at: new Date(),
|
|
1139
|
+
chat_last_message_preview: text.substring(0, 100),
|
|
1140
|
+
chat_message_count: { increment: 1 },
|
|
1141
|
+
},
|
|
1142
|
+
});
|
|
1143
|
+
|
|
1144
|
+
this.eventEmitter.emit('whatsapp.message.sent', {
|
|
1145
|
+
chatId,
|
|
1146
|
+
messageId: msg.msg_id,
|
|
1147
|
+
tenantId: chat.chat_tenant_id,
|
|
1148
|
+
});
|
|
1149
|
+
|
|
1150
|
+
this.eventEmitter.emit('chat.message.created', {
|
|
1151
|
+
chatId,
|
|
1152
|
+
messageId: msg.msg_id,
|
|
1153
|
+
tenantId: chat.chat_tenant_id,
|
|
1154
|
+
sender: 'human',
|
|
1155
|
+
});
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
/* ================================================================== */
|
|
1159
|
+
/* SEND HUMAN MEDIA */
|
|
1160
|
+
/* ================================================================== */
|
|
1161
|
+
|
|
1162
|
+
async sendHumanMedia(
|
|
1163
|
+
chatId: string,
|
|
1164
|
+
userId: string,
|
|
1165
|
+
buffer: Buffer,
|
|
1166
|
+
mimeType: string,
|
|
1167
|
+
filename: string,
|
|
1168
|
+
caption?: string,
|
|
1169
|
+
): Promise<void> {
|
|
1170
|
+
const chat = await this.prisma.whatsappChat.findUnique({
|
|
1171
|
+
where: { chat_id: chatId },
|
|
1172
|
+
include: {
|
|
1173
|
+
clinic: { select: { clinic_id: true, clinic_tenant_id: true } },
|
|
1174
|
+
},
|
|
1175
|
+
});
|
|
1176
|
+
|
|
1177
|
+
if (!chat) {
|
|
1178
|
+
this.logger.warn(`Chat ${chatId} not found for sending media`);
|
|
1179
|
+
return;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
const [recipientId, receiverId] = chat.chat_external_id.split('@');
|
|
1183
|
+
const mediaType = this.resolveMediaType(mimeType);
|
|
1184
|
+
|
|
1185
|
+
let metaResult: any = null;
|
|
1186
|
+
|
|
1187
|
+
if (chat.chat_channel === 'instagram') {
|
|
1188
|
+
// Instagram: upload isn't supported via API, use URL-based approach
|
|
1189
|
+
// Store in MinIO first, then send via URL
|
|
1190
|
+
const minioKey = `chat-media-out/${chat.chat_tenant_id}/${Date.now()}-${filename}`;
|
|
1191
|
+
await this.minio.putObject(minioKey, buffer, mimeType);
|
|
1192
|
+
const mediaUrl = await this.minio.presignedGetObject(minioKey, 86400);
|
|
1193
|
+
|
|
1194
|
+
const metaConn = await this.getInstagramConnection(chat.clinic.clinic_id, receiverId);
|
|
1195
|
+
if (metaConn) {
|
|
1196
|
+
metaResult = await this.metaInstagram.sendMedia(
|
|
1197
|
+
metaConn.pageId,
|
|
1198
|
+
recipientId,
|
|
1199
|
+
mediaUrl,
|
|
1200
|
+
metaConn.token,
|
|
1201
|
+
);
|
|
1202
|
+
}
|
|
1203
|
+
} else {
|
|
1204
|
+
// WhatsApp: upload to Meta first, then send by ID
|
|
1205
|
+
const metaConn = await this.getWhatsAppConnection(chat.clinic.clinic_id, receiverId);
|
|
1206
|
+
if (metaConn) {
|
|
1207
|
+
const mediaId = await this.metaWhatsApp.uploadMedia(
|
|
1208
|
+
metaConn.phoneNumberId,
|
|
1209
|
+
buffer,
|
|
1210
|
+
mimeType,
|
|
1211
|
+
filename,
|
|
1212
|
+
metaConn.token,
|
|
1213
|
+
);
|
|
1214
|
+
metaResult = await this.metaWhatsApp.sendMediaById(
|
|
1215
|
+
metaConn.phoneNumberId,
|
|
1216
|
+
recipientId,
|
|
1217
|
+
mediaType,
|
|
1218
|
+
mediaId,
|
|
1219
|
+
caption,
|
|
1220
|
+
metaConn.token,
|
|
1221
|
+
filename,
|
|
1222
|
+
);
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
// Resolve sender name
|
|
1227
|
+
let senderName = '';
|
|
1228
|
+
try {
|
|
1229
|
+
const user = await this.prisma.user.findUnique({
|
|
1230
|
+
where: { user_id: userId },
|
|
1231
|
+
select: { user_first_name: true, user_last_name: true },
|
|
1232
|
+
});
|
|
1233
|
+
if (user) senderName = [user.user_first_name, user.user_last_name].filter(Boolean).join(' ');
|
|
1234
|
+
} catch {}
|
|
1235
|
+
|
|
1236
|
+
// Store in MinIO for display
|
|
1237
|
+
const minioKey = `chat-media/${chat.chat_tenant_id}/${metaResult?.messages?.[0]?.id || Date.now()}`;
|
|
1238
|
+
this.minio.putObject(minioKey, buffer, mimeType).catch(() => {});
|
|
1239
|
+
|
|
1240
|
+
// Persist message
|
|
1241
|
+
const encryptedCaption = this.encryption.encrypt(
|
|
1242
|
+
caption || '',
|
|
1243
|
+
this.messageEncryptionKey,
|
|
1244
|
+
);
|
|
1245
|
+
|
|
1246
|
+
const msg = await this.prisma.whatsappMessage.create({
|
|
1247
|
+
data: {
|
|
1248
|
+
msg_chat_id: chatId,
|
|
1249
|
+
msg_session_id: chat.chat_active_session_id,
|
|
1250
|
+
msg_tenant_id: chat.chat_tenant_id,
|
|
1251
|
+
msg_external_id: metaResult?.messages?.[0]?.id ?? null,
|
|
1252
|
+
msg_sender: 'human',
|
|
1253
|
+
msg_sender_name: senderName,
|
|
1254
|
+
msg_sender_user_id: userId,
|
|
1255
|
+
msg_text: encryptedCaption,
|
|
1256
|
+
msg_media_type: mediaType,
|
|
1257
|
+
msg_media_key: minioKey,
|
|
1258
|
+
msg_media_mime: mimeType,
|
|
1259
|
+
msg_media_filename: filename,
|
|
1260
|
+
},
|
|
1261
|
+
});
|
|
1262
|
+
|
|
1263
|
+
await this.prisma.whatsappChat.update({
|
|
1264
|
+
where: { chat_id: chatId },
|
|
1265
|
+
data: {
|
|
1266
|
+
chat_last_message_at: new Date(),
|
|
1267
|
+
chat_last_message_preview: caption || `[${mediaType}]`,
|
|
1268
|
+
chat_message_count: { increment: 1 },
|
|
1269
|
+
},
|
|
1270
|
+
});
|
|
1271
|
+
|
|
1272
|
+
this.eventEmitter.emit('chat.message.created', {
|
|
1273
|
+
chatId,
|
|
1274
|
+
messageId: msg.msg_id,
|
|
1275
|
+
tenantId: chat.chat_tenant_id,
|
|
1276
|
+
sender: 'human',
|
|
1277
|
+
});
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
private resolveMediaType(mimeType: string): 'image' | 'video' | 'document' | 'audio' {
|
|
1281
|
+
if (mimeType.startsWith('image/')) return 'image';
|
|
1282
|
+
if (mimeType.startsWith('video/')) return 'video';
|
|
1283
|
+
if (mimeType.startsWith('audio/')) return 'audio';
|
|
1284
|
+
return 'document';
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
private async getWhatsAppConnection(clinicId: string, metaPhoneNumberId?: string) {
|
|
1288
|
+
const where: any = { meta_clinic_id: clinicId, meta_type: 'whatsapp', meta_is_active: true };
|
|
1289
|
+
if (metaPhoneNumberId) where.meta_phone_number_id = metaPhoneNumberId;
|
|
1290
|
+
|
|
1291
|
+
const conn = await this.prisma.clinicMetaConnection.findFirst({
|
|
1292
|
+
where,
|
|
1293
|
+
select: { meta_phone_number_id: true, meta_encrypted_access_token: true },
|
|
1294
|
+
});
|
|
1295
|
+
if (!conn?.meta_phone_number_id || !conn?.meta_encrypted_access_token) return null;
|
|
1296
|
+
|
|
1297
|
+
const token = this.tokenEncryptionKey
|
|
1298
|
+
? this.encryption.decrypt(conn.meta_encrypted_access_token, this.tokenEncryptionKey)
|
|
1299
|
+
: conn.meta_encrypted_access_token;
|
|
1300
|
+
return { phoneNumberId: conn.meta_phone_number_id, token };
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
private async getInstagramConnection(clinicId: string, igPageId?: string) {
|
|
1304
|
+
const where: any = { meta_clinic_id: clinicId, meta_type: 'instagram', meta_is_active: true };
|
|
1305
|
+
if (igPageId) where.meta_instagram_page_id = igPageId;
|
|
1306
|
+
|
|
1307
|
+
const conn = await this.prisma.clinicMetaConnection.findFirst({
|
|
1308
|
+
where,
|
|
1309
|
+
select: { meta_instagram_page_id: true, meta_facebook_page_id: true, meta_encrypted_access_token: true },
|
|
1310
|
+
});
|
|
1311
|
+
const pageId = conn?.meta_facebook_page_id || conn?.meta_instagram_page_id;
|
|
1312
|
+
if (!pageId || !conn?.meta_encrypted_access_token) return null;
|
|
1313
|
+
|
|
1314
|
+
const token = this.tokenEncryptionKey
|
|
1315
|
+
? this.encryption.decrypt(conn.meta_encrypted_access_token, this.tokenEncryptionKey)
|
|
1316
|
+
: conn.meta_encrypted_access_token;
|
|
1317
|
+
return { pageId, token };
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
/* ================================================================== */
|
|
1321
|
+
/* INJECT OPERATOR RESPONSE — called by OperatorService */
|
|
1322
|
+
/* ================================================================== */
|
|
1323
|
+
|
|
1324
|
+
async injectOperatorResponse(
|
|
1325
|
+
sessionId: string,
|
|
1326
|
+
refCode: string,
|
|
1327
|
+
responseText: string,
|
|
1328
|
+
): Promise<void> {
|
|
1329
|
+
const session = await this.prisma.whatsappSession.findUnique({
|
|
1330
|
+
where: { session_id: sessionId },
|
|
1331
|
+
select: {
|
|
1332
|
+
session_id: true,
|
|
1333
|
+
session_status: true,
|
|
1334
|
+
session_chat_id: true,
|
|
1335
|
+
session_tenant_id: true,
|
|
1336
|
+
session_clinic_id: true,
|
|
1337
|
+
},
|
|
1338
|
+
});
|
|
1339
|
+
|
|
1340
|
+
if (!session || session.session_status !== 'active') {
|
|
1341
|
+
this.logger.warn(
|
|
1342
|
+
`Cannot inject operator response for session ${sessionId}: not active`,
|
|
1343
|
+
);
|
|
1344
|
+
return;
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
const conn = this.wsConnections.get(session.session_chat_id);
|
|
1348
|
+
const contextText =
|
|
1349
|
+
`[SISTEM: Doktor yaniti alindi - REF: ${refCode} - Yanit: ${responseText}]`;
|
|
1350
|
+
|
|
1351
|
+
if (conn && conn.ws.readyState === WebSocket.OPEN) {
|
|
1352
|
+
// WS still alive — send contextual update
|
|
1353
|
+
this.wsSend(conn, {
|
|
1354
|
+
type: 'contextual_update',
|
|
1355
|
+
text: contextText,
|
|
1356
|
+
});
|
|
1357
|
+
this.logger.debug(
|
|
1358
|
+
`Injected operator response for ${refCode} into live WS`,
|
|
1359
|
+
);
|
|
1360
|
+
} else {
|
|
1361
|
+
// WS dead — recreate connection with full chat context
|
|
1362
|
+
this.logger.debug(
|
|
1363
|
+
`WS dead for chat ${session.session_chat_id}, recreating for operator response`,
|
|
1364
|
+
);
|
|
1365
|
+
|
|
1366
|
+
const agent = await this.resolveAgent(
|
|
1367
|
+
session.session_clinic_id,
|
|
1368
|
+
session.session_tenant_id,
|
|
1369
|
+
);
|
|
1370
|
+
|
|
1371
|
+
if (!agent) {
|
|
1372
|
+
this.logger.error(
|
|
1373
|
+
`No agent found when recreating WS for operator response`,
|
|
1374
|
+
);
|
|
1375
|
+
return;
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
const newConn = await this.getOrCreateConnection(
|
|
1379
|
+
session.session_chat_id,
|
|
1380
|
+
agent.agent_elevenlabs_id,
|
|
1381
|
+
session.session_tenant_id,
|
|
1382
|
+
sessionId,
|
|
1383
|
+
);
|
|
1384
|
+
|
|
1385
|
+
if (!newConn) return;
|
|
1386
|
+
|
|
1387
|
+
try {
|
|
1388
|
+
await newConn.readyPromise;
|
|
1389
|
+
} catch {
|
|
1390
|
+
this.logger.error(`Failed to reconnect WS for operator response`);
|
|
1391
|
+
return;
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
// Send full chat history as context
|
|
1395
|
+
const chatContext = await this.buildChatContext(sessionId);
|
|
1396
|
+
if (chatContext) {
|
|
1397
|
+
this.wsSend(newConn, { type: 'contextual_update', text: chatContext });
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
// Send the operator response as a user message so the AI acts on it
|
|
1401
|
+
this.wsSend(newConn, {
|
|
1402
|
+
type: 'contextual_update',
|
|
1403
|
+
text: contextText,
|
|
1404
|
+
});
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
/* ================================================================== */
|
|
1409
|
+
/* EVENT LISTENERS */
|
|
1410
|
+
/* ================================================================== */
|
|
1411
|
+
|
|
1412
|
+
/** Handle buffered messages from MessageBufferProcessor */
|
|
1413
|
+
@OnEvent('whatsapp.messages.buffered')
|
|
1414
|
+
async onMessagesBuffered(payload: {
|
|
1415
|
+
sessionId: string;
|
|
1416
|
+
chatId: string;
|
|
1417
|
+
tenantId: string;
|
|
1418
|
+
clinicId: string;
|
|
1419
|
+
combinedText: string;
|
|
1420
|
+
messageCount: number;
|
|
1421
|
+
}): Promise<void> {
|
|
1422
|
+
this.logger.debug(
|
|
1423
|
+
`Buffered ${payload.messageCount} messages for session ${payload.sessionId}`,
|
|
1424
|
+
);
|
|
1425
|
+
await this.triggerAiResponse(
|
|
1426
|
+
payload.sessionId,
|
|
1427
|
+
payload.chatId,
|
|
1428
|
+
payload.combinedText,
|
|
1429
|
+
);
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
/* ================================================================== */
|
|
1433
|
+
/* ELEVENLABS WEBSOCKET MANAGEMENT */
|
|
1434
|
+
/* ================================================================== */
|
|
1435
|
+
|
|
1436
|
+
/**
|
|
1437
|
+
* Get an existing WS connection or create a new one.
|
|
1438
|
+
* Returns null if the connection cannot be established.
|
|
1439
|
+
*/
|
|
1440
|
+
private async getOrCreateConnection(
|
|
1441
|
+
chatId: string,
|
|
1442
|
+
agentId: string,
|
|
1443
|
+
tenantId: string,
|
|
1444
|
+
sessionId: string,
|
|
1445
|
+
): Promise<WsConnection | null> {
|
|
1446
|
+
// Check if we already have a live connection
|
|
1447
|
+
const existing = this.wsConnections.get(chatId);
|
|
1448
|
+
if (existing && existing.ws.readyState === WebSocket.OPEN) {
|
|
1449
|
+
return existing;
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
// Clean up stale connection if any
|
|
1453
|
+
if (existing) {
|
|
1454
|
+
this.closeConnection(chatId);
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
try {
|
|
1458
|
+
// Detect reconnection: session already has a conversation_id
|
|
1459
|
+
const sessionData = await this.prisma.whatsappSession.findUnique({
|
|
1460
|
+
where: { session_id: sessionId },
|
|
1461
|
+
select: {
|
|
1462
|
+
session_el_conversation_id: true,
|
|
1463
|
+
session_el_prev_conversation_ids: true,
|
|
1464
|
+
},
|
|
1465
|
+
});
|
|
1466
|
+
const isReconnect = !!sessionData?.session_el_conversation_id;
|
|
1467
|
+
|
|
1468
|
+
// Get signed URL from ElevenLabs
|
|
1469
|
+
const { signed_url } = await this.elevenlabsService.getSignedUrl(agentId);
|
|
1470
|
+
|
|
1471
|
+
// Build patient summary for dynamic variables
|
|
1472
|
+
const patientSummary = await this.getPatientSummary(sessionId);
|
|
1473
|
+
|
|
1474
|
+
// Build continuation directive for reconnection
|
|
1475
|
+
let continuationDirective = '';
|
|
1476
|
+
if (isReconnect) {
|
|
1477
|
+
try {
|
|
1478
|
+
const chatContext = await this.buildChatContext(sessionId);
|
|
1479
|
+
// Find last AI message for the directive
|
|
1480
|
+
const lastAiMsg = await this.prisma.whatsappMessage.findFirst({
|
|
1481
|
+
where: {
|
|
1482
|
+
msg_session_id: sessionId,
|
|
1483
|
+
msg_sender: 'ai',
|
|
1484
|
+
},
|
|
1485
|
+
orderBy: { msg_created_at: 'desc' },
|
|
1486
|
+
select: { msg_text: true },
|
|
1487
|
+
});
|
|
1488
|
+
|
|
1489
|
+
let lastAiText = '';
|
|
1490
|
+
if (lastAiMsg) {
|
|
1491
|
+
try {
|
|
1492
|
+
lastAiText = this.encryption.decrypt(
|
|
1493
|
+
lastAiMsg.msg_text,
|
|
1494
|
+
this.messageEncryptionKey,
|
|
1495
|
+
);
|
|
1496
|
+
} catch {
|
|
1497
|
+
lastAiText = '[mesaj okunamadi]';
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
continuationDirective = [
|
|
1502
|
+
'ONEMLI -- KONUSMA DEVAMI:',
|
|
1503
|
+
'Bu hasta ile onceden baslamis bir konusma var. Teknik bir baglanti yenilenmesi yasandi.',
|
|
1504
|
+
'Kendini TANITMA. Selam VERME. Hosgeldin DEME. Konusmaya kaldigin yerden DEVAM ET.',
|
|
1505
|
+
lastAiText
|
|
1506
|
+
? `En son gonderdigin mesaj: "${lastAiText}"`
|
|
1507
|
+
: '',
|
|
1508
|
+
'',
|
|
1509
|
+
'Konusma gecmisi:',
|
|
1510
|
+
chatContext,
|
|
1511
|
+
]
|
|
1512
|
+
.filter(Boolean)
|
|
1513
|
+
.join('\n');
|
|
1514
|
+
|
|
1515
|
+
// Push old conversation_id to prev_conversation_ids
|
|
1516
|
+
const prevIds = sessionData.session_el_prev_conversation_ids ?? [];
|
|
1517
|
+
prevIds.push(sessionData.session_el_conversation_id!);
|
|
1518
|
+
await this.prisma.whatsappSession.update({
|
|
1519
|
+
where: { session_id: sessionId },
|
|
1520
|
+
data: {
|
|
1521
|
+
session_el_prev_conversation_ids: prevIds,
|
|
1522
|
+
session_el_conversation_id: null, // Will be set when new WS is ready
|
|
1523
|
+
},
|
|
1524
|
+
});
|
|
1525
|
+
|
|
1526
|
+
this.logger.debug(
|
|
1527
|
+
`WS reconnect for session ${sessionId}, continuation directive built (${continuationDirective.length} chars)`,
|
|
1528
|
+
);
|
|
1529
|
+
} catch (err: any) {
|
|
1530
|
+
this.logger.warn(
|
|
1531
|
+
`Failed to build continuation directive for session ${sessionId}: ${err.message}`,
|
|
1532
|
+
);
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
// Create ready promise for synchronization
|
|
1537
|
+
let readyResolve!: () => void;
|
|
1538
|
+
let readyReject!: (err: Error) => void;
|
|
1539
|
+
const readyPromise = new Promise<void>((resolve, reject) => {
|
|
1540
|
+
readyResolve = resolve;
|
|
1541
|
+
readyReject = reject;
|
|
1542
|
+
});
|
|
1543
|
+
|
|
1544
|
+
const ws = new WebSocket(`${signed_url}&textOnly=true`);
|
|
1545
|
+
|
|
1546
|
+
const conn: WsConnection = {
|
|
1547
|
+
ws,
|
|
1548
|
+
agentId,
|
|
1549
|
+
tenantId,
|
|
1550
|
+
sessionId,
|
|
1551
|
+
chatId,
|
|
1552
|
+
isReady: false,
|
|
1553
|
+
readyPromise,
|
|
1554
|
+
readyResolve,
|
|
1555
|
+
};
|
|
1556
|
+
|
|
1557
|
+
this.wsConnections.set(chatId, conn);
|
|
1558
|
+
|
|
1559
|
+
// Capture reconnect state and context for the open handler closure
|
|
1560
|
+
const _isReconnect = isReconnect;
|
|
1561
|
+
const _continuationDirective = continuationDirective;
|
|
1562
|
+
|
|
1563
|
+
// Setup event handlers
|
|
1564
|
+
ws.on('open', () => {
|
|
1565
|
+
this.logger.debug(`WS opened for chat ${chatId} (reconnect=${_isReconnect})`);
|
|
1566
|
+
|
|
1567
|
+
// Send conversation initiation data
|
|
1568
|
+
ws.send(
|
|
1569
|
+
JSON.stringify({
|
|
1570
|
+
type: 'conversation_initiation_client_data',
|
|
1571
|
+
conversation_config_override: {},
|
|
1572
|
+
dynamic_variables: {
|
|
1573
|
+
user_summary: patientSummary,
|
|
1574
|
+
current_date: new Date().toISOString().split('T')[0],
|
|
1575
|
+
continuation_directive: _continuationDirective,
|
|
1576
|
+
},
|
|
1577
|
+
}),
|
|
1578
|
+
);
|
|
1579
|
+
});
|
|
1580
|
+
|
|
1581
|
+
ws.on('message', (data) => {
|
|
1582
|
+
this.handleWsMessage(chatId, data.toString()).catch((err) =>
|
|
1583
|
+
this.logger.error(
|
|
1584
|
+
`WS message handler error for chat ${chatId}: ${err.message}`,
|
|
1585
|
+
err.stack,
|
|
1586
|
+
),
|
|
1587
|
+
);
|
|
1588
|
+
});
|
|
1589
|
+
|
|
1590
|
+
ws.on('close', (code, reason) => {
|
|
1591
|
+
this.logger.debug(
|
|
1592
|
+
`WS closed for chat ${chatId}: code=${code} reason=${reason.toString()}`,
|
|
1593
|
+
);
|
|
1594
|
+
this.wsConnections.delete(chatId);
|
|
1595
|
+
this.redis
|
|
1596
|
+
.del(`ws:conn:${chatId}`)
|
|
1597
|
+
.catch(() => { /* ignore */ });
|
|
1598
|
+
|
|
1599
|
+
if (!conn.isReady) {
|
|
1600
|
+
readyReject(new Error(`WS closed before ready: ${code}`));
|
|
1601
|
+
}
|
|
1602
|
+
});
|
|
1603
|
+
|
|
1604
|
+
ws.on('error', (err) => {
|
|
1605
|
+
this.logger.error(
|
|
1606
|
+
`WS error for chat ${chatId}: ${err.message}`,
|
|
1607
|
+
);
|
|
1608
|
+
if (!conn.isReady) {
|
|
1609
|
+
readyReject(err);
|
|
1610
|
+
}
|
|
1611
|
+
});
|
|
1612
|
+
|
|
1613
|
+
// Set a timeout for the ready state
|
|
1614
|
+
const readyTimeout = setTimeout(() => {
|
|
1615
|
+
if (!conn.isReady) {
|
|
1616
|
+
readyReject(new Error('WS ready timeout (15s)'));
|
|
1617
|
+
this.closeConnection(chatId);
|
|
1618
|
+
}
|
|
1619
|
+
}, 15_000);
|
|
1620
|
+
|
|
1621
|
+
// Clean up timeout when ready
|
|
1622
|
+
readyPromise.then(() => clearTimeout(readyTimeout)).catch(() => clearTimeout(readyTimeout));
|
|
1623
|
+
|
|
1624
|
+
return conn;
|
|
1625
|
+
} catch (err: any) {
|
|
1626
|
+
this.logger.error(
|
|
1627
|
+
`Failed to create WS connection for chat ${chatId}: ${err.message}`,
|
|
1628
|
+
err.stack,
|
|
1629
|
+
);
|
|
1630
|
+
return null;
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
/** Close a WS connection and clean up. */
|
|
1635
|
+
private closeConnection(chatId: string): void {
|
|
1636
|
+
const conn = this.wsConnections.get(chatId);
|
|
1637
|
+
if (!conn) return;
|
|
1638
|
+
|
|
1639
|
+
try {
|
|
1640
|
+
if (
|
|
1641
|
+
conn.ws.readyState === WebSocket.OPEN ||
|
|
1642
|
+
conn.ws.readyState === WebSocket.CONNECTING
|
|
1643
|
+
) {
|
|
1644
|
+
conn.ws.close(1000, 'session_ended');
|
|
1645
|
+
}
|
|
1646
|
+
} catch {
|
|
1647
|
+
// Ignore close errors
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
this.wsConnections.delete(chatId);
|
|
1651
|
+
this.redis.del(`ws:conn:${chatId}`).catch(() => { /* ignore */ });
|
|
1652
|
+
|
|
1653
|
+
this.logger.debug(`Closed WS connection for chat ${chatId}`);
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
/** Route incoming WS messages from ElevenLabs. */
|
|
1657
|
+
private async handleWsMessage(
|
|
1658
|
+
chatId: string,
|
|
1659
|
+
rawData: string,
|
|
1660
|
+
): Promise<void> {
|
|
1661
|
+
let msg: any;
|
|
1662
|
+
try {
|
|
1663
|
+
msg = JSON.parse(rawData);
|
|
1664
|
+
} catch {
|
|
1665
|
+
this.logger.warn(`Non-JSON WS message for chat ${chatId}: ${rawData.substring(0, 100)}`);
|
|
1666
|
+
return;
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
const conn = this.wsConnections.get(chatId);
|
|
1670
|
+
if (!conn) {
|
|
1671
|
+
this.logger.warn(
|
|
1672
|
+
`Received WS message for unknown chat ${chatId}: type=${msg.type}`,
|
|
1673
|
+
);
|
|
1674
|
+
return;
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
switch (msg.type) {
|
|
1678
|
+
case 'conversation_initiation_metadata': {
|
|
1679
|
+
// Connection is ready — store conversation ID
|
|
1680
|
+
conn.conversationId = msg.conversation_id;
|
|
1681
|
+
conn.isReady = true;
|
|
1682
|
+
conn.readyResolve();
|
|
1683
|
+
|
|
1684
|
+
// Persist the conversation ID on the session
|
|
1685
|
+
if (msg.conversation_id) {
|
|
1686
|
+
await this.prisma.whatsappSession.update({
|
|
1687
|
+
where: { session_id: conn.sessionId },
|
|
1688
|
+
data: {
|
|
1689
|
+
session_el_conversation_id: msg.conversation_id,
|
|
1690
|
+
session_el_conversation_created: new Date(),
|
|
1691
|
+
},
|
|
1692
|
+
});
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
this.logger.debug(
|
|
1696
|
+
`WS ready for chat ${chatId}, conversationId=${msg.conversation_id}`,
|
|
1697
|
+
);
|
|
1698
|
+
break;
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
case 'agent_response': {
|
|
1702
|
+
// AI responded — send to patient and store
|
|
1703
|
+
const responseText = msg.agent_response_text ?? msg.text ?? '';
|
|
1704
|
+
if (!responseText.trim()) break;
|
|
1705
|
+
|
|
1706
|
+
await this.handleAgentResponse(conn, responseText);
|
|
1707
|
+
break;
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
case 'client_tool_call': {
|
|
1711
|
+
// Delegate to tool registry via the WS adapter
|
|
1712
|
+
await this.handleClientToolCall(conn, msg);
|
|
1713
|
+
break;
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
case 'ping': {
|
|
1717
|
+
// Respond with pong to keep alive
|
|
1718
|
+
this.wsSend(conn, { type: 'pong' });
|
|
1719
|
+
break;
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
case 'user_transcript':
|
|
1723
|
+
case 'internal_tentative_agent_response':
|
|
1724
|
+
// Informational — ignore
|
|
1725
|
+
break;
|
|
1726
|
+
|
|
1727
|
+
default:
|
|
1728
|
+
this.logger.debug(
|
|
1729
|
+
`Unhandled WS message type for chat ${chatId}: ${msg.type}`,
|
|
1730
|
+
);
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
/** Handle an AI agent_response: send to patient + store. */
|
|
1735
|
+
private async handleAgentResponse(
|
|
1736
|
+
conn: WsConnection,
|
|
1737
|
+
responseText: string,
|
|
1738
|
+
): Promise<void> {
|
|
1739
|
+
const { chatId, sessionId, tenantId } = conn;
|
|
1740
|
+
|
|
1741
|
+
// Send to patient via Meta API (fire-and-forget)
|
|
1742
|
+
this.sendAiResponseToPatient(chatId, responseText).catch((err) =>
|
|
1743
|
+
this.logger.error(
|
|
1744
|
+
`Failed to send AI response for chat ${chatId}: ${err.message}`,
|
|
1745
|
+
),
|
|
1746
|
+
);
|
|
1747
|
+
|
|
1748
|
+
// Encrypt and store as AI message
|
|
1749
|
+
const encryptedText = this.encryption.encrypt(
|
|
1750
|
+
responseText,
|
|
1751
|
+
this.messageEncryptionKey,
|
|
1752
|
+
);
|
|
1753
|
+
|
|
1754
|
+
const msg = await this.prisma.whatsappMessage.create({
|
|
1755
|
+
data: {
|
|
1756
|
+
msg_chat_id: chatId,
|
|
1757
|
+
msg_session_id: sessionId,
|
|
1758
|
+
msg_tenant_id: tenantId,
|
|
1759
|
+
msg_sender: 'ai',
|
|
1760
|
+
msg_sender_name: 'AI',
|
|
1761
|
+
msg_text: encryptedText,
|
|
1762
|
+
},
|
|
1763
|
+
});
|
|
1764
|
+
|
|
1765
|
+
// Update chat metadata
|
|
1766
|
+
await this.prisma.whatsappChat.update({
|
|
1767
|
+
where: { chat_id: chatId },
|
|
1768
|
+
data: {
|
|
1769
|
+
chat_last_message_at: new Date(),
|
|
1770
|
+
chat_last_message_preview: responseText.substring(0, 100),
|
|
1771
|
+
chat_message_count: { increment: 1 },
|
|
1772
|
+
},
|
|
1773
|
+
});
|
|
1774
|
+
|
|
1775
|
+
// Update session message count and last interaction
|
|
1776
|
+
await this.prisma.whatsappSession.update({
|
|
1777
|
+
where: { session_id: sessionId },
|
|
1778
|
+
data: {
|
|
1779
|
+
session_message_count: { increment: 1 },
|
|
1780
|
+
session_el_last_interaction: new Date(),
|
|
1781
|
+
},
|
|
1782
|
+
});
|
|
1783
|
+
|
|
1784
|
+
// Emit for real-time dashboard
|
|
1785
|
+
this.eventEmitter.emit('whatsapp.message.sent', {
|
|
1786
|
+
chatId,
|
|
1787
|
+
messageId: msg.msg_id,
|
|
1788
|
+
tenantId,
|
|
1789
|
+
});
|
|
1790
|
+
|
|
1791
|
+
this.eventEmitter.emit('chat.message.created', {
|
|
1792
|
+
chatId,
|
|
1793
|
+
messageId: msg.msg_id,
|
|
1794
|
+
tenantId,
|
|
1795
|
+
sender: 'ai',
|
|
1796
|
+
});
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
/** Handle a client_tool_call from ElevenLabs. */
|
|
1800
|
+
private async handleClientToolCall(
|
|
1801
|
+
conn: WsConnection,
|
|
1802
|
+
msg: any,
|
|
1803
|
+
): Promise<void> {
|
|
1804
|
+
const { chatId, sessionId, tenantId } = conn;
|
|
1805
|
+
|
|
1806
|
+
// Load chat for context
|
|
1807
|
+
const chat = await this.prisma.whatsappChat.findUnique({
|
|
1808
|
+
where: { chat_id: chatId },
|
|
1809
|
+
select: {
|
|
1810
|
+
chat_contact_phone: true,
|
|
1811
|
+
chat_contact_name: true,
|
|
1812
|
+
chat_clinic_id: true,
|
|
1813
|
+
chat_channel: true,
|
|
1814
|
+
chat_patient_id: true,
|
|
1815
|
+
chat_phone_id: true,
|
|
1816
|
+
},
|
|
1817
|
+
});
|
|
1818
|
+
|
|
1819
|
+
if (!chat) {
|
|
1820
|
+
this.logger.error(`Chat ${chatId} not found for tool call`);
|
|
1821
|
+
// Send error result back to ElevenLabs
|
|
1822
|
+
this.wsSend(conn, {
|
|
1823
|
+
type: 'client_tool_result',
|
|
1824
|
+
tool_call_id: msg.tool_call_id,
|
|
1825
|
+
result: 'Internal error: chat not found',
|
|
1826
|
+
is_error: true,
|
|
1827
|
+
});
|
|
1828
|
+
return;
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
// Build tool context
|
|
1832
|
+
const toolCtx: ToolContext = {
|
|
1833
|
+
tenantId,
|
|
1834
|
+
clinicId: chat.chat_clinic_id,
|
|
1835
|
+
patientPhone: chat.chat_contact_phone,
|
|
1836
|
+
patientName: chat.chat_contact_name ?? '',
|
|
1837
|
+
patientId: chat.chat_patient_id ?? undefined,
|
|
1838
|
+
channel: chat.chat_channel as 'whatsapp' | 'instagram',
|
|
1839
|
+
channelMetadata: {
|
|
1840
|
+
chatId,
|
|
1841
|
+
sessionId,
|
|
1842
|
+
conversationId: conn.conversationId,
|
|
1843
|
+
agentId: conn.agentId,
|
|
1844
|
+
phoneNumberId: chat.chat_phone_id ?? undefined,
|
|
1845
|
+
},
|
|
1846
|
+
};
|
|
1847
|
+
|
|
1848
|
+
// Delegate to the WS adapter which handles the tool registry
|
|
1849
|
+
const result = await this.wsAdapter.handleToolCall(
|
|
1850
|
+
{
|
|
1851
|
+
tool_call_id: msg.tool_call_id,
|
|
1852
|
+
tool_name: msg.tool_name,
|
|
1853
|
+
parameters: msg.parameters ?? {},
|
|
1854
|
+
},
|
|
1855
|
+
toolCtx,
|
|
1856
|
+
);
|
|
1857
|
+
|
|
1858
|
+
// Send result back to ElevenLabs
|
|
1859
|
+
this.wsSend(conn, result);
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
/* ================================================================== */
|
|
1863
|
+
/* PRIVATE HELPERS */
|
|
1864
|
+
/* ================================================================== */
|
|
1865
|
+
|
|
1866
|
+
/** Check if message text matches any system message pattern. */
|
|
1867
|
+
private isSystemMessage(text: string): boolean {
|
|
1868
|
+
if (!text || !text.trim()) return false;
|
|
1869
|
+
return SYSTEM_MESSAGE_PATTERNS.some((pattern) => pattern.test(text));
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
/**
|
|
1873
|
+
* Handle echo (isSender) messages.
|
|
1874
|
+
* Returns true if the message was handled (should be skipped).
|
|
1875
|
+
*/
|
|
1876
|
+
private async handleEchoMessage(
|
|
1877
|
+
tenantId: string,
|
|
1878
|
+
externalChatId: string,
|
|
1879
|
+
msgExternalId: string,
|
|
1880
|
+
): Promise<boolean> {
|
|
1881
|
+
// Check if this is a message we sent via our API (match by external ID)
|
|
1882
|
+
const ourMessage = await this.prisma.whatsappMessage.findFirst({
|
|
1883
|
+
where: {
|
|
1884
|
+
msg_external_id: msgExternalId,
|
|
1885
|
+
msg_tenant_id: tenantId,
|
|
1886
|
+
msg_sender: { in: ['ai', 'human', 'system'] },
|
|
1887
|
+
},
|
|
1888
|
+
});
|
|
1889
|
+
|
|
1890
|
+
if (ourMessage) {
|
|
1891
|
+
// This is an echo of a message we sent — skip entirely
|
|
1892
|
+
this.logger.debug(`Echo of our message ${msgExternalId}, skipping`);
|
|
1893
|
+
return true;
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
// This was sent by a real person from the business phone (not via our API).
|
|
1897
|
+
// Check if there's a session in 'waiting' — resolve as 'human'
|
|
1898
|
+
const chat = await this.prisma.whatsappChat.findUnique({
|
|
1899
|
+
where: {
|
|
1900
|
+
chat_tenant_id_chat_external_id: {
|
|
1901
|
+
chat_tenant_id: tenantId,
|
|
1902
|
+
chat_external_id: externalChatId,
|
|
1903
|
+
},
|
|
1904
|
+
},
|
|
1905
|
+
select: { chat_active_session_id: true },
|
|
1906
|
+
});
|
|
1907
|
+
|
|
1908
|
+
if (chat?.chat_active_session_id) {
|
|
1909
|
+
const session = await this.prisma.whatsappSession.findUnique({
|
|
1910
|
+
where: { session_id: chat.chat_active_session_id },
|
|
1911
|
+
select: { session_status: true, session_id: true },
|
|
1912
|
+
});
|
|
1913
|
+
|
|
1914
|
+
if (session?.session_status === 'waiting') {
|
|
1915
|
+
this.logger.log(
|
|
1916
|
+
`Human replied from business phone, resolving session ${session.session_id}`,
|
|
1917
|
+
);
|
|
1918
|
+
await this.resolveSession(session.session_id, 'human');
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
return true;
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
/** Store a system message (audit trail only, no session trigger). */
|
|
1926
|
+
private async storeSystemMessage(
|
|
1927
|
+
tenantId: string,
|
|
1928
|
+
externalChatId: string,
|
|
1929
|
+
message: IncomingMessagePayload['message'],
|
|
1930
|
+
sender: IncomingMessagePayload['sender'],
|
|
1931
|
+
): Promise<void> {
|
|
1932
|
+
// Find the chat (don't create one for system messages)
|
|
1933
|
+
const chat = await this.prisma.whatsappChat.findUnique({
|
|
1934
|
+
where: {
|
|
1935
|
+
chat_tenant_id_chat_external_id: {
|
|
1936
|
+
chat_tenant_id: tenantId,
|
|
1937
|
+
chat_external_id: externalChatId,
|
|
1938
|
+
},
|
|
1939
|
+
},
|
|
1940
|
+
select: { chat_id: true, chat_active_session_id: true },
|
|
1941
|
+
});
|
|
1942
|
+
|
|
1943
|
+
if (!chat) {
|
|
1944
|
+
// No existing chat — don't create one for a system message
|
|
1945
|
+
return;
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
const encryptedText = this.encryption.encrypt(
|
|
1949
|
+
message.text,
|
|
1950
|
+
this.messageEncryptionKey,
|
|
1951
|
+
);
|
|
1952
|
+
|
|
1953
|
+
await this.prisma.whatsappMessage.create({
|
|
1954
|
+
data: {
|
|
1955
|
+
msg_chat_id: chat.chat_id,
|
|
1956
|
+
msg_session_id: chat.chat_active_session_id,
|
|
1957
|
+
msg_tenant_id: tenantId,
|
|
1958
|
+
msg_external_id: message.externalId,
|
|
1959
|
+
msg_sender: 'system',
|
|
1960
|
+
msg_sender_name: sender.name ?? '',
|
|
1961
|
+
msg_text: encryptedText,
|
|
1962
|
+
},
|
|
1963
|
+
});
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
/** Encrypt and store an incoming message. */
|
|
1967
|
+
private async storeEncryptedMessage(
|
|
1968
|
+
chatId: string,
|
|
1969
|
+
sessionId: string | null,
|
|
1970
|
+
tenantId: string,
|
|
1971
|
+
message: IncomingMessagePayload['message'],
|
|
1972
|
+
sender: IncomingMessagePayload['sender'],
|
|
1973
|
+
) {
|
|
1974
|
+
const encryptedText = this.encryption.encrypt(
|
|
1975
|
+
message.text,
|
|
1976
|
+
this.messageEncryptionKey,
|
|
1977
|
+
);
|
|
1978
|
+
|
|
1979
|
+
const encryptedQuoted = message.quotedText
|
|
1980
|
+
? this.encryption.encrypt(message.quotedText, this.messageEncryptionKey)
|
|
1981
|
+
: null;
|
|
1982
|
+
|
|
1983
|
+
return this.prisma.whatsappMessage.create({
|
|
1984
|
+
data: {
|
|
1985
|
+
msg_chat_id: chatId,
|
|
1986
|
+
msg_session_id: sessionId,
|
|
1987
|
+
msg_tenant_id: tenantId,
|
|
1988
|
+
msg_external_id: message.externalId,
|
|
1989
|
+
msg_sender: 'contact',
|
|
1990
|
+
msg_sender_name: sender.name ?? '',
|
|
1991
|
+
msg_text: encryptedText,
|
|
1992
|
+
msg_media_type: message.mediaType,
|
|
1993
|
+
msg_media_key: message.mediaKey,
|
|
1994
|
+
msg_media_mime: message.mediaMime,
|
|
1995
|
+
msg_media_filename: message.mediaFilename,
|
|
1996
|
+
msg_ad_context: message.adContext as any,
|
|
1997
|
+
msg_metadata: message.metadata as any,
|
|
1998
|
+
msg_quoted_text: encryptedQuoted,
|
|
1999
|
+
},
|
|
2000
|
+
});
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
private async findOrCreatePatient(
|
|
2004
|
+
tenantId: string,
|
|
2005
|
+
clinicId: string,
|
|
2006
|
+
phone: string,
|
|
2007
|
+
name?: string,
|
|
2008
|
+
channel: 'whatsapp' | 'instagram' = 'whatsapp',
|
|
2009
|
+
) {
|
|
2010
|
+
let patient = await this.prisma.patient.findUnique({
|
|
2011
|
+
where: {
|
|
2012
|
+
patient_tenant_id_patient_phone: {
|
|
2013
|
+
patient_tenant_id: tenantId,
|
|
2014
|
+
patient_phone: phone,
|
|
2015
|
+
},
|
|
2016
|
+
},
|
|
2017
|
+
});
|
|
2018
|
+
|
|
2019
|
+
if (!patient) {
|
|
2020
|
+
const displayName = name || 'Bilinmiyor';
|
|
2021
|
+
patient = await this.prisma.patient.create({
|
|
2022
|
+
data: {
|
|
2023
|
+
patient_tenant_id: tenantId,
|
|
2024
|
+
patient_clinic_id: clinicId,
|
|
2025
|
+
patient_phone: phone,
|
|
2026
|
+
patient_display_name: displayName,
|
|
2027
|
+
patient_first_name: name ?? '',
|
|
2028
|
+
patient_last_name: '',
|
|
2029
|
+
patient_source: channel,
|
|
2030
|
+
patient_last_contact_at: new Date(),
|
|
2031
|
+
},
|
|
2032
|
+
});
|
|
2033
|
+
this.logger.debug(
|
|
2034
|
+
`Created new patient ${patient.patient_id} for ${phone}`,
|
|
2035
|
+
);
|
|
2036
|
+
} else {
|
|
2037
|
+
// Update last contact + name if we now have a better one
|
|
2038
|
+
const updates: any = { patient_last_contact_at: new Date() };
|
|
2039
|
+
if (name && (!patient.patient_display_name || patient.patient_display_name === 'Bilinmiyor' || patient.patient_display_name === patient.patient_phone)) {
|
|
2040
|
+
updates.patient_display_name = name;
|
|
2041
|
+
updates.patient_first_name = name;
|
|
2042
|
+
}
|
|
2043
|
+
await this.prisma.patient.update({
|
|
2044
|
+
where: { patient_id: patient.patient_id },
|
|
2045
|
+
data: updates,
|
|
2046
|
+
});
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
return patient;
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
private async findOrCreateChat(
|
|
2053
|
+
tenantId: string,
|
|
2054
|
+
clinicId: string,
|
|
2055
|
+
externalChatId: string,
|
|
2056
|
+
channel: 'whatsapp' | 'instagram',
|
|
2057
|
+
patientId: string,
|
|
2058
|
+
sender: { phone: string; name?: string },
|
|
2059
|
+
phoneNumberId?: string,
|
|
2060
|
+
) {
|
|
2061
|
+
let chat = await this.prisma.whatsappChat.findUnique({
|
|
2062
|
+
where: {
|
|
2063
|
+
chat_tenant_id_chat_external_id: {
|
|
2064
|
+
chat_tenant_id: tenantId,
|
|
2065
|
+
chat_external_id: externalChatId,
|
|
2066
|
+
},
|
|
2067
|
+
},
|
|
2068
|
+
});
|
|
2069
|
+
|
|
2070
|
+
if (!chat) {
|
|
2071
|
+
// Resolve phone number record — phoneNumberId may be Meta's internal ID
|
|
2072
|
+
let phoneId: string | null = null;
|
|
2073
|
+
if (phoneNumberId) {
|
|
2074
|
+
// Try direct match first (actual phone number string)
|
|
2075
|
+
let phone = await this.prisma.phoneNumber.findFirst({
|
|
2076
|
+
where: { phone_tenant_id: tenantId, phone_number: phoneNumberId },
|
|
2077
|
+
select: { phone_id: true },
|
|
2078
|
+
});
|
|
2079
|
+
// Fall back: resolve via ClinicMetaConnection (Meta phone number ID)
|
|
2080
|
+
if (!phone) {
|
|
2081
|
+
const metaConn = await this.prisma.clinicMetaConnection.findFirst({
|
|
2082
|
+
where: { meta_phone_number_id: phoneNumberId, meta_is_active: true },
|
|
2083
|
+
select: { meta_display_phone: true },
|
|
2084
|
+
});
|
|
2085
|
+
if (metaConn?.meta_display_phone) {
|
|
2086
|
+
phone = await this.prisma.phoneNumber.findFirst({
|
|
2087
|
+
where: { phone_tenant_id: tenantId, phone_number: metaConn.meta_display_phone },
|
|
2088
|
+
select: { phone_id: true },
|
|
2089
|
+
});
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
phoneId = phone?.phone_id ?? null;
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
chat = await this.prisma.whatsappChat.create({
|
|
2096
|
+
data: {
|
|
2097
|
+
chat_tenant_id: tenantId,
|
|
2098
|
+
chat_clinic_id: clinicId,
|
|
2099
|
+
chat_patient_id: patientId,
|
|
2100
|
+
chat_channel: channel,
|
|
2101
|
+
chat_external_id: externalChatId,
|
|
2102
|
+
chat_phone_id: phoneId,
|
|
2103
|
+
chat_contact_name: sender.name || 'Bilinmiyor',
|
|
2104
|
+
chat_contact_phone: sender.name && channel === 'instagram' ? sender.name : sender.phone,
|
|
2105
|
+
},
|
|
2106
|
+
});
|
|
2107
|
+
this.logger.debug(
|
|
2108
|
+
`Created new chat ${chat.chat_id} for external ${externalChatId}`,
|
|
2109
|
+
);
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2112
|
+
return chat;
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
private static readonly AGENT_CACHE_PREFIX = 'agent_config:';
|
|
2116
|
+
private static readonly AGENT_CACHE_TTL = 300; // 5 minutes
|
|
2117
|
+
|
|
2118
|
+
/** Resolve the agent for a clinic (cached in Redis). */
|
|
2119
|
+
private async resolveAgent(
|
|
2120
|
+
clinicId: string,
|
|
2121
|
+
tenantId: string,
|
|
2122
|
+
): Promise<ResolvedAgent | null> {
|
|
2123
|
+
const cacheKey = `${WhatsAppAgentService.AGENT_CACHE_PREFIX}${tenantId}:${clinicId}`;
|
|
2124
|
+
|
|
2125
|
+
// Check cache
|
|
2126
|
+
const cached = await this.redis.get(cacheKey);
|
|
2127
|
+
if (cached) {
|
|
2128
|
+
if (cached === 'null') return null;
|
|
2129
|
+
return JSON.parse(cached) as ResolvedAgent;
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
// DB lookup
|
|
2133
|
+
const agent = await this.prisma.agent.findFirst({
|
|
2134
|
+
where: {
|
|
2135
|
+
agent_is_active: true,
|
|
2136
|
+
agent_status: 'production',
|
|
2137
|
+
OR: [
|
|
2138
|
+
{ agent_clinic_id: clinicId },
|
|
2139
|
+
{ agent_tenant_id: tenantId, agent_clinic_id: null },
|
|
2140
|
+
],
|
|
2141
|
+
},
|
|
2142
|
+
orderBy: {
|
|
2143
|
+
agent_clinic_id: { sort: 'desc', nulls: 'last' },
|
|
2144
|
+
},
|
|
2145
|
+
select: {
|
|
2146
|
+
agent_id: true,
|
|
2147
|
+
agent_elevenlabs_id: true,
|
|
2148
|
+
agent_name: true,
|
|
2149
|
+
agent_system_prompt: true,
|
|
2150
|
+
agent_ai_provider: true,
|
|
2151
|
+
agent_ai_model: true,
|
|
2152
|
+
agent_language: true,
|
|
2153
|
+
agent_greeting: true,
|
|
2154
|
+
agent_temperature: true,
|
|
2155
|
+
agent_max_tokens: true,
|
|
2156
|
+
agent_enabled_tools: true,
|
|
2157
|
+
agent_channels: true,
|
|
2158
|
+
agent_extra_config: true,
|
|
2159
|
+
},
|
|
2160
|
+
});
|
|
2161
|
+
|
|
2162
|
+
// Cache result (even null to avoid repeated DB misses)
|
|
2163
|
+
await this.redis.setex(
|
|
2164
|
+
cacheKey,
|
|
2165
|
+
WhatsAppAgentService.AGENT_CACHE_TTL,
|
|
2166
|
+
agent ? JSON.stringify(agent) : 'null',
|
|
2167
|
+
);
|
|
2168
|
+
|
|
2169
|
+
return agent;
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
/** Invalidate agent cache for a tenant (call after agent config changes). */
|
|
2173
|
+
async invalidateAgentCache(tenantId: string): Promise<void> {
|
|
2174
|
+
const keys = await this.redis.keys(`${WhatsAppAgentService.AGENT_CACHE_PREFIX}${tenantId}:*`);
|
|
2175
|
+
if (keys.length > 0) {
|
|
2176
|
+
await this.redis.del(...keys);
|
|
2177
|
+
this.logger.debug(`Invalidated ${keys.length} agent cache entries for tenant ${tenantId}`);
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
|
|
2181
|
+
/** Get the decrypted patient summary for dynamic variables. */
|
|
2182
|
+
private async getPatientSummary(sessionId: string): Promise<string> {
|
|
2183
|
+
try {
|
|
2184
|
+
const session = await this.prisma.whatsappSession.findUnique({
|
|
2185
|
+
where: { session_id: sessionId },
|
|
2186
|
+
select: {
|
|
2187
|
+
chat: {
|
|
2188
|
+
select: {
|
|
2189
|
+
chat_patient_id: true,
|
|
2190
|
+
},
|
|
2191
|
+
},
|
|
2192
|
+
},
|
|
2193
|
+
});
|
|
2194
|
+
|
|
2195
|
+
if (!session?.chat.chat_patient_id) return '';
|
|
2196
|
+
|
|
2197
|
+
const patient = await this.prisma.patient.findUnique({
|
|
2198
|
+
where: { patient_id: session.chat.chat_patient_id },
|
|
2199
|
+
select: {
|
|
2200
|
+
patient_display_name: true,
|
|
2201
|
+
patient_phone: true,
|
|
2202
|
+
patient_ai_summary: true,
|
|
2203
|
+
patient_total_sessions: true,
|
|
2204
|
+
patient_tags: true,
|
|
2205
|
+
},
|
|
2206
|
+
});
|
|
2207
|
+
|
|
2208
|
+
if (!patient) return '';
|
|
2209
|
+
|
|
2210
|
+
const parts: string[] = [
|
|
2211
|
+
`Hasta: ${patient.patient_display_name}`,
|
|
2212
|
+
`Telefon: ${patient.patient_phone}`,
|
|
2213
|
+
`Toplam gorusme: ${patient.patient_total_sessions}`,
|
|
2214
|
+
];
|
|
2215
|
+
|
|
2216
|
+
if (patient.patient_tags.length > 0) {
|
|
2217
|
+
parts.push(`Etiketler: ${patient.patient_tags.join(', ')}`);
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2220
|
+
// Decrypt AI summary if available
|
|
2221
|
+
if (patient.patient_ai_summary && this.patientSummaryKey) {
|
|
2222
|
+
try {
|
|
2223
|
+
const summary = this.encryption.decrypt(
|
|
2224
|
+
patient.patient_ai_summary,
|
|
2225
|
+
this.patientSummaryKey,
|
|
2226
|
+
);
|
|
2227
|
+
if (summary.trim()) {
|
|
2228
|
+
parts.push(`Onceki notlar: ${summary}`);
|
|
2229
|
+
}
|
|
2230
|
+
} catch {
|
|
2231
|
+
// Non-critical — skip summary if decryption fails
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
2234
|
+
|
|
2235
|
+
return parts.join('\n');
|
|
2236
|
+
} catch (err: any) {
|
|
2237
|
+
this.logger.warn(
|
|
2238
|
+
`Failed to build patient summary for session ${sessionId}: ${err.message}`,
|
|
2239
|
+
);
|
|
2240
|
+
return '';
|
|
2241
|
+
}
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
/** Send AI response text to the patient via Meta Business API. */
|
|
2245
|
+
private async sendAiResponseToPatient(
|
|
2246
|
+
chatId: string,
|
|
2247
|
+
text: string,
|
|
2248
|
+
): Promise<void> {
|
|
2249
|
+
const chat = await this.prisma.whatsappChat.findUnique({
|
|
2250
|
+
where: { chat_id: chatId },
|
|
2251
|
+
select: {
|
|
2252
|
+
chat_external_id: true,
|
|
2253
|
+
chat_clinic_id: true,
|
|
2254
|
+
chat_channel: true,
|
|
2255
|
+
},
|
|
2256
|
+
});
|
|
2257
|
+
|
|
2258
|
+
if (!chat) return;
|
|
2259
|
+
|
|
2260
|
+
const [recipientId, receiverId] = chat.chat_external_id.split('@');
|
|
2261
|
+
|
|
2262
|
+
if (chat.chat_channel === 'instagram') {
|
|
2263
|
+
await this.sendViaInstagram(chat.chat_clinic_id, recipientId, text, receiverId);
|
|
2264
|
+
} else {
|
|
2265
|
+
await this.sendViaMeta(chat.chat_clinic_id, recipientId, text, receiverId);
|
|
2266
|
+
}
|
|
2267
|
+
}
|
|
2268
|
+
|
|
2269
|
+
/** Send a text message via Meta Business API. Resolves the access token. */
|
|
2270
|
+
private async sendViaMeta(
|
|
2271
|
+
clinicId: string,
|
|
2272
|
+
recipientPhone: string,
|
|
2273
|
+
text: string,
|
|
2274
|
+
metaPhoneNumberId?: string,
|
|
2275
|
+
): Promise<any> {
|
|
2276
|
+
// Find the exact Meta connection by phone number ID, or fall back to any active one
|
|
2277
|
+
const where: any = {
|
|
2278
|
+
meta_clinic_id: clinicId,
|
|
2279
|
+
meta_type: 'whatsapp',
|
|
2280
|
+
meta_is_active: true,
|
|
2281
|
+
};
|
|
2282
|
+
if (metaPhoneNumberId) {
|
|
2283
|
+
where.meta_phone_number_id = metaPhoneNumberId;
|
|
2284
|
+
}
|
|
2285
|
+
const metaConnection = await this.prisma.clinicMetaConnection.findFirst({
|
|
2286
|
+
where,
|
|
2287
|
+
select: {
|
|
2288
|
+
meta_phone_number_id: true,
|
|
2289
|
+
meta_encrypted_access_token: true,
|
|
2290
|
+
},
|
|
2291
|
+
});
|
|
2292
|
+
|
|
2293
|
+
if (
|
|
2294
|
+
!metaConnection?.meta_phone_number_id ||
|
|
2295
|
+
!metaConnection?.meta_encrypted_access_token
|
|
2296
|
+
) {
|
|
2297
|
+
this.logger.warn(
|
|
2298
|
+
`No active Meta WhatsApp connection for clinic ${clinicId}`,
|
|
2299
|
+
);
|
|
2300
|
+
return null;
|
|
2301
|
+
}
|
|
2302
|
+
|
|
2303
|
+
let accessToken: string;
|
|
2304
|
+
try {
|
|
2305
|
+
accessToken = this.tokenEncryptionKey
|
|
2306
|
+
? this.encryption.decrypt(
|
|
2307
|
+
metaConnection.meta_encrypted_access_token,
|
|
2308
|
+
this.tokenEncryptionKey,
|
|
2309
|
+
)
|
|
2310
|
+
: metaConnection.meta_encrypted_access_token;
|
|
2311
|
+
} catch {
|
|
2312
|
+
this.logger.error(
|
|
2313
|
+
`Failed to decrypt Meta access token for clinic ${clinicId}`,
|
|
2314
|
+
);
|
|
2315
|
+
return null;
|
|
2316
|
+
}
|
|
2317
|
+
|
|
2318
|
+
return this.metaWhatsApp.sendMessage(
|
|
2319
|
+
metaConnection.meta_phone_number_id,
|
|
2320
|
+
recipientPhone,
|
|
2321
|
+
text,
|
|
2322
|
+
accessToken,
|
|
2323
|
+
);
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
/** Send a text message via Instagram Messenger API. */
|
|
2327
|
+
private async sendViaInstagram(
|
|
2328
|
+
clinicId: string,
|
|
2329
|
+
recipientId: string,
|
|
2330
|
+
text: string,
|
|
2331
|
+
igPageId?: string,
|
|
2332
|
+
): Promise<any> {
|
|
2333
|
+
const where: any = {
|
|
2334
|
+
meta_clinic_id: clinicId,
|
|
2335
|
+
meta_type: 'instagram',
|
|
2336
|
+
meta_is_active: true,
|
|
2337
|
+
};
|
|
2338
|
+
if (igPageId) {
|
|
2339
|
+
where.meta_instagram_page_id = igPageId;
|
|
2340
|
+
}
|
|
2341
|
+
const metaConnection = await this.prisma.clinicMetaConnection.findFirst({
|
|
2342
|
+
where,
|
|
2343
|
+
select: {
|
|
2344
|
+
meta_instagram_page_id: true,
|
|
2345
|
+
meta_facebook_page_id: true,
|
|
2346
|
+
meta_encrypted_access_token: true,
|
|
2347
|
+
},
|
|
2348
|
+
});
|
|
2349
|
+
|
|
2350
|
+
// Instagram messaging API is called on the Facebook Page ID
|
|
2351
|
+
const pageId = metaConnection?.meta_facebook_page_id || metaConnection?.meta_instagram_page_id;
|
|
2352
|
+
|
|
2353
|
+
if (
|
|
2354
|
+
!pageId ||
|
|
2355
|
+
!metaConnection?.meta_encrypted_access_token
|
|
2356
|
+
) {
|
|
2357
|
+
this.logger.warn(
|
|
2358
|
+
`No active Meta Instagram connection for clinic ${clinicId}`,
|
|
2359
|
+
);
|
|
2360
|
+
return null;
|
|
2361
|
+
}
|
|
2362
|
+
|
|
2363
|
+
let accessToken: string;
|
|
2364
|
+
try {
|
|
2365
|
+
accessToken = this.tokenEncryptionKey
|
|
2366
|
+
? this.encryption.decrypt(
|
|
2367
|
+
metaConnection.meta_encrypted_access_token,
|
|
2368
|
+
this.tokenEncryptionKey,
|
|
2369
|
+
)
|
|
2370
|
+
: metaConnection.meta_encrypted_access_token;
|
|
2371
|
+
} catch {
|
|
2372
|
+
this.logger.error(
|
|
2373
|
+
`Failed to decrypt Meta Instagram token for clinic ${clinicId}`,
|
|
2374
|
+
);
|
|
2375
|
+
return null;
|
|
2376
|
+
}
|
|
2377
|
+
|
|
2378
|
+
return this.metaInstagram.sendMessage(
|
|
2379
|
+
pageId,
|
|
2380
|
+
recipientId,
|
|
2381
|
+
text,
|
|
2382
|
+
accessToken,
|
|
2383
|
+
);
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
/** Send the fixed first message configured on the clinic. */
|
|
2387
|
+
private async sendFixedFirstMessage(
|
|
2388
|
+
clinicId: string,
|
|
2389
|
+
recipientPhone: string,
|
|
2390
|
+
fixedMessage: string,
|
|
2391
|
+
sessionId: string,
|
|
2392
|
+
chatId: string,
|
|
2393
|
+
tenantId: string,
|
|
2394
|
+
metaPhoneNumberId?: string,
|
|
2395
|
+
): Promise<void> {
|
|
2396
|
+
const metaResult = await this.sendViaMeta(
|
|
2397
|
+
clinicId,
|
|
2398
|
+
recipientPhone,
|
|
2399
|
+
fixedMessage,
|
|
2400
|
+
metaPhoneNumberId,
|
|
2401
|
+
);
|
|
2402
|
+
|
|
2403
|
+
if (!metaResult) return;
|
|
2404
|
+
|
|
2405
|
+
// Store the fixed message
|
|
2406
|
+
const encryptedText = this.encryption.encrypt(
|
|
2407
|
+
fixedMessage,
|
|
2408
|
+
this.messageEncryptionKey,
|
|
2409
|
+
);
|
|
2410
|
+
|
|
2411
|
+
const msg = await this.prisma.whatsappMessage.create({
|
|
2412
|
+
data: {
|
|
2413
|
+
msg_chat_id: chatId,
|
|
2414
|
+
msg_session_id: sessionId,
|
|
2415
|
+
msg_tenant_id: tenantId,
|
|
2416
|
+
msg_external_id: metaResult?.messages?.[0]?.id ?? null,
|
|
2417
|
+
msg_sender: 'system',
|
|
2418
|
+
msg_sender_name: 'System',
|
|
2419
|
+
msg_text: encryptedText,
|
|
2420
|
+
},
|
|
2421
|
+
});
|
|
2422
|
+
|
|
2423
|
+
await this.prisma.whatsappChat.update({
|
|
2424
|
+
where: { chat_id: chatId },
|
|
2425
|
+
data: {
|
|
2426
|
+
chat_last_message_at: new Date(),
|
|
2427
|
+
chat_last_message_preview: fixedMessage.substring(0, 100),
|
|
2428
|
+
chat_message_count: { increment: 1 },
|
|
2429
|
+
},
|
|
2430
|
+
});
|
|
2431
|
+
|
|
2432
|
+
this.eventEmitter.emit('chat.message.created', {
|
|
2433
|
+
chatId,
|
|
2434
|
+
messageId: msg.msg_id,
|
|
2435
|
+
tenantId,
|
|
2436
|
+
sender: 'system',
|
|
2437
|
+
});
|
|
2438
|
+
}
|
|
2439
|
+
|
|
2440
|
+
/** Enqueue a message-buffer BullMQ job. */
|
|
2441
|
+
private async bufferMessage(
|
|
2442
|
+
sessionId: string,
|
|
2443
|
+
messageId: string,
|
|
2444
|
+
): Promise<void> {
|
|
2445
|
+
await this.messageBufferQueue.add(
|
|
2446
|
+
'buffer',
|
|
2447
|
+
{ sessionId, messageId },
|
|
2448
|
+
{
|
|
2449
|
+
delay: 1500, // 1.5s debounce for multi-message bursts
|
|
2450
|
+
removeOnComplete: true,
|
|
2451
|
+
removeOnFail: 100,
|
|
2452
|
+
},
|
|
2453
|
+
);
|
|
2454
|
+
}
|
|
2455
|
+
|
|
2456
|
+
/** Get a human-readable sender label. */
|
|
2457
|
+
private getSenderLabel(
|
|
2458
|
+
sender: string,
|
|
2459
|
+
): string {
|
|
2460
|
+
switch (sender) {
|
|
2461
|
+
case 'contact':
|
|
2462
|
+
return 'Hasta';
|
|
2463
|
+
case 'ai':
|
|
2464
|
+
return 'AI';
|
|
2465
|
+
case 'human':
|
|
2466
|
+
return 'Operator';
|
|
2467
|
+
case 'system':
|
|
2468
|
+
return 'Sistem';
|
|
2469
|
+
default:
|
|
2470
|
+
return sender;
|
|
2471
|
+
}
|
|
2472
|
+
}
|
|
2473
|
+
|
|
2474
|
+
/** Check if a tenant has a specific feature enabled. */
|
|
2475
|
+
private async isTenantFeatureEnabled(
|
|
2476
|
+
tenantId: string,
|
|
2477
|
+
feature: string,
|
|
2478
|
+
): Promise<boolean> {
|
|
2479
|
+
try {
|
|
2480
|
+
const tenant = await this.prisma.tenant.findUnique({
|
|
2481
|
+
where: { tenant_id: tenantId },
|
|
2482
|
+
select: { tenant_enabled_features: true },
|
|
2483
|
+
});
|
|
2484
|
+
return tenant?.tenant_enabled_features?.includes(feature) ?? false;
|
|
2485
|
+
} catch {
|
|
2486
|
+
return false;
|
|
2487
|
+
}
|
|
2488
|
+
}
|
|
2489
|
+
|
|
2490
|
+
/** Send a JSON message over a WS connection. */
|
|
2491
|
+
private wsSend(conn: WsConnection, data: Record<string, any>): void {
|
|
2492
|
+
if (conn.ws.readyState !== WebSocket.OPEN) {
|
|
2493
|
+
this.logger.warn(
|
|
2494
|
+
`Cannot send WS message for chat ${conn.chatId}: not open (state=${conn.ws.readyState})`,
|
|
2495
|
+
);
|
|
2496
|
+
return;
|
|
2497
|
+
}
|
|
2498
|
+
try {
|
|
2499
|
+
conn.ws.send(JSON.stringify(data));
|
|
2500
|
+
} catch (err: any) {
|
|
2501
|
+
this.logger.error(
|
|
2502
|
+
`Failed to send WS message for chat ${conn.chatId}: ${err.message}`,
|
|
2503
|
+
);
|
|
2504
|
+
}
|
|
2505
|
+
}
|
|
2506
|
+
}
|