Files

120 lines
4.0 KiB
TypeScript

/**
* Inspect and manage the suppression list without hand-writing SQL.
*
* pkgx pnpm run sms:blocks list every block
* pkgx pnpm run sms:blocks list manual list one origin only
* pkgx pnpm run sms:blocks block +15551234567 "spam complaint"
* pkgx pnpm run sms:blocks unblock +15551234567
*
* Blocking here is always recorded as `manual`, and never downgrades an
* existing opt-out. Needs SMS_CODE_SECRET, since rows are keyed on the HMAC of
* the number.
*/
import {
smsBlockedNumbersDb,
type SmsBlockReason,
} from "../src/db/smsBlockedNumbersSqlite.js";
import { smsConfig } from "../src/env.js";
import { normalizePhoneNumber } from "../src/util/smsPhoneNumber.js";
import { hashPhoneNumber } from "../src/util/smsVerificationCode.js";
const REASONS: SmsBlockReason[] = ["opt-out", "provider-opt-out", "manual"];
const ORIGIN_NOTE: Record<SmsBlockReason, string> = {
"opt-out": "the handset texted STOP to this service",
"provider-opt-out": "Twilio refused a send with 21610",
manual: "an operator added it here",
};
const secret = smsConfig().codeSecret;
if (secret === undefined) {
console.error("Set SMS_CODE_SECRET; blocks are keyed on the HMAC of the number.");
process.exit(1);
}
function requireNumber(raw: string | undefined): string {
const normalized = normalizePhoneNumber(raw);
if (normalized === undefined) {
console.error(`Not a phone number: ${raw ?? "(missing)"}`);
process.exit(1);
}
return normalized;
}
const [command = "list", ...rest] = process.argv.slice(2);
if (command === "list") {
const filter = rest[0] as SmsBlockReason | undefined;
if (filter !== undefined && !REASONS.includes(filter)) {
console.error(`Unknown origin: ${filter}. One of: ${REASONS.join(", ")}`);
process.exit(1);
}
const rows = (await smsBlockedNumbersDb.list(1000)).filter(
(row) => filter === undefined || row.reason === filter
);
if (rows.length === 0) {
console.log(filter === undefined ? "No blocked numbers." : `No ${filter} blocks.`);
process.exit(0);
}
for (const reason of REASONS) {
const group = rows.filter((row) => row.reason === reason);
if (group.length === 0) continue;
console.log(`\n${reason} (${group.length}) — ${ORIGIN_NOTE[reason]}`);
for (const row of group) {
console.log(
` ${(row.phoneE164 ?? "(hash only)").padEnd(16)} ${row.createdAt}` +
(row.detail === undefined ? "" : ` ${row.detail}`)
);
}
}
console.log(`\n${rows.length} total.`);
process.exit(0);
}
if (command === "block") {
const phoneE164 = requireNumber(rest[0]);
const stored = await smsBlockedNumbersDb.block({
phoneHash: hashPhoneNumber(phoneE164, secret),
phoneE164,
reason: "manual",
detail: rest[1],
});
console.log(`Blocked ${phoneE164} — recorded as ${stored.reason}.`);
if (stored.reason !== "manual") {
console.log(
"It already carried a stronger origin, which is kept: an opt-out record\n" +
"is what says the block may not simply be lifted again."
);
}
process.exit(0);
}
if (command === "unblock") {
const phoneE164 = requireNumber(rest[0]);
const phoneHash = hashPhoneNumber(phoneE164, secret);
const existing = await smsBlockedNumbersDb.get(phoneHash);
if (existing === undefined) {
console.log(`${phoneE164} is not blocked.`);
process.exit(0);
}
if (existing.reason !== "manual" && rest[1] !== "--force") {
console.error(
`${phoneE164} is blocked as "${existing.reason}" — ${ORIGIN_NOTE[existing.reason]}.\n` +
"Lifting it would resume messaging someone who asked to stop, and Twilio\n" +
"will refuse the send anyway until they text START. Pass --force if you\n" +
"are certain this is a mistaken record."
);
process.exit(1);
}
await smsBlockedNumbersDb.unblock(phoneHash);
console.log(`Unblocked ${phoneE164} (was ${existing.reason}).`);
process.exit(0);
}
console.error(`Unknown command: ${command}. Use list, block, or unblock.`);
process.exit(1);