120 lines
4.0 KiB
TypeScript
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);
|