const client = require("../graphql-client/graphql-client").client; const { FIND_BODYSHOP_BY_MESSAGING_SERVICE_SID, UNARCHIVE_CONVERSATION, CREATE_CONVERSATION, INSERT_MESSAGE, CHECK_PHONE_NUMBER_OPT_OUT, DELETE_PHONE_NUMBER_OPT_OUT, INSERT_PHONE_NUMBER_OPT_OUT } = require("../graphql-client/queries"); const { phone } = require("phone"); const { admin } = require("../firebase/firebase-handler"); const InstanceManager = require("../utils/instanceMgr").default; / TWILLIO KEYWORDS; // Note: When we handle different languages, we might need to adjust these keywords accordingly. const optInKeywords = ["START", "YES", "UNSTOP"]; const optOutKeywords = ["STOP", "STOPALL", "UNSUBSCRIBE", "CANCEL", "END", "QUIT", "REVOKE", "OPTOUT"]; /** * Receive SMS messages from Twilio and process them * @param req * @param res * @returns {Promise<*>} */ const receive = async (req, res) => { const { logger, ioRedis, ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom } } = req; const loggerData = { msid: req.body.SmsMessageSid, text: req.body.Body, image: !!req.body.MediaUrl0, image_path: generateMediaArray(req.body, logger) }; logger.log("sms-inbound", "DEBUG", "api", null, loggerData); if (!req.body || !req.body.MessagingServiceSid || !req.body.SmsMessageSid) { logger.log("sms-inbound-error", "ERROR", "api", null, { ...loggerData, type: "malformed-request" }); return res.status(400).json({ success: false, error: "Malformed Request" }); } try { // Step 1: Find the bodyshop and existing conversation const response = await client.request(FIND_BODYSHOP_BY_MESSAGING_SERVICE_SID, { mssid: req.body.MessagingServiceSid, phone: phone(req.body.From).phoneNumber }); if (!response.bodyshops[0]) { return res.status(400).json({ success: false, error: "No matching bodyshop" }); } const bodyshop = response.bodyshops[0]; const normalizedPhone = phone(req.body.From).phoneNumber.replace(/^\+1/, ""); // Normalize phone number (remove +1 for CA numbers) const messageText = (req.body.Body || "").trim().toUpperCase(); // Step 2: Check for opt-in or opt-out keywords if (optInKeywords.includes(messageText) || optOutKeywords.includes(messageText)) { // Check if the phone number is in phone_number_opt_out const optOutCheck = await client.request(CHECK_PHONE_NUMBER_OPT_OUT, { bodyshopid: bodyshop.id, phone_number: normalizedPhone }); if (optInKeywords.includes(messageText)) { // Handle opt-in if (optOutCheck.phone_number_opt_out.length > 0) { // Phone number is opted out; delete the record const deleteResponse = await client.request(DELETE_PHONE_NUMBER_OPT_OUT, { bodyshopid: bodyshop.id, phone_number: normalizedPhone }); logger.log("sms-opt-in-success", "INFO", "api", null, { msid: req.body.SmsMessageSid, bodyshopid: bodyshop.id, phone_number: normalizedPhone, affected_rows: deleteResponse.delete_phone_number_opt_out.affected_rows }); // Emit WebSocket event to notify clients const broadcastRoom = getBodyshopRoom(bodyshop.id); ioRedis.to(broadcastRoom).emit("phone-number-opted-in", { bodyshopid: bodyshop.id, phone_number: normalizedPhone }); } } else if (optOutKeywords.includes(messageText)) { // Handle opt-out if (optOutCheck.phone_number_opt_out.length === 0) { // Phone number is not opted out; insert a new record const now = new Date().toISOString(); const optOutInput = { bodyshopid: bodyshop.id, phone_number: normalizedPhone, created_at: now, updated_at: now }; const insertResponse = await client.request(INSERT_PHONE_NUMBER_OPT_OUT, { optOutInput: [optOutInput] }); logger.log("sms-opt-out-success", "INFO", "api", null, { msid: req.body.SmsMessageSid, bodyshopid: bodyshop.id, phone_number: normalizedPhone, affected_rows: insertResponse.insert_phone_number_opt_out.affected_rows }); // Emit WebSocket event to notify clients const broadcastRoom = getBodyshopRoom(bodyshop.id); ioRedis.to(broadcastRoom).emit("phone-number-opted-out", { bodyshopid: bodyshop.id, phone_number: normalizedPhone }); } } // Respond immediately without processing as a regular message res.status(200).send(""); return; } // Step 3: Process conversation const sortedConversations = bodyshop.conversations.sort((a, b) => new Date(a.created_at) - new Date(b.created_at)); const existingConversation = sortedConversations.length ? sortedConversations[sortedConversations.length - 1] : null; let conversationid; let newMessage = { msid: req.body.SmsMessageSid, text: req.body.Body, image: !!req.body.MediaUrl0, image_path: generateMediaArray(req.body, logger), isoutbound: false, userid: null }; if (existingConversation) { conversationid = existingConversation.id; if (existingConversation.archived) { await client.request(UNARCHIVE_CONVERSATION, { id: conversationid, archived: false }); } } else { const newConversationResponse = await client.request(CREATE_CONVERSATION, { conversation: { bodyshopid: bodyshop.id, phone_num: phone(req.body.From).phoneNumber, archived: false } }); const createdConversation = newConversationResponse.insert_conversations.returning[0]; conversationid = createdConversation.id; } newMessage.conversationid = conversationid; // Step 4: Insert the message const insertresp = await client.request(INSERT_MESSAGE, { msg: newMessage, conversationid }); const message = insertresp?.insert_messages?.returning?.[0]; const conversation = message?.conversation || null; if (!conversation) { throw new Error("Conversation data is missing from the response."); } // Step 5: Notify clients const conversationRoom = getBodyshopConversationRoom({ bodyshopId: conversation.bodyshop.id, conversationId: conversation.id }); const commonPayload = { isoutbound: false, conversationId: conversation.id, updated_at: message.updated_at, msid: message.sid }; const broadcastRoom = getBodyshopRoom(conversation.bodyshop.id); ioRedis.to(broadcastRoom).emit("new-message-summary", { ...commonPayload, existingConversation: !!existingConversation, newConversation: !existingConversation ? conversation : null, summary: true }); ioRedis.to(conversationRoom).emit("new-message-detailed", { newMessage: message, ...commonPayload, newConversation: !existingConversation ? conversation : null, existingConversation: !!existingConversation, summary: false }); // Step 6: Send FCM notification const fcmresp = await admin.messaging().send({ topic: `${message.conversation.bodyshop.imexshopid}-messaging`, notification: { title: InstanceManager({ imex: `ImEX Online Message - ${message.conversation.phone_num}`, rome: `Rome Online Message - ${message.conversation.phone_num}` }), body: message.image_path ? `Image ${message.text}` : message.text }, data: { type: "messaging-inbound", conversationid: message.conversationid || "", text: message.text || "", messageid: message.id || "", phone_num: message.conversation.phone_num || "" } }); logger.log("sms-inbound-success", "DEBUG", "api", null, { newMessage, fcmresp }); res.status(200).send(""); } catch (e) { handleError(req, e, res, "RECEIVE_MESSAGE", logger); } }; /** * Generate media array from the request body * @param body * @param logger * @returns {null|*[]} */ const generateMediaArray = (body, logger) => { const { NumMedia } = body; if (parseInt(NumMedia) > 0) { const ret = []; for (let i = 0; i < parseInt(NumMedia); i++) { ret.push(body[`MediaUrl${i}`]); } return ret; } else { return null; } }; /** * Handle error logging and response * @param req * @param error * @param res * @param context * @param logger */ const handleError = (req, error, res, context, logger) => { logger.log("sms-inbound-error", "ERROR", "api", null, { msid: req.body.SmsMessageSid, text: req.body.Body, image: !!req.body.MediaUrl0, image_path: generateMediaArray(req.body, logger), messagingServiceSid: req.body.MessagingServiceSid, context, error }); res.status(500).json({ error: error.message || "Internal Server Error" }); }; module.exports = { receive };