287 lines
8.9 KiB
JavaScript
287 lines
8.9 KiB
JavaScript
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;
|
|
|
|
9; // 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
|
|
};
|