Files
notification-wakeup-service/src/services/smsService.ts
T

204 lines
6.7 KiB
TypeScript

import { smsConfig, type SmsConfig } from "../env.js";
import { errorMessage } from "../util/formatElapsed.js";
import { maskPhoneNumber } from "../util/smsPhoneNumber.js";
import { log } from "../util/log.js";
export type SmsSendResult =
| { status: "sent"; messageId: string }
| { status: "failed"; error: string; code?: number };
export type SmsSender = (to: string, body: string) => Promise<SmsSendResult>;
export const SMS_NOT_CONFIGURED = "SMS_NOT_CONFIGURED";
/**
* Twilio's "attempt to send to unsubscribed recipient". It is the provider
* telling us a number is on its own opt-out list, which is the authority for
* compliance and is invisible to this service any other way.
*/
export const TWILIO_UNSUBSCRIBED_CODE = 21610;
const TWILIO_API_BASE = "https://api.twilio.com/2010-04-01";
type TwilioCredentials = {
accountSid: string;
authToken: string;
from: { From: string } | { MessagingServiceSid: string };
};
/**
* Names the variables a send would need and does not have. An account and a
* token alone cannot produce a message: there has to be something to send from.
*/
export function missingTwilioConfig(config: SmsConfig = smsConfig()): string[] {
const missing: string[] = [];
if (config.twilioAccountSid === undefined) missing.push("TWILIO_ACCOUNT_SID");
if (config.twilioAuthToken === undefined) missing.push("TWILIO_AUTH_TOKEN");
if (
config.twilioMessagingServiceSid === undefined &&
config.twilioFromNumber === undefined
) {
missing.push("TWILIO_MESSAGING_SERVICE_SID or TWILIO_FROM_NUMBER");
}
return missing;
}
/**
* Whether this instance may text the given identity at all.
*
* An environment guard rather than a transport concern: a test server pointed
* at live credentials and restored from a production database would otherwise
* text every verified user it inherited. Comparison is case-insensitive, since
* a checksummed `did:ethr` address and its lowercase form name one identity.
*/
export function isSmsRecipientAllowed(
userId: string,
config: SmsConfig = smsConfig()
): boolean {
const allowed = config.allowedRecipientDids;
if (allowed === undefined) return true;
return allowed.includes(userId.trim().toLowerCase());
}
/**
* A sender needs an account, a token, and something to send from. Anything less
* cannot produce a message, so it is not a partial configuration but no sender.
*/
function twilioCredentials(
config: SmsConfig
): TwilioCredentials | undefined {
const { twilioAccountSid, twilioAuthToken } = config;
if (twilioAccountSid === undefined || twilioAuthToken === undefined) {
return undefined;
}
const from =
config.twilioMessagingServiceSid !== undefined
? { MessagingServiceSid: config.twilioMessagingServiceSid }
: config.twilioFromNumber !== undefined
? { From: config.twilioFromNumber }
: undefined;
if (from === undefined) return undefined;
return { accountSid: twilioAccountSid, authToken: twilioAuthToken, from };
}
/** One form POST. The repo already talks to Endorser and Partner with fetch. */
export async function sendViaTwilio(
credentials: TwilioCredentials,
to: string,
body: string
): Promise<SmsSendResult> {
const url = `${TWILIO_API_BASE}/Accounts/${encodeURIComponent(
credentials.accountSid
)}/Messages.json`;
const form = new URLSearchParams({ To: to, Body: body, ...credentials.from });
const basic = Buffer.from(
`${credentials.accountSid}:${credentials.authToken}`
).toString("base64");
try {
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `Basic ${basic}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: form.toString(),
});
const payload = (await response.json().catch(() => ({}))) as {
sid?: unknown;
message?: unknown;
code?: unknown;
};
if (!response.ok) {
const detail =
typeof payload.message === "string"
? payload.message
: `HTTP ${response.status}`;
return {
status: "failed",
error: detail,
...(typeof payload.code === "number" ? { code: payload.code } : {}),
};
}
if (typeof payload.sid !== "string" || payload.sid.length === 0) {
return { status: "failed", error: "Twilio response carried no sid" };
}
return { status: "sent", messageId: payload.sid };
} catch (err) {
return { status: "failed", error: errorMessage(err) };
}
}
/** Prints instead of sending, so a desk with no carrier coverage still works. */
export const consoleSmsSender: SmsSender = async (to, body) => {
log.info(
"[SmsService] Console adapter would send to",
maskPhoneNumber(to) + ":",
body
);
return { status: "sent", messageId: `console-${Date.now()}` };
};
let warnedNotConfigured = false;
/**
* The configured sender, resolved per call. Missing credentials fail the send
* rather than the process: a texting outage should not take push down with it.
*/
export const sendSms: SmsSender = async (to, body) => {
const config = smsConfig();
const credentials = twilioCredentials(config);
if (credentials === undefined) {
if (process.env.NODE_ENV === "test-local") {
return consoleSmsSender(to, body);
}
if (!warnedNotConfigured) {
warnedNotConfigured = true;
log.error(
"[SmsService] Twilio is not configured; SMS sends will fail. Missing:",
missingTwilioConfig(config).join(", ")
);
}
return { status: "failed", error: SMS_NOT_CONFIGURED };
}
return sendViaTwilio(credentials, to, body);
};
/** Test helper: let the once-per-process warning fire again. */
export function resetSmsNotConfiguredWarning(): void {
warnedNotConfigured = false;
}
/**
* Whether this service must not text a number, for any reason.
*
* Two sources, both consulted: the `sms_blocked_numbers` table, which the STOP
* webhook writes to, and `SMS_BLOCKED_NUMBERS`, which blocks a number without
* waiting for the handset to ask. Twilio keeps its own opt-out list and refuses
* such messages with 21610 regardless; this is how the service comes to agree
* with it rather than retrying forever.
*/
export async function isPhoneNumberBlocked(input: {
phoneE164: string;
phoneHash: string;
config?: SmsConfig;
}): Promise<boolean> {
const config = input.config ?? smsConfig();
if (config.blockedNumbers.length > 0) {
const { normalizePhoneNumber } = await import(
"../util/smsPhoneNumber.js"
);
for (const entry of config.blockedNumbers) {
if (normalizePhoneNumber(entry) === input.phoneE164) return true;
}
}
const { smsBlockedNumbersDb } = await import(
"../db/smsBlockedNumbersSqlite.js"
);
return smsBlockedNumbersDb.isBlocked(input.phoneHash);
}