/opt/mawid/apps/api/src/whatsapp
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;
}
}