Files
selfphoneng/apps/api/src/spam/ipqs.ts
T
iamdoubz 6f87d6f663 api: spam check settings, lookups and cache
SD1 (14 §9): the spam/lookups tables and calls columns, the
IPQualityScore and FTC lookups with the cache, the spam settings with
write-only keys, the provider Test routes and Settings → Spam check.
2026-10-27 15:32:31 -05:00

61 lines
2.4 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
// IPQualityScore (14 §2.1). Its key goes in the URL's path: see http.ts.
import type { IpqsInfo, SpamRisk } from '@selfphoneng/shared';
import { SpamError, getJson } from './http.ts';
const BASE = 'https://www.ipqualityscore.com/api/json';
export type IpqsAnswer = { kind: 'ok'; risk: SpamRisk; info: IpqsInfo } | { kind: 'exhausted' };
const get = (path: string) => getJson(`${BASE}/${path}`);
const str = (v: unknown) => (typeof v === 'string' && v.trim() && v !== 'N/A' ? v.trim() : null);
const used = (msg: unknown) => typeof msg === 'string' && /insufficient credits/i.test(msg);
/** The provider's own message, cut short and without the key in case it quotes it. */
const said = (msg: unknown, key: string) =>
new SpamError(
typeof msg === 'string' && msg
? msg.replaceAll(key, '***').slice(0, 200)
: 'the request failed',
);
/** Risk (owner, 2026-10-27): IPQualityScore's own advice, 75 suspicious and 85 high risk. */
export function ipqsRisk(a: {
fraudScore: number;
spammer: boolean;
recentAbuse: boolean;
risky: boolean;
}): SpamRisk {
if (a.spammer || a.fraudScore >= 85) return 'high';
if (a.fraudScore >= 75 || a.recentAbuse || a.risky) return 'medium';
return 'low';
}
/** One phone lookup: `number` is 1NXXNXXXXXX. */
export async function ipqsLookup(key: string, number: string): Promise<IpqsAnswer> {
const j = await get(`phone/${encodeURIComponent(key)}/${number}?country[]=US`);
if (j.success !== true) {
if (used(j.message)) return { kind: 'exhausted' };
throw said(j.message, key);
}
const info: IpqsInfo = {
fraudScore: typeof j.fraud_score === 'number' ? j.fraud_score : 0,
spammer: j.spammer === true,
recentAbuse: j.recent_abuse === true,
lineType: str(j.line_type),
carrier: str(j.carrier),
name: str(j.name),
city: str(j.city),
region: str(j.region),
};
return { kind: 'ok', risk: ipqsRisk({ ...info, risky: j.risky === true }), info };
}
/** The Credit Usage API: credits left in this billing period (Test, and 14 §3). */
export async function ipqsCredits(key: string): Promise<number> {
const j = await get(`account/${encodeURIComponent(key)}`);
if (j.success === false) throw said(j.message, key);
const credits = Number(j.credits);
if (!Number.isFinite(credits)) throw new SpamError('the answer had no credit count');
return credits;
}