204 lines
6.7 KiB
TypeScript
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);
|
|
}
|