/opt/mawid/apps/api/src/whatsapp
NameSizeModeActions
owner-notifier.service.ts17450644editdlrm
wa-core.module.ts13520644editdlrm
wa-http.client.ts17360644editdlrm
wa-inbound.service.spec.ts50660644editdlrm
wa-inbound.service.ts54310644editdlrm
wa-outbound.worker.ts27230644editdlrm
wa-queue.ts4470644editdlrm
wa-sender.service.ts49590644editdlrm
wa-signature.spec.ts12640644editdlrm
wa-signature.ts6620644editdlrm
wa-types.ts16830644editdlrm
wa-webhook.controller.spec.ts26840644editdlrm
wa-webhook.controller.ts23080644editdlrm
wa-window.spec.ts8600644editdlrm
wa-window.ts5090644editdlrm
whatsapp.module.ts5790644editdlrm
Edit: /opt/mawid/apps/api/src/whatsapp/wa-inbound.service.ts (5431B)
import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { t, type Language } from '@mawid/shared'; import { AgentService } from '../agent/agent.service'; import { PrismaService } from '../prisma/prisma.service'; import { ReminderReplyService } from '../reminders/reminder-reply.service'; import { WaitlistReplyService } from '../waitlist/waitlist-reply.service'; import { WaitlistService } from '../waitlist/waitlist.service'; import { WaSenderService } from './wa-sender.service'; import type { WaChangeValue, WaInboundMessage, WaWebhookPayload } from './wa-types'; @Injectable() export class WaInboundService { private readonly logger = new Logger(WaInboundService.name); constructor( private readonly prisma: PrismaService, private readonly sender: WaSenderService, private readonly config: ConfigService, private readonly agent: AgentService, private readonly reminderReplies: ReminderReplyService, private readonly waitlistReplies: WaitlistReplyService, private readonly waitlist: WaitlistService, ) {} async process(payload: WaWebhookPayload): Promise { for (const entry of payload.entry ?? []) { for (const change of entry.changes ?? []) { if (change.field !== 'messages') continue; await this.processChange(change.value); } } } private async processChange(value: WaChangeValue): Promise { const clinic = await this.prisma.clinic.findUnique({ where: { waPhoneNumberId: value.metadata.phone_number_id }, }); if (!clinic) { this.logger.warn(`No clinic for phone_number_id=${value.metadata.phone_number_id}, skipping`); return; } for (const status of value.statuses ?? []) { await this.applyStatus(status.id, status.status); } for (const message of value.messages ?? []) { const contactName = value.contacts?.find((c) => c.wa_id === message.from)?.profile?.name; await this.processMessage(clinic.id, clinic.defaultLanguage, message, contactName); } } private async applyStatus(waMessageId: string, status: string): Promise { const message = await this.prisma.message.findUnique({ where: { waMessageId } }); if (!message) return; const payload = (message.payload ?? {}) as Record; await this.prisma.message.update({ where: { id: message.id }, data: { payload: { ...payload, deliveryStatus: status } }, }); } private async processMessage( clinicId: string, clinicLanguage: string, message: WaInboundMessage, contactName?: string, ): Promise { // Meta redelivers webhooks — waMessageId is unique, so replays are no-ops. const duplicate = await this.prisma.message.findUnique({ where: { waMessageId: message.id }, }); if (duplicate) return; const waPhone = `+${message.from}`; const patient = await this.prisma.patient.upsert({ where: { clinicId_waPhone: { clinicId, waPhone } }, create: { clinicId, waPhone, name: contactName, language: clinicLanguage }, update: contactName ? { name: contactName } : {}, }); const conversation = await this.sender.getOrCreateConversation(clinicId, patient.id); const body = this.extractBody(message); await this.prisma.message.create({ data: { conversationId: conversation.id, direction: 'inbound', waMessageId: message.id, type: message.type, body, payload: message as unknown as object, }, }); await this.prisma.conversation.update({ where: { id: conversation.id }, data: { lastMessageAt: new Date() }, }); // Reminder and waitlist buttons are deterministic — handle them before // echo mode or the agent. if (body) { const reminderResult = await this.reminderReplies.tryHandle(clinicId, patient.id, body); if (reminderResult.handled) { if (reminderResult.cancelledAppointmentId) { await this.waitlist.onAppointmentCancelled(reminderResult.cancelledAppointmentId); } return; } if (await this.waitlistReplies.tryHandle(clinicId, patient.id, body)) { return; } } // Echo mode is a pipe-verification tool and takes precedence over the agent. if (this.config.get('FEATURE_ECHO_MODE') === true && message.type === 'text' && body) { const lang = (['ar', 'tr', 'en'] as const).includes(patient.language as Language) ? (patient.language as Language) : 'en'; await this.sender.sendText(clinicId, patient.id, t('echoReceived', lang, { text: body })); return; } // Agent runs only when OPENAI_API_KEY is configured. if (this.agent.enabled && body) { const reply = await this.agent.handleInbound({ clinicId, patientId: patient.id, conversationId: conversation.id, text: body, }); if (reply) { await this.sender.sendText(clinicId, patient.id, reply); } } } private extractBody(message: WaInboundMessage): string | null { if (message.text?.body) return message.text.body; if (message.interactive?.button_reply) return message.interactive.button_reply.id; if (message.interactive?.list_reply) return message.interactive.list_reply.id; if (message.button?.payload) return message.button.payload; return null; } }