Skip to content

Commit

Permalink
Merge branch 'ag/send-auto-opt-out-response' into stage-main-12-2
Browse files Browse the repository at this point in the history
  • Loading branch information
crayolakat authored and codygordon committed Jul 4, 2022
1 parent 07d10d1 commit 7c22825
Show file tree
Hide file tree
Showing 5 changed files with 202 additions and 52 deletions.
79 changes: 79 additions & 0 deletions __test__/extensions/message-handlers/auto-optout.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { postMessageSave } from "../../../src/extensions/message-handlers/auto-optout";
import { cacheableData } from "../../../src/server/models";
const sendMessage = require("../../../src/server/api/mutations/sendMessage");

describe("Auto Opt-Out Tests", () => {
let message;

beforeEach(() => {
jest.resetAllMocks();

global.SEND_AUTO_OPT_OUT_RESPONSE = false;
global.DEFAULT_SERVICE = "fakeservice";

jest.spyOn(cacheableData.optOut, "save").mockResolvedValue(null);
jest.spyOn(cacheableData.campaignContact, "load").mockResolvedValue({
id: 1,
assignment_id: 2
});
jest.spyOn(sendMessage, "sendRawMessage").mockResolvedValue(null);

message = {
is_from_contact: true,
contact_number: "+123456",
campaign_contact_id: 1,
text: 'please stop'
};
});

afterEach(() => {
global.SEND_AUTO_OPT_OUT_RESPONSE = false;
global.DEFAULT_SERVICE = "fakeservice";
});

it("Does not send message without env variable", async () => {
await postMessageSave({
message,
organization: { id: 1 },
handlerContext: { autoOptOutReason: "stop" }
});

expect(cacheableData.optOut.save).toHaveBeenCalled();
expect(cacheableData.campaignContact.load).toHaveBeenCalled();

expect(sendMessage.sendRawMessage).not.toHaveBeenCalled();
});

it("Sends message with env variable", async () => {
global.SEND_AUTO_OPT_OUT_RESPONSE = true;

await postMessageSave({
message,
organization: { id: 1 },
handlerContext: { autoOptOutReason: "stop" }
});

expect(cacheableData.optOut.save).toHaveBeenCalled();
expect(cacheableData.campaignContact.load).toHaveBeenCalled();

expect(sendMessage.sendRawMessage).toHaveBeenCalled();
});

it("Does not send with twilio opt-out words", async () => {
global.SEND_AUTO_OPT_OUT_RESPONSE = true;
global.DEFAULT_SERVICE = "twilio";

message.text = " stopall ";

await postMessageSave({
message,
organization: { id: 1 },
handlerContext: { autoOptOutReason: "stop" }
});

expect(cacheableData.optOut.save).toHaveBeenCalled();
expect(cacheableData.campaignContact.load).toHaveBeenCalled();

expect(sendMessage.sendRawMessage).not.toHaveBeenCalled();
});
})
1 change: 1 addition & 0 deletions docs/REFERENCE-environment_variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@
| ROLLBAR_CLIENT_TOKEN | Client token for Rollbar error tracking. |
| ROLLBAR_ACCESS_TOKEN | Access token for Rollbar error tracking. |
| ROLLBAR_ENDPOINT | Endpoint URL for Rollbar error tracking. |
| SEND_AUTO_OPT_OUT_RESPONSE | Send the organization's default opt-out message if a user is auto-opted out. Alternatively, set a shouldAutoRespond property in an item for the auto opt-out regex to true, for it to only apply on those regex matches. |
| SESSION_SECRET | Unique key used to encrypt sessions. _Required_. |
| SHOW_SERVER_ERROR | Best practice is to hide errors in production for security purposes which can reveal internal database/system state (even in an open-source project where the code paths are known) |
| SLACK_NOTIFY_URL | If set, then on post-install (often from deploying) a message will be posted to a slack channel's `#spoke` channel |
Expand Down
2 changes: 1 addition & 1 deletion src/containers/AdminBulkScriptEditor.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ class AdminBulkScriptEditor extends Component {
<p style={{ fontStyle: "italic" }}>
Note: the text must be an exact match. For example, be careful of
single quotes vs. double quotes: <span style={styles.code}>'</span>{" "}
vs <span style={styles.code}></span>
vs <span style={styles.code}></span> )
</p>
</Paper>
<Paper style={styles.paddedPaper}>
Expand Down
56 changes: 51 additions & 5 deletions src/extensions/message-handlers/auto-optout/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { getConfig } from "../../../server/api/lib/config";
import { getConfig, getFeatures } from "../../../server/api/lib/config";
import { cacheableData } from "../../../server/models";
import { sendRawMessage } from "../../../server/api/mutations/sendMessage";

const DEFAULT_AUTO_OPTOUT_REGEX_LIST_BASE64 =
"W3sicmVnZXgiOiAiXlxccypzdG9wXFxifFxcYnJlbW92ZSBtZVxccyokfHJlbW92ZSBteSBuYW1lfFxcYnRha2UgbWUgb2ZmIHRoXFx3KyBsaXN0fFxcYmxvc2UgbXkgbnVtYmVyfGRvblxcVz90IGNvbnRhY3QgbWV8ZGVsZXRlIG15IG51bWJlcnxJIG9wdCBvdXR8c3RvcDJxdWl0fHN0b3BhbGx8Xlxccyp1bnN1YnNjcmliZVxccyokfF5cXHMqY2FuY2VsXFxzKiR8XlxccyplbmRcXHMqJHxeXFxzKnF1aXRcXHMqJCIsICJyZWFzb24iOiAic3RvcCJ9XQ==";
Expand Down Expand Up @@ -69,7 +70,10 @@ export const preMessageSave = async ({ messageToSave, organization }) => {
error_code: -133,
message_status: "closed"
},
handlerContext: { autoOptOutReason: reason },
handlerContext: {
autoOptOutReason: reason,
autoOptOutShouldAutoRespond: matches[0].shouldAutoRespond
},
messageToSave
};
}
Expand All @@ -79,24 +83,27 @@ export const preMessageSave = async ({ messageToSave, organization }) => {
export const postMessageSave = async ({
message,
organization,
handlerContext
handlerContext,
campaign
}) => {
if (message.is_from_contact && handlerContext.autoOptOutReason) {
console.log(
"auto-optout.postMessageSave",
message.campaign_contact_id,
handlerContext.autoOptOutReason
);
const contact = await cacheableData.campaignContact.load(
let contact = await cacheableData.campaignContact.load(
message.campaign_contact_id,
{ cacheOnly: true }
);
campaign = campaign || { organization_id: organization.id };

// OPTOUT
await cacheableData.optOut.save({
cell: message.contact_number,
campaignContactId: message.campaign_contact_id,
assignmentId: (contact && contact.assignment_id) || null,
campaign: { organization_id: organization.id },
campaign: campaign,
noReply: true,
reason: handlerContext.autoOptOutReason,
// RISKY: we depend on the contactUpdates in preMessageSave
Expand All @@ -106,5 +113,44 @@ export const postMessageSave = async ({
organization,
user: null // If this is auto-optout, there is no user happening.
});

if (
handlerContext.autoOptOutShouldAutoRespond ||
getConfig("SEND_AUTO_OPT_OUT_RESPONSE", organization)
) {
// https://support.twilio.com/hc/en-us/articles/223134027-Twilio-support-for-opt-out-keywords-SMS-STOP-filtering-
const twilioAutoOptOutWords = [
"STOP",
"STOPALL",
"UNSUBSCRIBE",
"CANCEL",
"END",
"QUIT"
];

if (
getConfig("DEFAULT_SERVICE", organization) == "twilio" &&
twilioAutoOptOutWords.indexOf(message.text.toUpperCase().trim()) > -1
) {
return;
}

contact =
contact ||
(await cacheableData.campaignContact.load(message.campaign_contact_id));

const optOutMessage =
getFeatures(organization).opt_out_message ||
getConfig("OPT_OUT_MESSAGE", organization) ||
"I'm opting you out of texts immediately. Have a great day.";

await sendRawMessage({
finalText: optOutMessage,
contact,
campaign,
organization,
user: {}
});
}
}
};
116 changes: 70 additions & 46 deletions src/server/api/mutations/sendMessage.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,71 @@ const newError = (message, code, details = {}) => {
return err;
};

export const sendRawMessage = async ({
finalText,
contact,
campaign,
organization,
user,
sendBeforeDate,
cannedResponseId
}) => {
const orgFeatures = JSON.parse(organization.features || "{}");

const serviceName =
orgFeatures.service ||
global.DEFAULT_SERVICE ||
process.env.DEFAULT_SERVICE ||
"";

const messageInstance = new Message({
text: finalText,
contact_number: contact.cell,
user_number: "",
user_id: user.id,
campaign_contact_id: contact.id,
messageservice_sid: null,
send_status: JOBS_SAME_PROCESS ? "SENDING" : "QUEUED",
service: serviceName,
is_from_contact: false,
queued_at: new Date(),
send_before: sendBeforeDate
});

const saveResult = await cacheableData.message.save({
messageInstance,
contact,
campaign,
organization,
texter: user,
cannedResponseId
});

if (!saveResult.message) {
console.log("SENDERR_SAVEFAIL", saveResult);
throw newError(
`Message send error ${saveResult.texterError ||
saveResult.matchError ||
saveResult.error ||
""}`,
saveResult.error || "SENDERR_SAVEFAIL"
);
}

if (!saveResult.blockSend) {
await jobRunner.dispatchTask(Tasks.SEND_MESSAGE, {
message: saveResult.message,
contact,
// TODO: start a transaction inside the service send message function
trx: null,
organization,
campaign
});
}

return saveResult.contactStatus;
};

export const sendMessage = async (
_,
{ message, campaignContactId, cannedResponseId },
Expand All @@ -81,7 +146,6 @@ export const sendMessage = async (
const organization = await loaders.organization.load(
campaign.organization_id
);
const orgFeatures = JSON.parse(organization.features || "{}");

const optOut = await cacheableData.optOut.query({
cell: contact.cell,
Expand Down Expand Up @@ -165,59 +229,19 @@ export const sendMessage = async (
}
);
}
const serviceName =
orgFeatures.service ||
global.DEFAULT_SERVICE ||
process.env.DEFAULT_SERVICE ||
"";

const finalText = replaceEasyGsmWins(text);
const messageInstance = new Message({
text: finalText,
contact_number: contact.cell,
user_number: "",
user_id: user.id,
campaign_contact_id: contact.id,
messageservice_sid: null,
send_status: JOBS_SAME_PROCESS ? "SENDING" : "QUEUED",
service: serviceName,
is_from_contact: false,
queued_at: new Date(),
send_before: sendBeforeDate
});

const initialMessageStatus = contact.message_status;
const finalText = replaceEasyGsmWins(text);

const saveResult = await cacheableData.message.save({
messageInstance,
contact.message_status = await sendRawMessage({
finalText,
contact,
campaign,
organization,
texter: user,
user,
sendBeforeDate,
cannedResponseId
});
if (!saveResult.message) {
console.log("SENDERR_SAVEFAIL", saveResult);
throw newError(
`Message send error ${saveResult.texterError ||
saveResult.matchError ||
saveResult.error ||
""}`,
saveResult.error || "SENDERR_SAVEFAIL"
);
}
contact.message_status = saveResult.contactStatus;

if (!saveResult.blockSend) {
await jobRunner.dispatchTask(Tasks.SEND_MESSAGE, {
message: saveResult.message,
contact,
// TODO: start a transaction inside the service send message function
trx: null,
organization,
campaign
});
}

if (cannedResponseId) {
const cannedResponses = await cacheableData.cannedResponse.query({
Expand Down

0 comments on commit 7c22825

Please sign in to comment.