Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79bb1fa78b | ||
|
|
11b531d5b4 | ||
|
|
70083f8f83 | ||
|
|
6b0c5aa827 | ||
|
|
e05065599e | ||
|
|
49d529e185 | ||
|
|
70f14288ca | ||
|
|
6f87d6f663 | ||
|
|
a3dfcb6b14 |
No files matched your search
@@ -30,6 +30,7 @@ write the software around it: database, config generator, API, web UI.
|
||||
| `docs/spec/11-contacts.md` | Contacts: the builtin phonebook or Contrack, lookup, the Contrack timeline | Working on contacts or caller names |
|
||||
| `docs/spec/12-sso.md` | Single sign-on: OpenID Connect (Keycloak and others) and LDAP/LDAPS | Working on sign-in, users or roles |
|
||||
| `docs/spec/13-mobile.md` | The Android/iPhone app (`mobile` repo): pairing, device tokens, its tasks | Working on the mobile app or its API |
|
||||
| `docs/spec/14-spam.md` | Spam check: IPQualityScore and the FTC complaint list for unknown callers | Working on spam check or caller information |
|
||||
|
||||
## Hard rules
|
||||
|
||||
|
||||
@@ -5,6 +5,28 @@ tag (08 §3.1): `selfphoneng-api`, `selfphoneng-asterisk` and `selfphoneng-probe
|
||||
the Gitea registry. From v0.0.3 the release also has the probe as downloads for machines without
|
||||
Docker.
|
||||
|
||||
## v0.0.10 — 2026-10-27
|
||||
|
||||
**Before you update:** this version changes the database (spam check); the api backs up first,
|
||||
by itself. Portainer: change both tags to `0.0.10` and redeploy. Compose:
|
||||
`sudo scripts/update.sh v0.0.10`.
|
||||
|
||||
- **Spam check** (spec 14, off until you turn it on in Settings → Spam check): when an outside
|
||||
US caller isn't in the company's or your contacts, the number is looked up and what's known
|
||||
shows while it rings. The browser phone shows a **Likely spam** or **Possible spam** badge with
|
||||
a line such as "Fraud score 92 · spammer · VOIP"; desk phones show the caller's name as
|
||||
**SPAM LIKELY** or **Spam risk** (you can change these) when the answer is quick; call
|
||||
history keeps the badge. It's information only: calls are never blocked or delayed, and a
|
||||
failed lookup just shows nothing. Two sources, either or both:
|
||||
- **IPQualityScore** (1,000 lookups a month free): sends callers' numbers to IPQualityScore,
|
||||
so turning it on asks you to confirm. Answers are kept 14 days for flagged numbers and 3 for
|
||||
the rest. When the lookups run out the dashboard says so, checks fall back to the FTC list,
|
||||
and it carries on by itself when lookups are back.
|
||||
- **FTC complaint list** (free api.data.gov key): downloaded to the server, so nothing leaves
|
||||
it. The first download takes about 7 hours; then it fetches what's new every 24 hours.
|
||||
- See the [admin guide](docs/admin-guide/spam-check.md), including privacy and GDPR.
|
||||
- Database changes: `spam_lookups`, `ftc_complaints` (the api backs up first).
|
||||
|
||||
## v0.0.9 — 2026-10-27
|
||||
|
||||
**Before you update:** this version changes the database (paired phones); the api backs up first,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
A fully open source, self-hosted business phone system. Bring your own SIP trunk and numbers
|
||||
(reference provider: voip.ms). Built on Asterisk 24 + TypeScript + PostgreSQL 18, runs in Docker.
|
||||
|
||||
**Status:** pre-1.0 (v0.0.9), for testing. See [what's left for v1](docs/v1-checklist.md).
|
||||
**Status:** pre-1.0 (v0.0.10), for testing. See [what's left for v1](docs/v1-checklist.md).
|
||||
|
||||
## What it does today
|
||||
|
||||
@@ -19,6 +19,9 @@ A fully open source, self-hosted business phone system. Bring your own SIP trunk
|
||||
pickup, conference rooms, paging and intercom, music on hold.
|
||||
- **Voicemail** with email (and transcripts by local AI), **call history**, **recordings**, a **live dashboard** (listen,
|
||||
whisper, barge) and reports.
|
||||
- **Spam check** (off until you turn it on): outside callers who aren't contacts are looked up
|
||||
with IPQualityScore or the FTC complaint list, and flagged in the browser phone, on desk phones
|
||||
and in call history. Calls are never blocked.
|
||||
- **Running it:** updates (`scripts/update.sh` for compose installs; a backup before every
|
||||
database change), backups and restore, background health checks, webhooks and API tokens,
|
||||
two-step sign-in for admins, limits on outside calls at once.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@selfphoneng/api",
|
||||
"version": "0.0.9",
|
||||
"version": "0.0.10",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
CREATE TABLE "ftc_complaints" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"number" text NOT NULL,
|
||||
"reported_at" timestamp with time zone NOT NULL,
|
||||
"subject" text,
|
||||
"robocall" boolean,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "spam_lookups" (
|
||||
"number" text PRIMARY KEY NOT NULL,
|
||||
"provider" text NOT NULL,
|
||||
"risk" text NOT NULL,
|
||||
"info" jsonb NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "spam_lookups_risk_check" CHECK ("spam_lookups"."risk" in ('low', 'medium', 'high'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "calls" ADD COLUMN "spam_risk" text;--> statement-breakpoint
|
||||
ALTER TABLE "calls" ADD COLUMN "spam_info" jsonb;--> statement-breakpoint
|
||||
CREATE INDEX "ftc_complaints_number_index" ON "ftc_complaints" USING btree ("number");--> statement-breakpoint
|
||||
CREATE INDEX "ftc_complaints_reported_at_index" ON "ftc_complaints" USING btree ("reported_at");--> statement-breakpoint
|
||||
CREATE INDEX "spam_lookups_expires_at_index" ON "spam_lookups" USING btree ("expires_at");
|
||||
File diff suppressed because it is too large.
Load diff
@@ -99,6 +99,13 @@
|
||||
"when": 1793052019332,
|
||||
"tag": "0013_mobile_devices",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "7",
|
||||
"when": 1793127210386,
|
||||
"tag": "0014_spam_check",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -630,11 +630,46 @@ export const calls = pgTable(
|
||||
answeredByExtensionId: uuid().references(() => extensions.id, { onDelete: 'set null' }),
|
||||
talkSeconds: integer().notNull().default(0),
|
||||
recordingId: uuid().references(() => recordings.id, { onDelete: 'set null' }),
|
||||
/** The spam check of an incoming call's number (14 §6); null = not checked. */
|
||||
spamRisk: text(),
|
||||
spamInfo: jsonb(),
|
||||
...timestamps,
|
||||
},
|
||||
(t) => [index().on(t.startedAt.desc())],
|
||||
);
|
||||
|
||||
// ───────────── spam check (14 §7) ─────────────
|
||||
/** IPQualityScore answers, so a caller who rings again costs no credit (14 §4). */
|
||||
export const spamLookups = pgTable(
|
||||
'spam_lookups',
|
||||
{
|
||||
number: text().primaryKey(),
|
||||
provider: text().notNull(),
|
||||
risk: text().notNull(),
|
||||
info: jsonb().notNull(),
|
||||
expiresAt: tstz().notNull(),
|
||||
...timestamps,
|
||||
},
|
||||
(t) => [
|
||||
index().on(t.expiresAt),
|
||||
check('spam_lookups_risk_check', sql`${t.risk} in ('low', 'medium', 'high')`),
|
||||
],
|
||||
);
|
||||
|
||||
/** The local copy of the FTC's Do Not Call complaints (14 §2.2). */
|
||||
export const ftcComplaints = pgTable(
|
||||
'ftc_complaints',
|
||||
{
|
||||
id: text().primaryKey(),
|
||||
number: text().notNull(),
|
||||
reportedAt: tstz().notNull(),
|
||||
subject: text(),
|
||||
robocall: boolean(),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [index().on(t.number), index().on(t.reportedAt)],
|
||||
);
|
||||
|
||||
export const callExtensions = pgTable(
|
||||
'call_extensions',
|
||||
{
|
||||
|
||||
+19
-2
@@ -10,6 +10,7 @@ import type { Db } from './db/client.ts';
|
||||
import {
|
||||
extensions,
|
||||
sessions,
|
||||
spamLookups,
|
||||
systemEvents,
|
||||
trunks,
|
||||
users,
|
||||
@@ -30,6 +31,8 @@ import { logCallsToContrack } from './contacts/timeline.ts';
|
||||
import { checkDirectory } from './sso/directory.ts';
|
||||
import { checkOidcSessions } from './sso/oidcCheck.ts';
|
||||
import { logEvent } from './services/events.ts';
|
||||
import { spamQuotaCheck } from './spam/check.ts';
|
||||
import { ftcSync } from './spam/ftc.ts';
|
||||
import { deliverDue } from './services/webhooks.ts';
|
||||
import type { SendMail } from './services/mail.ts';
|
||||
import { getSetting, setSetting } from './services/settings.ts';
|
||||
@@ -42,7 +45,11 @@ export type JobDeps = {
|
||||
generator: Pick<Generator, 'apply'>;
|
||||
voicemail: { scan(ext: string): Promise<void> };
|
||||
send: SendMail;
|
||||
log: { info(o: object, m: string): void; error(o: object, m: string): void };
|
||||
log: {
|
||||
info(o: object, m: string): void;
|
||||
warn(o: object, m: string): void;
|
||||
error(o: object, m: string): void;
|
||||
};
|
||||
now?: () => Date;
|
||||
statfs?: typeof statfs;
|
||||
};
|
||||
@@ -70,7 +77,8 @@ export async function mailAdmins(d: JobDeps, subject: string, text: string) {
|
||||
await d.send({ to, subject, text }).catch((err: unknown) => d.log.error({ err }, subject));
|
||||
}
|
||||
|
||||
/** Recordings, voicemail, expired sessions and old webhook deliveries (04 §8 retentionCleanup). */
|
||||
/** Recordings, voicemail, expired sessions, old webhook deliveries and spam check answers
|
||||
* (04 §8 retentionCleanup, 14 §4). */
|
||||
export async function retentionCleanup(d: JobDeps) {
|
||||
const recordings = await cleanupRecordings(
|
||||
d.db,
|
||||
@@ -113,6 +121,7 @@ export async function retentionCleanup(d: JobDeps) {
|
||||
await d.db
|
||||
.delete(systemEvents)
|
||||
.where(lt(systemEvents.createdAt, sql`now() - interval '90 days'`));
|
||||
await d.db.delete(spamLookups).where(lt(spamLookups.expiresAt, sql`now()`));
|
||||
await cleanupContactShares(d.db);
|
||||
if (recordings || voicemail) d.log.info({ recordings, voicemail }, 'old messages deleted');
|
||||
}
|
||||
@@ -274,6 +283,12 @@ export function createJobs(d: JobDeps) {
|
||||
};
|
||||
|
||||
return {
|
||||
spamQuotaCheck: () => spamQuotaCheck(d.db),
|
||||
/** 14 §2.2: a failure is logged when it changes, and shown on Settings → Spam check. */
|
||||
spamFtcSync: async () => {
|
||||
const reason = await ftcSync(d.db, { now: now() });
|
||||
if (reason) d.log.warn({ provider: 'ftc', reason }, 'FTC complaint download failed');
|
||||
},
|
||||
oidcCheck,
|
||||
directoryCheck,
|
||||
daily,
|
||||
@@ -311,6 +326,8 @@ export function startJobs(d: JobDeps): () => void {
|
||||
run('contrackLog', j.contrackLog);
|
||||
if (minute % 5 === 0) run('oidcCheck', j.oidcCheck);
|
||||
if (minute % 60 === 0) run('directoryCheck', j.directoryCheck);
|
||||
if (minute % 360 === 0) run('spamQuotaCheck', j.spamQuotaCheck);
|
||||
run('spamFtcSync', j.spamFtcSync);
|
||||
run('callTranscripts', async () => {
|
||||
await transcribeCalls({ db: d.db, dir: resolve(d.config.RECORDINGS_DIR), log: d.log });
|
||||
});
|
||||
|
||||
@@ -10,10 +10,12 @@ import {
|
||||
type CallDto,
|
||||
type ListResponse,
|
||||
type MyCallDto,
|
||||
type SpamCheck,
|
||||
} from '@selfphoneng/shared';
|
||||
import { requireAdmin, requireAuth } from '../auth/guards.ts';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { callExtensions, calls, cdr, dids, extensions } from '../db/schema.ts';
|
||||
import { contactNames } from '../contacts/book.ts';
|
||||
import { badRequest, notFound } from '../errors.ts';
|
||||
import { getSetting } from '../services/settings.ts';
|
||||
|
||||
@@ -65,7 +67,8 @@ async function conditions(db: Db, f: Filter): Promise<SQL[]> {
|
||||
return where;
|
||||
}
|
||||
|
||||
async function page(db: Db, where: SQL[], limit: number) {
|
||||
/** `viewer`: whose books hide the spam check for a number they name (14 §6). */
|
||||
async function page(db: Db, where: SQL[], limit: number, viewer: string) {
|
||||
const rows = await db
|
||||
.select({ c: calls, did: dids, a: answerer })
|
||||
.from(calls)
|
||||
@@ -76,6 +79,12 @@ async function page(db: Db, where: SQL[], limit: number) {
|
||||
.limit(limit + 1);
|
||||
const more = rows.length > limit;
|
||||
const items = rows.slice(0, limit);
|
||||
const known = await contactNames(
|
||||
db,
|
||||
viewer,
|
||||
items.filter(({ c }) => c.spamRisk).map(({ c }) => c.fromNumber),
|
||||
{ wait: false },
|
||||
).catch(() => null);
|
||||
return {
|
||||
items: items.map(({ c, did, a }): CallDto => ({
|
||||
id: c.id,
|
||||
@@ -91,6 +100,10 @@ async function page(db: Db, where: SQL[], limit: number) {
|
||||
answeredBy: a ? { id: a.id, number: a.number, name: a.name } : null,
|
||||
talkSeconds: c.talkSeconds,
|
||||
recordingId: c.recordingId,
|
||||
spam:
|
||||
c.spamRisk && known && !known.has(c.fromNumber ?? '')
|
||||
? ({ risk: c.spamRisk, ...(c.spamInfo as object) } as SpamCheck)
|
||||
: null,
|
||||
})),
|
||||
nextCursor: more ? encodeCursor(items.at(-1)!.c) : null,
|
||||
};
|
||||
@@ -111,7 +124,7 @@ export async function callRoutes(app: FastifyInstance) {
|
||||
{ preHandler: requireAdmin },
|
||||
async (req): Promise<ListResponse<CallDto>> => {
|
||||
const f = CallFilterSchema.parse(req.query);
|
||||
return page(db, await conditions(db, f), f.limit);
|
||||
return page(db, await conditions(db, f), f.limit, req.user!.id);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -123,7 +136,7 @@ export async function callRoutes(app: FastifyInstance) {
|
||||
'started,direction,status,from,from_name,to,phone_number,answered_by,talk_seconds',
|
||||
];
|
||||
// ponytail: one query capped at 50,000 rows; stream with a cursor if exports get bigger.
|
||||
const { items } = await page(db, where, 50_000);
|
||||
const { items } = await page(db, where, 50_000, req.user!.id);
|
||||
const fmt = new Intl.DateTimeFormat('sv-SE', {
|
||||
timeZone: tz,
|
||||
dateStyle: 'short',
|
||||
@@ -156,7 +169,7 @@ export async function callRoutes(app: FastifyInstance) {
|
||||
|
||||
app.get('/api/calls/:id', { preHandler: requireAdmin }, async (req): Promise<CallDetailDto> => {
|
||||
const { id } = z.object({ id: z.uuid() }).parse(req.params);
|
||||
const { items } = await page(db, [eq(calls.id, id)], 1);
|
||||
const { items } = await page(db, [eq(calls.id, id)], 1, req.user!.id);
|
||||
const [c] = await db.select({ linkedid: calls.linkedid }).from(calls).where(eq(calls.id, id));
|
||||
if (!items[0] || !c) throw notFound('Call not found');
|
||||
const legs = await db
|
||||
@@ -189,7 +202,7 @@ export async function callRoutes(app: FastifyInstance) {
|
||||
.where(eq(extensions.userId, req.user!.id));
|
||||
if (!ext) throw notFound("You don't have an extension yet. Ask your admin.");
|
||||
const where = await conditions(db, { ...f, extensionId: ext.id });
|
||||
const result = await page(db, where, f.limit);
|
||||
const result = await page(db, where, f.limit, req.user!.id);
|
||||
const ids = result.items.map((c) => c.id);
|
||||
const roles = ids.length
|
||||
? await db
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
type ContactImportResult,
|
||||
type ContactShareDto,
|
||||
type ListResponse,
|
||||
type SpamCheck,
|
||||
} from '@selfphoneng/shared';
|
||||
import { NO_LIMIT, SLOW, requireAdmin, requireAuth } from '../auth/guards.ts';
|
||||
import {
|
||||
@@ -37,6 +38,7 @@ import { getSetting } from '../services/settings.ts';
|
||||
import { decrypt } from '../crypto/secrets.ts';
|
||||
import { contrackCheck } from '../contacts/contrack.ts';
|
||||
import { callerNameToken, phoneSafeName, sameToken } from '../contacts/callername.ts';
|
||||
import { checkCaller, usNumber } from '../spam/check.ts';
|
||||
|
||||
const IdParams = z.object({ id: z.uuid() });
|
||||
const CommitQuery = z.object({ commit: z.enum(['0', '1']).default('0') });
|
||||
@@ -187,12 +189,52 @@ export async function contactRoutes(app: FastifyInstance) {
|
||||
.select({ userId: extensions.userId })
|
||||
.from(extensions)
|
||||
.where(eq(extensions.number, q.data.ext));
|
||||
const names = await contactNames(db, e?.userId ?? null, [q.data.number], {
|
||||
const owner = e?.userId ?? null;
|
||||
const names = await contactNames(db, owner, [q.data.number], {
|
||||
wait: false,
|
||||
}).catch(() => new Map<string, string>());
|
||||
return reply
|
||||
.type('text/plain; charset=utf-8')
|
||||
.send(phoneSafeName(names.get(q.data.number) ?? ''));
|
||||
let name = names.get(q.data.number) ?? '';
|
||||
// 14 §5.2: no contact → the spam check's label, if it answers in 1.2 s (of the 2 s).
|
||||
// app.log: this route's own log is silent (the token is in the query).
|
||||
// ponytail: the label replaces the carrier's caller ID name, which the dialplan doesn't
|
||||
// send; IPQualityScore's name for the line follows it when it has one.
|
||||
if (!name) {
|
||||
const c = await checkCaller({ db, log: app.log }, owner, usNumber(q.data.number), 1200);
|
||||
if (c && c.risk !== 'low') {
|
||||
const labels = (await getSetting(db, 'spam')).deskPhoneLabels;
|
||||
name = `${labels[c.risk]} ${c.provider === 'ipqs' ? (c.info.name ?? '') : ''}`;
|
||||
}
|
||||
}
|
||||
return reply.type('text/plain; charset=utf-8').send(phoneSafeName(name));
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* The softphone's spam check (14 §5.1): only for a caller ringing one of my extensions right
|
||||
* now, so nobody can spend the credits on numbers that never called.
|
||||
*/
|
||||
app.get(
|
||||
'/api/me/caller-check',
|
||||
{ preHandler: requireAuth },
|
||||
async (req): Promise<{ check: SpamCheck | null }> => {
|
||||
const { number } = z.object({ number: z.string().max(30) }).parse(req.query);
|
||||
const n = usNumber(number);
|
||||
const mine = (
|
||||
await db
|
||||
.select({ number: extensions.number })
|
||||
.from(extensions)
|
||||
.where(eq(extensions.userId, req.user!.id))
|
||||
).map((x) => x.number);
|
||||
const ringing = app.live.calls
|
||||
.list()
|
||||
.some(
|
||||
(c) =>
|
||||
c.direction === 'inbound' &&
|
||||
usNumber(c.from.number) === n &&
|
||||
c.extensions.some((x) => mine.includes(x)),
|
||||
);
|
||||
if (!n || !ringing) return { check: null };
|
||||
return { check: await checkCaller({ db, log: req.log }, req.user!.id, n, 5000) };
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
OidcInputSchema,
|
||||
SettingsSchemas,
|
||||
SmtpInputSchema,
|
||||
SpamInputSchema,
|
||||
type AiTestResult,
|
||||
type AuthMode,
|
||||
type Destination,
|
||||
@@ -19,13 +20,16 @@ import {
|
||||
type OidcTestResult,
|
||||
type SettingKey,
|
||||
type SettingsDto,
|
||||
type SpamInput,
|
||||
type SpamSettings,
|
||||
type SpamTestResult,
|
||||
type Warning,
|
||||
} from '@selfphoneng/shared';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { dids, extensions, trunks } from '../db/schema.ts';
|
||||
import { HttpError, badRequest } from '../errors.ts';
|
||||
import { SLOW, requireAdmin } from '../auth/guards.ts';
|
||||
import { encrypt } from '../crypto/secrets.ts';
|
||||
import { decrypt, encrypt } from '../crypto/secrets.ts';
|
||||
import { audit } from '../services/audit.ts';
|
||||
import { assertDestinationExists } from '../services/destinations.ts';
|
||||
import { getAllSettings, getSetting, setSetting } from '../services/settings.ts';
|
||||
@@ -39,6 +43,10 @@ import { forgetBook } from '../contacts/contrack.ts';
|
||||
import { assertLdap, testLdap, type LdapSettings } from '../sso/ldap.ts';
|
||||
import { directoryUserFor } from '../sso/directory.ts';
|
||||
import { assertIssuer, oidcConfig, oidcError, testOidc, type OidcSettings } from '../sso/oidc.ts';
|
||||
import { ftcTest } from '../spam/ftc.ts';
|
||||
import { SpamError } from '../spam/http.ts';
|
||||
import { ipqsCredits } from '../spam/ipqs.ts';
|
||||
import { patchSpamState, resumeIpqs } from '../spam/check.ts';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
@@ -52,6 +60,7 @@ const READ_ONLY = new Set<string>([
|
||||
'system.restartRequired',
|
||||
'system.transportHash',
|
||||
'system.directoryWarning',
|
||||
'spam.state',
|
||||
'sms.webhookToken',
|
||||
'setup.completed',
|
||||
]);
|
||||
@@ -62,10 +71,20 @@ async function toDto(db: Db): Promise<SettingsDto> {
|
||||
'contacts.contrack': ctk,
|
||||
'auth.oidc': oidc,
|
||||
'auth.ldap': ldap,
|
||||
spam,
|
||||
...rest
|
||||
} = await getAllSettings(db);
|
||||
return {
|
||||
...rest,
|
||||
spam: {
|
||||
...spam,
|
||||
ipqs: spam.ipqs && { hasKey: true },
|
||||
ftc: spam.ftc && {
|
||||
hasKey: true,
|
||||
syncHours: spam.ftc.syncHours,
|
||||
windowDays: spam.ftc.windowDays,
|
||||
},
|
||||
},
|
||||
'auth.ldap': ldap && {
|
||||
enabled: ldap.enabled,
|
||||
preset: ldap.preset,
|
||||
@@ -209,6 +228,28 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
trustEmail: input.trustEmail,
|
||||
};
|
||||
}
|
||||
/** The stored form of `spam` (14 §7): keys are write-only (empty keeps the stored one). */
|
||||
async function spamFromInput(input: SpamInput): Promise<SpamSettings> {
|
||||
const v = SpamInputSchema.parse(input);
|
||||
const old = await getSetting(db, 'spam');
|
||||
const keyEnc = (typed: string | undefined, stored: string | undefined, name: string) => {
|
||||
if (typed) return encrypt(typed);
|
||||
if (stored) return stored;
|
||||
throw badRequest(`Enter the ${name} key`);
|
||||
};
|
||||
const ipqs = v.ipqs && {
|
||||
keyEnc: keyEnc(v.ipqs.key, old.ipqs?.keyEnc, 'IPQualityScore'),
|
||||
};
|
||||
const ftc = v.ftc && {
|
||||
keyEnc: keyEnc(v.ftc.key, old.ftc?.keyEnc, 'api.data.gov'),
|
||||
syncHours: v.ftc.syncHours,
|
||||
windowDays: v.ftc.windowDays,
|
||||
};
|
||||
if (v.enabled && !ipqs && !ftc)
|
||||
throw badRequest('Set up IPQualityScore or the FTC list to turn spam check on');
|
||||
return { ...v, ipqs, ftc };
|
||||
}
|
||||
|
||||
app.addHook('preHandler', requireAdmin);
|
||||
|
||||
app.get('/api/settings', () => toDto(db));
|
||||
@@ -258,6 +299,8 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
forgetBook(null);
|
||||
} else if (key === 'auth.ldap') {
|
||||
values[key] = await ldapFromInput(raw);
|
||||
} else if (key === 'spam') {
|
||||
values[key] = await spamFromInput(raw as SpamInput);
|
||||
} else if (key === 'auth.oidc') {
|
||||
values[key] = await oidcFromInput(raw === null ? null : OidcInputSchema.parse(raw));
|
||||
} else values[key] = SettingsSchemas[key as SettingKey].parse(raw);
|
||||
@@ -308,6 +351,8 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
}
|
||||
for (const [key, value] of Object.entries(values))
|
||||
await setSetting(db, key as SettingKey, value as never);
|
||||
// A new IPQualityScore key may have credits: don't wait for spamQuotaCheck (14 §3).
|
||||
if ((body.spam as SpamInput | undefined)?.ipqs?.key) await resumeIpqs(db, 'a new key');
|
||||
const changed = Object.keys(values).filter(
|
||||
(k) => JSON.stringify(before[k as keyof typeof before]) !== JSON.stringify(values[k]),
|
||||
);
|
||||
@@ -403,6 +448,32 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Settings → Spam check → Test (14 §7.1), with the typed key or else the saved one. IPQualityScore
|
||||
* answers with its credits left, which spends none.
|
||||
*/
|
||||
for (const provider of ['ipqs', 'ftc'] as const)
|
||||
app.post(
|
||||
`/api/settings/spam/${provider}/test`,
|
||||
{ config: SLOW },
|
||||
async (req): Promise<SpamTestResult> => {
|
||||
const { key } = z
|
||||
.object({ key: z.string().trim().max(200).optional() })
|
||||
.parse(req.body ?? {});
|
||||
const keyEnc = (await getSetting(db, 'spam'))[provider]?.keyEnc;
|
||||
const k = key || (keyEnc && decrypt(keyEnc));
|
||||
if (!k) return { ok: false, error: 'Enter the key first' };
|
||||
try {
|
||||
if (provider === 'ftc') return { ok: true, detail: await ftcTest(k) };
|
||||
const credits = await ipqsCredits(k);
|
||||
return { ok: true, detail: `Works: ${credits} credits left this billing period.` };
|
||||
} catch (err) {
|
||||
if (err instanceof SpamError) return { ok: false, error: err.message };
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/** The server's public IPv4, as an outside service sees it (only sent when asked). */
|
||||
app.post('/api/settings/network/detect-public-ip', { config: SLOW }, async () => {
|
||||
const ip = await detectPublicIp();
|
||||
@@ -488,6 +559,14 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
message: s['system.directoryWarning'],
|
||||
link: '/admin/settings/sign-in',
|
||||
});
|
||||
const st = s['spam.state'];
|
||||
if (s.spam.enabled && s.spam.ipqs && st.ipqsPausedAt && !st.warningDismissedAt)
|
||||
items.push({
|
||||
code: 'spam_quota',
|
||||
message: `IPQualityScore's lookups are used up. Spam checks ${s.spam.ftc ? 'use the FTC list' : 'are off'} until they come back.`,
|
||||
link: '/admin/settings/spam',
|
||||
dismissable: true,
|
||||
});
|
||||
if (!s.smtp)
|
||||
items.push({
|
||||
code: 'smtp_missing',
|
||||
@@ -496,4 +575,17 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
});
|
||||
return { items };
|
||||
});
|
||||
|
||||
/** Settings → Spam check → Download now (14 §2.2): due at the next minute, unless under way. */
|
||||
app.post('/api/settings/spam/ftc/sync', async () => {
|
||||
if (!(await getSetting(db, 'spam')).ftc) throw badRequest('Set up the FTC list first');
|
||||
await patchSpamState(db, { ftcLastSyncAt: null });
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
/** Hidden until the next time the credits run out (14 §3). */
|
||||
app.post('/api/system/warnings/spam_quota/dismiss', async () => {
|
||||
await patchSpamState(db, { warningDismissedAt: new Date().toISOString() });
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { join } from 'node:path';
|
||||
import { and, asc, eq, inArray, isNull, lt } from 'drizzle-orm';
|
||||
import { field, type AmiMessage } from '../ami/events.ts';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { checkNumber, usNumber, type SpamDeps } from '../spam/check.ts';
|
||||
import { callExtensions, calls, cdr, dids, extensions, recordings } from '../db/schema.ts';
|
||||
import { durationMs } from '../media/audio.ts';
|
||||
|
||||
@@ -160,7 +161,7 @@ async function linkRecording(db: Db | Tx, dir: string, s: CallSummary, linkedid:
|
||||
export async function aggregateCall(
|
||||
db: Db,
|
||||
linkedid: string,
|
||||
opts: { recordingsDir?: string } = {},
|
||||
opts: { recordingsDir?: string; log?: SpamDeps['log'] } = {},
|
||||
): Promise<void> {
|
||||
const rows = await db
|
||||
.select()
|
||||
@@ -180,6 +181,13 @@ export async function aggregateCall(
|
||||
const [d] = s.did
|
||||
? await db.select({ id: dids.id }).from(dids).where(eq(dids.number, s.did))
|
||||
: [];
|
||||
// 14 §6: what the spam check knows now (the cache, the local FTC copy), kept with the call.
|
||||
const spam =
|
||||
s.direction === 'inbound' && s.fromNumber
|
||||
? await checkNumber({ db, log: opts.log ?? { warn() {} } }, usNumber(s.fromNumber), {
|
||||
cachedOnly: true,
|
||||
})
|
||||
: null;
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.delete(calls).where(eq(calls.linkedid, linkedid));
|
||||
const [call] = await tx
|
||||
@@ -197,6 +205,8 @@ export async function aggregateCall(
|
||||
didId: d?.id ?? null,
|
||||
answeredByExtensionId: idOf(s.answeredBy),
|
||||
talkSeconds: s.talkSeconds,
|
||||
spamRisk: spam?.risk ?? null,
|
||||
spamInfo: spam && { provider: spam.provider, info: spam.info },
|
||||
recordingId: opts.recordingsDir
|
||||
? await linkRecording(tx, opts.recordingsDir, s, linkedid)
|
||||
: null,
|
||||
@@ -218,7 +228,7 @@ export async function aggregateCall(
|
||||
|
||||
export type CdrDeps = {
|
||||
db: Db;
|
||||
log: { error(o: object, msg: string): void };
|
||||
log: { error(o: object, msg: string): void; warn(o: object, msg: string): void };
|
||||
/** How long after a call's last Cdr event to aggregate it (04 §5.4: 5 s). */
|
||||
settleMs?: number;
|
||||
/** Where MixMonitor writes (RECORDINGS_DIR); recordings are linked to their calls. */
|
||||
@@ -233,7 +243,7 @@ export function watchCdr(d: CdrDeps, ami: Pick<EventEmitter, 'on'>) {
|
||||
linkedid,
|
||||
setTimeout(() => {
|
||||
timers.delete(linkedid);
|
||||
void aggregateCall(d.db, linkedid, { recordingsDir: d.recordingsDir }).catch(
|
||||
void aggregateCall(d.db, linkedid, { recordingsDir: d.recordingsDir, log: d.log }).catch(
|
||||
(err: unknown) => d.log.error({ err, linkedid }, 'call aggregation failed'),
|
||||
);
|
||||
}, d.settleMs ?? 5000),
|
||||
@@ -257,7 +267,7 @@ export function watchCdr(d: CdrDeps, ami: Pick<EventEmitter, 'on'>) {
|
||||
.leftJoin(calls, eq(calls.linkedid, cdr.linkedid))
|
||||
.where(and(isNull(calls.id), lt(cdr.createdAt, new Date(Date.now() - 60_000))));
|
||||
for (const o of orphans)
|
||||
await aggregateCall(d.db, o.linkedid, { recordingsDir: d.recordingsDir });
|
||||
await aggregateCall(d.db, o.linkedid, { recordingsDir: d.recordingsDir, log: d.log });
|
||||
},
|
||||
stop: () => timers.forEach(clearTimeout),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// The spam check (14 §5): the one way to ask what's known about a caller's number. Information
|
||||
// only: nothing here changes a call, and a failure only means there's no answer (and one warning
|
||||
// in the log). The contacts test (14 §1) is the caller's: it knows whose books to look in.
|
||||
import { and, eq, gt, isNull, sql, type SQL } from 'drizzle-orm';
|
||||
import {
|
||||
SpamStateSchema,
|
||||
normalizeNumber,
|
||||
type IpqsInfo,
|
||||
type SpamCheck,
|
||||
type SpamRisk,
|
||||
type SpamSettings,
|
||||
type SpamState,
|
||||
} from '@selfphoneng/shared';
|
||||
import { contactNames } from '../contacts/book.ts';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { settings, spamLookups } from '../db/schema.ts';
|
||||
import { decrypt } from '../crypto/secrets.ts';
|
||||
import { logEvent } from '../services/events.ts';
|
||||
import { getSetting } from '../services/settings.ts';
|
||||
import { ftcLookup } from './ftc.ts';
|
||||
import { SpamError } from './http.ts';
|
||||
import { ipqsCredits, ipqsLookup } from './ipqs.ts';
|
||||
|
||||
export type SpamDeps = { db: Db; log: { warn(o: object, msg: string): void } };
|
||||
|
||||
/** US numbers only (owner, 2026-10-27): 1NXXNXXXXXX after normalizeNumber (00 §8). */
|
||||
export const isCheckable = (n: string) => /^1[2-9]\d{2}[2-9]\d{6}$/.test(n);
|
||||
|
||||
/** `n` as 1NXXNXXXXXX-style digits (00 §8), or '' when it isn't a number. */
|
||||
export const usNumber = (n: string) => {
|
||||
try {
|
||||
return normalizeNumber(n);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
/** A check under way, by number: a ring group ringing five people costs one credit (14 §4). */
|
||||
const pending = new Map<string, Promise<SpamCheck | null>>();
|
||||
|
||||
function warn(d: SpamDeps, provider: string, err: unknown) {
|
||||
if (err instanceof SpamError) d.log.warn({ provider, reason: err.message }, 'spam check failed');
|
||||
else d.log.warn({ provider, err }, 'spam check failed');
|
||||
}
|
||||
|
||||
/**
|
||||
* IPQualityScore first when it's set up, then the FTC list (owner, 2026-10-27). `cachedOnly`:
|
||||
* the cache and the local FTC copy, never a request out (call history, 14 §6).
|
||||
*/
|
||||
export function checkNumber(
|
||||
d: SpamDeps,
|
||||
number: string,
|
||||
opts: { cachedOnly?: boolean } = {},
|
||||
): Promise<SpamCheck | null> {
|
||||
if (opts.cachedOnly) return check(d, number, true);
|
||||
// Shared from the start, before anything is awaited: else a check that finishes between
|
||||
// another's cache read and its look here would cost a second request.
|
||||
let p = pending.get(number);
|
||||
if (!p) {
|
||||
p = check(d, number, false).finally(() => pending.delete(number));
|
||||
pending.set(number, p);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
async function check(d: SpamDeps, number: string, cachedOnly: boolean): Promise<SpamCheck | null> {
|
||||
if (!isCheckable(number)) return null;
|
||||
const s = await getSetting(d.db, 'spam');
|
||||
if (!s.enabled) return null;
|
||||
if (s.ipqs) {
|
||||
const [hit] = await d.db
|
||||
.select()
|
||||
.from(spamLookups)
|
||||
.where(and(eq(spamLookups.number, number), gt(spamLookups.expiresAt, sql`now()`)));
|
||||
if (hit) return { risk: hit.risk as SpamRisk, provider: 'ipqs', info: hit.info as IpqsInfo };
|
||||
// Paused (14 §3): the cache still answers, IPQualityScore isn't asked.
|
||||
if (!cachedOnly && !(await getSetting(d.db, 'spam.state')).ipqsPausedAt) {
|
||||
const answer = await askIpqs(d, s, s.ipqs.keyEnc, number);
|
||||
if (answer) return answer;
|
||||
}
|
||||
}
|
||||
if (!s.ftc) return null;
|
||||
try {
|
||||
return { provider: 'ftc', ...(await ftcLookup(d.db, number, s.ftc.windowDays)) };
|
||||
} catch (err) {
|
||||
warn(d, 'ftc', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The check for a call ringing `userId` (14 §5): nothing for a number in their books (or when
|
||||
* the books can't be read), else the answer if it comes within `waitMs`. A later answer still
|
||||
* fills the cache.
|
||||
*/
|
||||
export async function checkCaller(
|
||||
d: SpamDeps,
|
||||
userId: string | null,
|
||||
number: string,
|
||||
waitMs: number,
|
||||
): Promise<SpamCheck | null> {
|
||||
if (!isCheckable(number)) return null;
|
||||
const known = await contactNames(d.db, userId, [number], { wait: false }).catch(() => null);
|
||||
if (!known || known.size) return null;
|
||||
const check = checkNumber(d, number).catch((err: unknown) => {
|
||||
warn(d, 'check', err);
|
||||
return null;
|
||||
});
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
const late = new Promise<null>((r) => (timer = setTimeout(r, waitMs, null)));
|
||||
try {
|
||||
return await Promise.race([check, late]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function askIpqs(
|
||||
d: SpamDeps,
|
||||
s: SpamSettings,
|
||||
keyEnc: string,
|
||||
number: string,
|
||||
): Promise<SpamCheck | null> {
|
||||
try {
|
||||
const a = await ipqsLookup(decrypt(keyEnc), number);
|
||||
if (a.kind === 'exhausted') {
|
||||
await pauseIpqs(d, s);
|
||||
return null;
|
||||
}
|
||||
const days = a.risk === 'low' ? s.cache.cleanDays : s.cache.flaggedDays;
|
||||
const expiresAt = sql`now() + make_interval(days => ${days})`;
|
||||
await d.db
|
||||
.insert(spamLookups)
|
||||
.values({ number, provider: 'ipqs', risk: a.risk, info: a.info, expiresAt })
|
||||
.onConflictDoUpdate({
|
||||
target: spamLookups.number,
|
||||
set: { risk: a.risk, info: a.info, expiresAt, updatedAt: new Date() },
|
||||
});
|
||||
return { risk: a.risk, provider: 'ipqs', info: a.info };
|
||||
} catch (err) {
|
||||
warn(d, 'ipqs', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges `patch` into spam.state in one statement, so the jobs and checks don't overwrite each
|
||||
* other's fields. `onlyIf`: the condition the stored state must meet; false when it didn't.
|
||||
*/
|
||||
export async function patchSpamState(
|
||||
db: Db,
|
||||
patch: Partial<SpamState>,
|
||||
onlyIf?: SQL,
|
||||
): Promise<boolean> {
|
||||
await db
|
||||
.insert(settings)
|
||||
.values({ key: 'spam.state', value: SpamStateSchema.parse({}) })
|
||||
.onConflictDoNothing();
|
||||
const rows = await db
|
||||
.update(settings)
|
||||
.set({
|
||||
value: sql`${settings.value} || ${JSON.stringify(patch)}::jsonb`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(settings.key, 'spam.state'), onlyIf))
|
||||
.returning({ key: settings.key });
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
const paused = sql`${settings.value}->>'ipqsPausedAt'`;
|
||||
|
||||
/** Out of credits (14 §3): paused, and the event, warning and log line once per time. */
|
||||
async function pauseIpqs(d: SpamDeps, s: SpamSettings) {
|
||||
const first = await patchSpamState(
|
||||
d.db,
|
||||
{ ipqsPausedAt: new Date().toISOString(), warningDismissedAt: null },
|
||||
isNull(paused),
|
||||
);
|
||||
if (!first) return;
|
||||
d.log.warn({ provider: 'ipqs' }, 'spam check: IPQualityScore credits are used up, paused');
|
||||
await logEvent(d.db, {
|
||||
severity: 'warning',
|
||||
code: 'spam.quotaExhausted',
|
||||
source: 'Spam check',
|
||||
message: `IPQualityScore's lookups are used up. ${s.ftc ? 'Spam checks use the FTC list' : 'Spam checks are off'} until they come back.`,
|
||||
});
|
||||
}
|
||||
|
||||
/** Unpaused, by the job or a new key (14 §3). */
|
||||
export async function resumeIpqs(db: Db, why: string) {
|
||||
const was = await patchSpamState(db, { ipqsPausedAt: null }, sql`${paused} is not null`);
|
||||
if (was)
|
||||
await logEvent(db, {
|
||||
severity: 'info',
|
||||
code: 'spam.quotaRestored',
|
||||
source: 'Spam check',
|
||||
message: `IPQualityScore lookups are back (${why}).`,
|
||||
});
|
||||
}
|
||||
|
||||
/** Job spamQuotaCheck (14 §3), every 6 hours: while paused, are credits back? A failure throws
|
||||
* (the job's log line) and the next run tries again. */
|
||||
export async function spamQuotaCheck(db: Db) {
|
||||
const s = await getSetting(db, 'spam');
|
||||
if (!s.ipqs || !(await getSetting(db, 'spam.state')).ipqsPausedAt) return;
|
||||
const credits = await ipqsCredits(decrypt(s.ipqs.keyEnc));
|
||||
if (credits > 0) await resumeIpqs(db, `${credits} credits left`);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// The FTC's Do Not Call complaints (14 §2.2): a check reads the local copy, so it sends nothing
|
||||
// out. spamFtcSync keeps the copy.
|
||||
import { lt, sql } from 'drizzle-orm';
|
||||
import type { FtcInfo, SpamRisk } from '@selfphoneng/shared';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { ftcComplaints } from '../db/schema.ts';
|
||||
import { decrypt } from '../crypto/secrets.ts';
|
||||
import { getSetting } from '../services/settings.ts';
|
||||
import { todayIn } from '../generator/load.ts';
|
||||
import { isCheckable, patchSpamState } from './check.ts';
|
||||
import { SpamError, getJson } from './http.ts';
|
||||
|
||||
export const FTC_API = 'https://api.ftc.gov/v0/dnc-complaints';
|
||||
|
||||
/** Risk (owner, 2026-10-27): 3 or more complaints in the window high, 1–2 medium. */
|
||||
export const ftcRisk = (complaints: number): SpamRisk =>
|
||||
complaints >= 3 ? 'high' : complaints >= 1 ? 'medium' : 'low';
|
||||
|
||||
/** The complaints about `number` (1NXXNXXXXXX) in the last `windowDays`. */
|
||||
export async function ftcLookup(
|
||||
db: Db,
|
||||
number: string,
|
||||
windowDays: number,
|
||||
): Promise<{ risk: SpamRisk; info: FtcInfo }> {
|
||||
const r = await db.execute<{
|
||||
complaints: number;
|
||||
robocalls: number;
|
||||
last: Date | string | null;
|
||||
subject: string | null;
|
||||
}>(sql`
|
||||
select count(*)::int as complaints,
|
||||
(count(*) filter (where robocall))::int as robocalls,
|
||||
max(reported_at) as last,
|
||||
mode() within group (order by subject) as subject
|
||||
from ftc_complaints
|
||||
where number = ${number}
|
||||
and reported_at > now() - make_interval(days => ${windowDays})`);
|
||||
const row = r.rows[0]!;
|
||||
const info: FtcInfo = {
|
||||
complaints: row.complaints,
|
||||
robocalls: row.robocalls,
|
||||
lastReportedAt: row.last === null ? null : new Date(row.last).toISOString(),
|
||||
subject: row.subject,
|
||||
};
|
||||
return { risk: ftcRisk(info.complaints), info };
|
||||
}
|
||||
|
||||
const OVER_LIMIT = 'api.data.gov says the key is over its hourly limit';
|
||||
|
||||
async function ftcGet(key: string, q: Record<string, string>) {
|
||||
try {
|
||||
return await getJson(`${FTC_API}?${new URLSearchParams(q)}`, { 'X-Api-Key': key });
|
||||
} catch (err) {
|
||||
const m = (err as Error).message;
|
||||
if (m === 'HTTP 403') throw new SpamError('api.data.gov refused the key');
|
||||
if (m === 'HTTP 429') throw new SpamError(OVER_LIMIT);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Settings → Spam check → Test: one request with the api.data.gov key. */
|
||||
export async function ftcTest(key: string): Promise<string> {
|
||||
await ftcGet(key, { items_per_page: '1' });
|
||||
return 'The FTC answered.';
|
||||
}
|
||||
|
||||
// ponytail: the FTC's dates have no zone; taken as Eastern (it's in Washington). Hours off at
|
||||
// worst, against a window of weeks.
|
||||
const FTC_TZ = 'America/New_York';
|
||||
const PAGE = 50;
|
||||
const DATE_TIME = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/;
|
||||
const nextDay = (day: string) =>
|
||||
new Date(Date.parse(`${day}T00:00:00Z`) + 86_400_000).toISOString().slice(0, 10);
|
||||
const daysBefore = (day: string, n: number) =>
|
||||
new Date(Date.parse(`${day}T00:00:00Z`) - n * 86_400_000).toISOString().slice(0, 10);
|
||||
|
||||
/** One complaint as stored, or null for one without a usable US number or date. */
|
||||
function complaint(c: unknown) {
|
||||
const { id, attributes: a } = (c ?? {}) as { id?: unknown; attributes?: Record<string, unknown> };
|
||||
const phone = `1${String(a?.['company-phone-number'] ?? '')}`;
|
||||
const at = a?.['created-date'];
|
||||
if (
|
||||
typeof id !== 'string' ||
|
||||
!isCheckable(phone) ||
|
||||
typeof at !== 'string' ||
|
||||
!DATE_TIME.test(at)
|
||||
)
|
||||
return null;
|
||||
const robo = a?.['recorded-message-or-robocall'];
|
||||
return {
|
||||
id,
|
||||
number: phone,
|
||||
reportedAt: sql`${at}::timestamp at time zone ${FTC_TZ}`,
|
||||
subject: typeof a?.subject === 'string' && a.subject ? a.subject : null,
|
||||
robocall: robo === 'Y' ? true : robo === 'N' ? false : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Job spamFtcSync (14 §2.2), every minute: when a sync is due or under way, read up to `budget`
|
||||
* pages (15 a minute is 900 an hour, under api.data.gov's 1,000) a day at a time, oldest first,
|
||||
* saving where it got to. A failure (a 429 among them) is saved for the Settings tab and the next
|
||||
* minute carries on from the same page. Returns the error, for the job's log line once.
|
||||
*/
|
||||
export async function ftcSync(
|
||||
db: Db,
|
||||
opts: { now?: Date; budget?: number } = {},
|
||||
): Promise<string | null> {
|
||||
const now = opts.now ?? new Date();
|
||||
const s = await getSetting(db, 'spam');
|
||||
if (!s.enabled || !s.ftc) return null;
|
||||
const st = await getSetting(db, 'spam.state');
|
||||
let cursor = st.ftcCursor;
|
||||
if (!cursor) {
|
||||
const due =
|
||||
!st.ftcLastSyncAt ||
|
||||
now.getTime() - Date.parse(st.ftcLastSyncAt) >= s.ftc.syncHours * 3_600_000;
|
||||
if (!due) return null;
|
||||
// From the newest complaint kept, less a day for ones posted late; at most the window.
|
||||
const r = await db.execute<{ day: string | null }>(
|
||||
sql`select to_char(max(reported_at) at time zone ${FTC_TZ}, 'YYYY-MM-DD') as day from ftc_complaints`,
|
||||
);
|
||||
const first = daysBefore(todayIn(FTC_TZ, now), s.ftc.windowDays);
|
||||
const last = r.rows[0]?.day ? daysBefore(r.rows[0].day, 1) : first;
|
||||
cursor = { day: last > first ? last : first, offset: 0 };
|
||||
}
|
||||
const key = decrypt(s.ftc.keyEnc);
|
||||
const today = todayIn(FTC_TZ, now);
|
||||
for (let n = 0; n < (opts.budget ?? 15); n++) {
|
||||
let page: unknown[];
|
||||
try {
|
||||
const j = await ftcGet(key, {
|
||||
created_date_from: `"${cursor.day} 00:00:00"`,
|
||||
created_date_to: `"${cursor.day} 23:59:59"`,
|
||||
sort_order: 'ASC',
|
||||
items_per_page: String(PAGE),
|
||||
offset: String(cursor.offset),
|
||||
});
|
||||
if (!Array.isArray(j.data)) throw new SpamError('the answer had no complaints list');
|
||||
page = j.data;
|
||||
} catch (err) {
|
||||
const error = err instanceof SpamError ? err.message : 'the sync failed';
|
||||
await patchSpamState(db, { ftcCursor: cursor, ftcLastError: error });
|
||||
if (!(err instanceof SpamError)) throw err;
|
||||
return error === st.ftcLastError ? null : error;
|
||||
}
|
||||
const rows = page.map(complaint).filter((c) => c !== null);
|
||||
if (rows.length) await db.insert(ftcComplaints).values(rows).onConflictDoNothing();
|
||||
if (page.length === PAGE) cursor = { day: cursor.day, offset: cursor.offset + PAGE };
|
||||
else if (cursor.day < today) cursor = { day: nextDay(cursor.day), offset: 0 };
|
||||
else {
|
||||
await db
|
||||
.delete(ftcComplaints)
|
||||
.where(
|
||||
lt(
|
||||
ftcComplaints.reportedAt,
|
||||
sql`${now.toISOString()}::timestamptz - make_interval(days => ${s.ftc.windowDays})`,
|
||||
),
|
||||
);
|
||||
await patchSpamState(db, {
|
||||
ftcCursor: null,
|
||||
ftcLastSyncAt: now.toISOString(),
|
||||
ftcLastError: null,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
await patchSpamState(db, { ftcCursor: cursor, ftcLastError: null });
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// The spam check's providers over HTTPS (14 §2). Errors carry only these texts: never the URL
|
||||
// or fetch's own error, which can quote it (IPQualityScore's key is in the path).
|
||||
|
||||
/** A failure whose message is safe to log and show. */
|
||||
export class SpamError extends Error {}
|
||||
|
||||
export async function getJson(
|
||||
url: string,
|
||||
headers: Record<string, string> = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
let r: Response;
|
||||
try {
|
||||
r = await fetch(url, { headers, signal: AbortSignal.timeout(4000) });
|
||||
} catch (err) {
|
||||
throw new SpamError(
|
||||
(err as Error).name === 'TimeoutError' ? 'no answer in 4 s' : "couldn't connect",
|
||||
);
|
||||
}
|
||||
if (!r.ok) throw new SpamError(`HTTP ${r.status}`);
|
||||
try {
|
||||
return (await r.json()) as Record<string, unknown>;
|
||||
} catch {
|
||||
throw new SpamError('the answer was not JSON');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// SD3: the FTC complaint download (14 §2.2) against a fake api.ftc.gov with three days of data.
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { SPAM_DEFAULTS } from '@selfphoneng/shared';
|
||||
import { encrypt, setSecretKey } from '../src/crypto/secrets.ts';
|
||||
import type { Db } from '../src/db/client.ts';
|
||||
import { ftcComplaints, settings } from '../src/db/schema.ts';
|
||||
import { ftcSync } from '../src/spam/ftc.ts';
|
||||
import { getSetting, setSetting } from '../src/services/settings.ts';
|
||||
import { createTestDb, hasTestDb } from './db.ts';
|
||||
|
||||
const KEY = 'datagov-secret-key-456';
|
||||
const NOW = new Date('2026-10-27T18:00:00Z'); // 14:00 in Washington
|
||||
const hour = 3_600_000;
|
||||
|
||||
const at = (day: string, i: number) =>
|
||||
`${day} ${String(Math.floor(i / 60)).padStart(2, '0')}:${String(i % 60).padStart(2, '0')}:00`;
|
||||
const ftc = (id: string, phone: string, created: string, robo = 'Y') => ({
|
||||
type: 'dnc_complaint',
|
||||
id,
|
||||
attributes: {
|
||||
'company-phone-number': phone,
|
||||
'created-date': created,
|
||||
subject: 'Warranties',
|
||||
'recorded-message-or-robocall': robo,
|
||||
},
|
||||
});
|
||||
/** 120 complaints on the 25th (three pages), 3 on the 26th, 3 today (one usable). */
|
||||
const DATA = [
|
||||
...Array.from({ length: 120 }, (_, i) => ftc(`a${i}`, '3125550100', at('2026-10-25', i))),
|
||||
...[0, 1, 2].map((i) => ftc(`b${i}`, '3125550101', at('2026-10-26', i), 'N')),
|
||||
ftc('c0', '3125550102', '2026-10-27 09:00:00', ''),
|
||||
ftc('c1', '', '2026-10-27 09:01:00'), // no number
|
||||
ftc('c2', '3125550102', '2026-10-27'), // no time
|
||||
];
|
||||
|
||||
describe.skipIf(!hasTestDb)('FTC complaint download', () => {
|
||||
let db: Db;
|
||||
let drop: () => Promise<void>;
|
||||
/** Each request's day and offset. */
|
||||
const asked: string[] = [];
|
||||
let fail: { at: number; status: number } | null = null;
|
||||
|
||||
const turnOn = (keyEnc = encrypt(KEY)) =>
|
||||
setSetting(db, 'spam', {
|
||||
...SPAM_DEFAULTS,
|
||||
enabled: true,
|
||||
ftc: { keyEnc, syncHours: 24, windowDays: 7 },
|
||||
});
|
||||
const stored = async () => (await db.select().from(ftcComplaints)).length;
|
||||
const reset = async () => {
|
||||
await db.delete(ftcComplaints);
|
||||
await db.delete(settings).where(eq(settings.key, 'spam.state'));
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, drop } = await createTestDb());
|
||||
setSecretKey(randomBytes(32).toString('base64'));
|
||||
});
|
||||
afterAll(() => drop?.());
|
||||
|
||||
beforeEach(() => {
|
||||
asked.length = 0;
|
||||
fail = null;
|
||||
vi.stubGlobal('fetch', async (url: string, init?: RequestInit) => {
|
||||
const json = (j: unknown, status = 200) => new Response(JSON.stringify(j), { status });
|
||||
if ((init?.headers as Record<string, string>)['X-Api-Key'] !== KEY) return json({}, 403);
|
||||
const q = new URL(url).searchParams;
|
||||
const day = /^"(\d{4}-\d{2}-\d{2}) 00:00:00"$/.exec(q.get('created_date_from')!)![1]!;
|
||||
expect(q.get('created_date_to')).toBe(`"${day} 23:59:59"`);
|
||||
expect(q.get('sort_order')).toBe('ASC');
|
||||
const offset = Number(q.get('offset'));
|
||||
asked.push(`${day}+${offset}`);
|
||||
if (fail && asked.length === fail.at) return json({}, fail.status);
|
||||
const all = DATA.filter((c) => c.attributes['created-date'].startsWith(day));
|
||||
return json({ data: all.slice(offset, offset + 50), meta: { 'record-total': all.length } });
|
||||
});
|
||||
});
|
||||
afterEach(async () => {
|
||||
vi.unstubAllGlobals();
|
||||
await reset();
|
||||
});
|
||||
|
||||
it('downloads nothing while spam check or the FTC list is off', async () => {
|
||||
await setSetting(db, 'spam', SPAM_DEFAULTS);
|
||||
expect(await ftcSync(db, { now: NOW })).toBeNull();
|
||||
await setSetting(db, 'spam', { ...SPAM_DEFAULTS, enabled: true });
|
||||
expect(await ftcSync(db, { now: NOW })).toBeNull();
|
||||
expect(asked).toEqual([]);
|
||||
});
|
||||
|
||||
it('the first download fills the window a day at a time and drops old complaints', async () => {
|
||||
await turnOn();
|
||||
await db.insert(ftcComplaints).values({
|
||||
id: 'old',
|
||||
number: '13125550109',
|
||||
reportedAt: sql`${NOW.toISOString()}::timestamptz - interval '8 days'`,
|
||||
});
|
||||
expect(await ftcSync(db, { now: NOW, budget: 100 })).toBeNull();
|
||||
expect(asked).toEqual([
|
||||
'2026-10-20+0',
|
||||
'2026-10-21+0',
|
||||
'2026-10-22+0',
|
||||
'2026-10-23+0',
|
||||
'2026-10-24+0',
|
||||
'2026-10-25+0',
|
||||
'2026-10-25+50',
|
||||
'2026-10-25+100',
|
||||
'2026-10-26+0',
|
||||
'2026-10-27+0',
|
||||
]);
|
||||
expect(await stored()).toBe(124);
|
||||
const row = async (id: string) =>
|
||||
(await db.select().from(ftcComplaints).where(eq(ftcComplaints.id, id)))[0];
|
||||
expect(await row('b0')).toMatchObject({
|
||||
number: '13125550101',
|
||||
subject: 'Warranties',
|
||||
robocall: false,
|
||||
reportedAt: new Date('2026-10-26T04:00:00Z'), // midnight in Washington
|
||||
});
|
||||
expect((await row('c0'))?.robocall).toBeNull();
|
||||
expect(await getSetting(db, 'spam.state')).toMatchObject({
|
||||
ftcCursor: null,
|
||||
ftcLastSyncAt: NOW.toISOString(),
|
||||
ftcLastError: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('not again until syncHours; then from the newest day kept, less one', async () => {
|
||||
await turnOn();
|
||||
await ftcSync(db, { now: NOW, budget: 100 });
|
||||
asked.length = 0;
|
||||
await ftcSync(db, { now: new Date(NOW.getTime() + 23 * hour) });
|
||||
expect(asked).toEqual([]);
|
||||
await ftcSync(db, { now: new Date(NOW.getTime() + 24 * hour) });
|
||||
expect(asked).toEqual(['2026-10-26+0', '2026-10-27+0', '2026-10-28+0']);
|
||||
});
|
||||
|
||||
it('15 pages a minute, the place saved; a 429 stops there and the next minute carries on', async () => {
|
||||
await turnOn();
|
||||
await ftcSync(db, { now: NOW });
|
||||
expect(asked).toHaveLength(10);
|
||||
await reset();
|
||||
asked.length = 0;
|
||||
|
||||
await ftcSync(db, { now: NOW, budget: 2 });
|
||||
expect((await getSetting(db, 'spam.state')).ftcCursor).toEqual({
|
||||
day: '2026-10-22',
|
||||
offset: 0,
|
||||
});
|
||||
fail = { at: 7, status: 429 }; // the 25th's second page
|
||||
const said = 'api.data.gov says the key is over its hourly limit';
|
||||
expect(await ftcSync(db, { now: NOW })).toBe(said);
|
||||
expect(await getSetting(db, 'spam.state')).toMatchObject({
|
||||
ftcCursor: { day: '2026-10-25', offset: 50 },
|
||||
ftcLastError: said,
|
||||
ftcLastSyncAt: null,
|
||||
});
|
||||
fail = { at: 8, status: 429 }; // the same failure isn't returned (logged) twice
|
||||
expect(await ftcSync(db, { now: NOW })).toBeNull();
|
||||
fail = null;
|
||||
expect(await ftcSync(db, { now: NOW })).toBeNull();
|
||||
expect(await stored()).toBe(124);
|
||||
expect(await getSetting(db, 'spam.state')).toMatchObject({
|
||||
ftcCursor: null,
|
||||
ftcLastError: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('a refused key is saved for the Settings tab', async () => {
|
||||
await turnOn(encrypt('wrong'));
|
||||
expect(await ftcSync(db, { now: NOW })).toBe('api.data.gov refused the key');
|
||||
expect((await getSetting(db, 'spam.state')).ftcLastError).toBe('api.data.gov refused the key');
|
||||
});
|
||||
});
|
||||
@@ -94,6 +94,8 @@ const EXPECTED: Record<string, Record<string, string>> = {
|
||||
answered_by_extension_id: 'uuid|YES|',
|
||||
talk_seconds: 'integer|NO|0',
|
||||
recording_id: 'uuid|YES|',
|
||||
spam_risk: 'text|YES|',
|
||||
spam_info: 'jsonb|YES|',
|
||||
...ts,
|
||||
},
|
||||
audit_log: {
|
||||
@@ -115,11 +117,11 @@ describe.skipIf(!hasTestDb)('schema matches spec 02', () => {
|
||||
beforeAll(async () => ({ db, drop } = await createTestDb()));
|
||||
afterAll(() => drop?.());
|
||||
|
||||
it('creates all 45 tables', async () => {
|
||||
it('creates all 47 tables', async () => {
|
||||
const r = await db.execute(
|
||||
sql`select count(*)::int as n from information_schema.tables where table_schema = 'public'`,
|
||||
);
|
||||
expect(r.rows[0]).toEqual({ n: 45 });
|
||||
expect(r.rows[0]).toEqual({ n: 47 });
|
||||
});
|
||||
|
||||
it.each(Object.keys(EXPECTED))('%s columns', async (table) => {
|
||||
|
||||
@@ -0,0 +1,506 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// SD1, SD2, SD4, SD5: the spam check (14): IPQualityScore first, then the local FTC copy, against a fake fetch.
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { eq, like, sql } from 'drizzle-orm';
|
||||
import type { LightMyRequestResponse } from 'fastify';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { SPAM_DEFAULTS, type SpamSettings } from '@selfphoneng/shared';
|
||||
import { encrypt } from '../src/crypto/secrets.ts';
|
||||
import {
|
||||
auditLog,
|
||||
cdr,
|
||||
ftcComplaints,
|
||||
settings as settingsTable,
|
||||
spamLookups,
|
||||
systemEvents,
|
||||
users,
|
||||
} from '../src/db/schema.ts';
|
||||
import type { ActiveCall } from '@selfphoneng/shared';
|
||||
import type { AmiMessage } from '../src/ami/events.ts';
|
||||
import { callerNameToken } from '../src/contacts/callername.ts';
|
||||
import { aggregateCall, toCdrRow } from '../src/services/cdr.ts';
|
||||
import { checkNumber, spamQuotaCheck } from '../src/spam/check.ts';
|
||||
import { getSetting, setSetting } from '../src/services/settings.ts';
|
||||
import { hasTestDb } from './db.ts';
|
||||
import { ADMIN, H, createTestApp } from './app.ts';
|
||||
|
||||
const IPQS_KEY = 'ipqs-secret-key-123';
|
||||
const FTC_KEY = 'datagov-secret-key-456';
|
||||
const SPAMMER = '13125550001';
|
||||
const SHADY = '13125550002';
|
||||
const CLEAN = '13125550003';
|
||||
const REPORTED = '13125550004';
|
||||
|
||||
/** IPQualityScore's phone answers by number; anything else is a valid, clean line. */
|
||||
const ANSWERS: Record<string, Record<string, unknown>> = {
|
||||
[SPAMMER]: { fraud_score: 40, spammer: true, line_type: 'VOIP', carrier: 'Acme Tel' },
|
||||
[SHADY]: { fraud_score: 78, city: 'Austin', region: 'TX', name: 'N/A' },
|
||||
[CLEAN]: { fraud_score: 10, line_type: 'Wireless', carrier: 'Verizon' },
|
||||
};
|
||||
|
||||
describe.skipIf(!hasTestDb)('spam check', () => {
|
||||
let t: Awaited<ReturnType<typeof createTestApp>>;
|
||||
type Req = (method: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
|
||||
let admin: Req;
|
||||
const urls: string[] = [];
|
||||
/** What the fake IPQualityScore does: answer, fail, or say the credits are used up. */
|
||||
let ipqs: 'ok' | 'down' | 'exhausted' | 'slow' | 'late' = 'ok';
|
||||
let credits = 912;
|
||||
const log = { warn: vi.fn() };
|
||||
const d = () => ({ db: t.db, log });
|
||||
|
||||
const settings = (s: Partial<SpamSettings>) =>
|
||||
setSetting(t.db, 'spam', { ...SPAM_DEFAULTS, enabled: true, ...s });
|
||||
// Getters: the secret key is only set once createTestApp has run.
|
||||
const keys = {
|
||||
get withIpqs() {
|
||||
return { ipqs: { keyEnc: encrypt(IPQS_KEY) } };
|
||||
},
|
||||
get withFtc() {
|
||||
return { ftc: { keyEnc: encrypt(FTC_KEY), syncHours: 24, windowDays: 30 } };
|
||||
},
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestApp();
|
||||
await t.clearMustChange(ADMIN.email);
|
||||
const cookie = await t.login(ADMIN.email, ADMIN.password);
|
||||
admin = (method, url, payload) =>
|
||||
t.app.inject({
|
||||
method: method as 'GET',
|
||||
url,
|
||||
payload: payload as object,
|
||||
headers: { ...H, cookie },
|
||||
});
|
||||
const day = (n: number) => sql`now() - make_interval(days => ${n})`;
|
||||
await t.db.insert(ftcComplaints).values([
|
||||
{ id: 'a1', number: REPORTED, reportedAt: day(1), subject: 'Warranties', robocall: true },
|
||||
{ id: 'a2', number: REPORTED, reportedAt: day(2), subject: 'Warranties', robocall: true },
|
||||
{ id: 'a3', number: REPORTED, reportedAt: day(3), subject: 'Debt', robocall: false },
|
||||
{ id: 'a4', number: REPORTED, reportedAt: day(40), subject: 'Debt', robocall: true },
|
||||
{ id: 'b1', number: SHADY, reportedAt: day(5), subject: null, robocall: null },
|
||||
]);
|
||||
});
|
||||
afterAll(() => t?.close());
|
||||
|
||||
beforeEach(() => {
|
||||
urls.length = 0;
|
||||
log.warn.mockClear();
|
||||
ipqs = 'ok';
|
||||
credits = 912;
|
||||
vi.stubGlobal('fetch', async (url: string, init?: RequestInit) => {
|
||||
urls.push(url);
|
||||
const json = (j: unknown, status = 200) => new Response(JSON.stringify(j), { status });
|
||||
if (url.startsWith('https://api.ftc.gov/')) {
|
||||
const key = (init?.headers as Record<string, string>)['X-Api-Key'];
|
||||
return key === FTC_KEY ? json({ data: [] }) : json({ error: {} }, 403);
|
||||
}
|
||||
const account = /\/api\/json\/account\/([^/]+)$/.exec(url);
|
||||
if (account)
|
||||
return account[1] === IPQS_KEY
|
||||
? json({ success: true, credits, usage: 88 })
|
||||
: json({ success: false, message: `Invalid or unauthorized key: ${account[1]}` });
|
||||
const phone = /\/api\/json\/phone\/[^/]+\/(\d+)\?country\[\]=US$/.exec(url);
|
||||
if (!phone) return json({}, 404);
|
||||
if (ipqs === 'down') return json({}, 503);
|
||||
if (ipqs === 'slow') throw Object.assign(new Error('aborted'), { name: 'TimeoutError' });
|
||||
if (ipqs === 'exhausted')
|
||||
return json({
|
||||
success: false,
|
||||
message: 'You have insufficient credits to make this query.',
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, ipqs === 'late' ? 1500 : 20));
|
||||
return json({ success: true, valid: true, ...(ANSWERS[phone[1]!] ?? { fraud_score: 0 }) });
|
||||
});
|
||||
});
|
||||
afterEach(async () => {
|
||||
vi.unstubAllGlobals();
|
||||
await t.db.delete(spamLookups);
|
||||
await t.db.delete(settingsTable).where(eq(settingsTable.key, 'spam.state'));
|
||||
await t.db.delete(systemEvents).where(like(systemEvents.code, 'spam.%'));
|
||||
});
|
||||
|
||||
it('checks nothing while off, and only US numbers', async () => {
|
||||
await setSetting(t.db, 'spam', { ...SPAM_DEFAULTS, ...keys.withIpqs });
|
||||
expect(await checkNumber(d(), SPAMMER)).toBeNull();
|
||||
await settings(keys.withIpqs);
|
||||
for (const n of ['442079460958', '911', '101', '13125550', '10005550001'])
|
||||
expect(await checkNumber(d(), n)).toBeNull();
|
||||
expect(urls).toEqual([]);
|
||||
});
|
||||
|
||||
it('IPQualityScore: risk from its flags and score, only the kept fields', async () => {
|
||||
await settings(keys.withIpqs);
|
||||
expect(await checkNumber(d(), SPAMMER)).toEqual({
|
||||
risk: 'high',
|
||||
provider: 'ipqs',
|
||||
info: {
|
||||
fraudScore: 40,
|
||||
spammer: true,
|
||||
recentAbuse: false,
|
||||
lineType: 'VOIP',
|
||||
carrier: 'Acme Tel',
|
||||
name: null,
|
||||
city: null,
|
||||
region: null,
|
||||
},
|
||||
});
|
||||
expect((await checkNumber(d(), SHADY))?.risk).toBe('medium');
|
||||
expect(await checkNumber(d(), CLEAN)).toMatchObject({
|
||||
risk: 'low',
|
||||
info: { carrier: 'Verizon' },
|
||||
});
|
||||
expect(urls).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('caches answers: flagged for flaggedDays, the rest for cleanDays', async () => {
|
||||
await settings({ ...keys.withIpqs, cache: { flaggedDays: 14, cleanDays: 3 } });
|
||||
await checkNumber(d(), SPAMMER);
|
||||
await checkNumber(d(), CLEAN);
|
||||
await checkNumber(d(), SPAMMER);
|
||||
await checkNumber(d(), CLEAN);
|
||||
expect(urls).toHaveLength(2);
|
||||
const days = await t.db.execute<{ number: string; days: number }>(
|
||||
sql`select number, round(extract(epoch from expires_at - now()) / 86400)::int as days from spam_lookups order by number`,
|
||||
);
|
||||
expect(days.rows).toEqual([
|
||||
{ number: SPAMMER, days: 14 },
|
||||
{ number: CLEAN, days: 3 },
|
||||
]);
|
||||
// An expired answer is asked again.
|
||||
await t.db.update(spamLookups).set({ expiresAt: sql`now() - interval '1 second'` });
|
||||
await checkNumber(d(), CLEAN);
|
||||
expect(urls).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('a check under way is shared: five at once cost one request', async () => {
|
||||
await settings(keys.withIpqs);
|
||||
const all = await Promise.all([1, 2, 3, 4, 5].map(() => checkNumber(d(), SPAMMER)));
|
||||
expect(new Set(all.map((a) => a?.risk))).toEqual(new Set(['high']));
|
||||
expect(urls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('IPQualityScore failing: one warning without the key, nothing cached, the FTC answers', async () => {
|
||||
await settings({ ...keys.withIpqs, ...keys.withFtc });
|
||||
for (const mode of ['down', 'slow'] as const) {
|
||||
ipqs = mode;
|
||||
expect(await checkNumber(d(), REPORTED)).toEqual({
|
||||
risk: 'high',
|
||||
provider: 'ftc',
|
||||
info: {
|
||||
complaints: 3,
|
||||
robocalls: 2,
|
||||
lastReportedAt: expect.any(String),
|
||||
subject: 'Warranties',
|
||||
},
|
||||
});
|
||||
}
|
||||
expect(log.warn.mock.calls.map((c) => c[0])).toEqual([
|
||||
{ provider: 'ipqs', reason: 'HTTP 503' },
|
||||
{ provider: 'ipqs', reason: 'no answer in 4 s' },
|
||||
]);
|
||||
expect(JSON.stringify(log.warn.mock.calls)).not.toContain(IPQS_KEY);
|
||||
expect(await t.db.select().from(spamLookups)).toEqual([]);
|
||||
});
|
||||
|
||||
it('without IPQualityScore the FTC list answers; without either, nothing', async () => {
|
||||
ipqs = 'down';
|
||||
await settings(keys.withFtc);
|
||||
expect((await checkNumber(d(), SHADY))?.info).toEqual({
|
||||
complaints: 1,
|
||||
robocalls: 0,
|
||||
lastReportedAt: expect.any(String),
|
||||
subject: null,
|
||||
});
|
||||
expect((await checkNumber(d(), SHADY))?.risk).toBe('medium');
|
||||
expect(await checkNumber(d(), CLEAN)).toMatchObject({ risk: 'low', provider: 'ftc' });
|
||||
expect(urls).toEqual([]);
|
||||
await settings({});
|
||||
expect(await checkNumber(d(), REPORTED)).toBeNull();
|
||||
});
|
||||
|
||||
it('out of credits: paused once, the FTC answers, the warning, Dismiss, and back', async () => {
|
||||
await settings({ ...keys.withIpqs, ...keys.withFtc });
|
||||
const warning = async () =>
|
||||
(await admin('GET', '/api/system/warnings'))
|
||||
.json()
|
||||
.items.find((w: { code: string }) => w.code === 'spam_quota');
|
||||
const events = async () =>
|
||||
(
|
||||
await t.db
|
||||
.select({ code: systemEvents.code })
|
||||
.from(systemEvents)
|
||||
.where(like(systemEvents.code, 'spam.%'))
|
||||
.orderBy(systemEvents.createdAt)
|
||||
).map((e) => e.code);
|
||||
ipqs = 'exhausted';
|
||||
const both = await Promise.all([checkNumber(d(), REPORTED), checkNumber(d(), SHADY)]);
|
||||
expect(both.map((a) => a?.provider)).toEqual(['ftc', 'ftc']);
|
||||
expect((await getSetting(t.db, 'spam.state')).ipqsPausedAt).not.toBeNull();
|
||||
expect(log.warn).toHaveBeenCalledTimes(1);
|
||||
expect(await events()).toEqual(['spam.quotaExhausted']);
|
||||
expect(await warning()).toEqual({
|
||||
code: 'spam_quota',
|
||||
message:
|
||||
"IPQualityScore's lookups are used up. Spam checks use the FTC list until they come back.",
|
||||
link: '/admin/settings/spam',
|
||||
dismissable: true,
|
||||
});
|
||||
|
||||
// Paused: IPQualityScore isn't asked and nothing is logged.
|
||||
urls.length = 0;
|
||||
expect((await checkNumber(d(), CLEAN))?.provider).toBe('ftc');
|
||||
expect(urls).toEqual([]);
|
||||
expect(log.warn).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect((await admin('POST', '/api/system/warnings/spam_quota/dismiss')).statusCode).toBe(200);
|
||||
expect(await warning()).toBeUndefined();
|
||||
|
||||
credits = 0;
|
||||
await spamQuotaCheck(t.db);
|
||||
expect((await getSetting(t.db, 'spam.state')).ipqsPausedAt).not.toBeNull();
|
||||
credits = 5;
|
||||
await spamQuotaCheck(t.db);
|
||||
expect((await getSetting(t.db, 'spam.state')).ipqsPausedAt).toBeNull();
|
||||
expect(await events()).toEqual(['spam.quotaExhausted', 'spam.quotaRestored']);
|
||||
ipqs = 'ok';
|
||||
expect((await checkNumber(d(), SPAMMER))?.provider).toBe('ipqs');
|
||||
|
||||
// Out again: the warning comes back though it was dismissed; without the FTC list, "off".
|
||||
await settings(keys.withIpqs);
|
||||
ipqs = 'exhausted';
|
||||
expect(await checkNumber(d(), SHADY)).toBeNull();
|
||||
expect((await warning())?.message).toBe(
|
||||
"IPQualityScore's lookups are used up. Spam checks are off until they come back.",
|
||||
);
|
||||
|
||||
// A new key doesn't wait for the job.
|
||||
const r = await admin('PATCH', '/api/settings', {
|
||||
spam: {
|
||||
...SPAM_DEFAULTS,
|
||||
enabled: true,
|
||||
ipqs: { key: IPQS_KEY },
|
||||
},
|
||||
});
|
||||
expect(r.statusCode).toBe(200);
|
||||
expect((await getSetting(t.db, 'spam.state')).ipqsPausedAt).toBeNull();
|
||||
expect(await warning()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('spamQuotaCheck asks nothing unless paused, and a failure throws without the key', async () => {
|
||||
await settings(keys.withIpqs);
|
||||
await spamQuotaCheck(t.db);
|
||||
expect(urls).toEqual([]);
|
||||
await setSetting(t.db, 'spam.state', {
|
||||
...(await getSetting(t.db, 'spam.state')),
|
||||
ipqsPausedAt: new Date().toISOString(),
|
||||
});
|
||||
await settings({ ipqs: { keyEnc: encrypt('wrong-key') } });
|
||||
const err = await spamQuotaCheck(t.db).catch((e: unknown) => e as Error);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err!.message).toBe('Invalid or unauthorized key: ***');
|
||||
expect((await getSetting(t.db, 'spam.state')).ipqsPausedAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it('cachedOnly never asks IPQualityScore', async () => {
|
||||
await settings({ ...keys.withIpqs, ...keys.withFtc });
|
||||
expect((await checkNumber(d(), REPORTED, { cachedOnly: true }))?.provider).toBe('ftc');
|
||||
await checkNumber(d(), SPAMMER);
|
||||
expect((await checkNumber(d(), SPAMMER, { cachedOnly: true }))?.provider).toBe('ipqs');
|
||||
expect(urls).toHaveLength(1);
|
||||
});
|
||||
|
||||
describe('while it rings', () => {
|
||||
let ringing: ActiveCall[] = [];
|
||||
const call = (from: string, ext: string): ActiveCall => ({
|
||||
linkedid: `l-${from}`,
|
||||
startedAt: new Date().toISOString(),
|
||||
direction: 'inbound',
|
||||
from: { number: from },
|
||||
to: { number: '13125559999' },
|
||||
state: 'ringing',
|
||||
extensions: [ext],
|
||||
channels: [],
|
||||
recording: false,
|
||||
});
|
||||
const check = async (number: string) =>
|
||||
(await admin('GET', `/api/me/caller-check?number=${number}`)).json().check;
|
||||
const deskName = async (number: string, ext = '101') =>
|
||||
(
|
||||
await t.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/internal/caller-name?ext=${ext}&number=${number}&token=${callerNameToken(t.app.config.APP_SECRET)}`,
|
||||
})
|
||||
).body;
|
||||
|
||||
beforeAll(async () => {
|
||||
const [me] = await t.db.select().from(users).where(eq(users.email, ADMIN.email));
|
||||
expect(
|
||||
(await admin('POST', '/api/extensions', { number: '101', name: 'Admin', userId: me!.id }))
|
||||
.statusCode,
|
||||
).toBe(201);
|
||||
await admin('POST', '/api/me/contacts', {
|
||||
name: 'Pizza Place',
|
||||
numbers: [{ number: SHADY.slice(1) }],
|
||||
});
|
||||
t.app.live.calls.list = () => ringing;
|
||||
});
|
||||
beforeEach(() => settings({ ...keys.withIpqs, ...keys.withFtc }));
|
||||
|
||||
it('the softphone: only callers ringing me now, never my contacts', async () => {
|
||||
ringing = [call(SPAMMER.slice(1), '101'), call(CLEAN, '102'), call(SHADY, '101')];
|
||||
expect(await check(SPAMMER)).toMatchObject({ risk: 'high', provider: 'ipqs' });
|
||||
expect(await check(`+1 ${SPAMMER.slice(1)}`)).toMatchObject({ risk: 'high' });
|
||||
expect(await check(CLEAN)).toBeNull(); // ringing someone else
|
||||
expect(await check(REPORTED)).toBeNull(); // not ringing
|
||||
expect(await check(SHADY)).toBeNull(); // a contact
|
||||
expect(urls).toHaveLength(1);
|
||||
ringing = [call(CLEAN, '101')];
|
||||
expect(await check(CLEAN)).toMatchObject({ risk: 'low' }); // low: the softphone hides it
|
||||
expect((await admin('GET', '/api/me/caller-check?number=nonsense')).json().check).toBeNull();
|
||||
});
|
||||
|
||||
it('desk phones: the label in time, nothing for a contact, low risk or a late answer', async () => {
|
||||
expect(await deskName(SPAMMER)).toBe('SPAM LIKELY');
|
||||
expect(await deskName(SHADY)).toBe('Pizza Place');
|
||||
expect(await deskName(SHADY, '999')).toBe('Spam risk'); // nobody's contact there
|
||||
expect(await deskName(CLEAN)).toBe('');
|
||||
await t.db.delete(spamLookups);
|
||||
ipqs = 'late';
|
||||
expect(await deskName(SPAMMER)).toBe('');
|
||||
await vi.waitFor(async () => expect(await t.db.select().from(spamLookups)).toHaveLength(1));
|
||||
expect(await deskName(SPAMMER)).toBe('SPAM LIKELY'); // from the cache
|
||||
await settings({ ...keys.withFtc, deskPhoneLabels: { high: 'JUNK', medium: 'Maybe junk' } });
|
||||
expect(await deskName(REPORTED)).toBe('JUNK');
|
||||
});
|
||||
|
||||
it('call history keeps what the check knew, after the cache expires; contacts first', async () => {
|
||||
const fixture = readFileSync(
|
||||
new URL('./fixtures/cdr/inbound-user.json', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const callFrom = async (number: string) => {
|
||||
const events = (JSON.parse(fixture.replaceAll('13125559999', number)) as AmiMessage[]).map(
|
||||
(e) => ({ ...e, LinkedID: `${number}-${String(e.LinkedID)}` }),
|
||||
);
|
||||
for (const e of events) await t.db.insert(cdr).values(toCdrRow(e));
|
||||
await aggregateCall(t.db, String(events[0]!.LinkedID));
|
||||
};
|
||||
await checkNumber(d(), SPAMMER);
|
||||
urls.length = 0;
|
||||
for (const n of [SPAMMER, REPORTED, SHADY, CLEAN]) await callFrom(n);
|
||||
expect(urls).toEqual([]); // cachedOnly: CLEAN isn't asked
|
||||
await t.db.delete(spamLookups);
|
||||
const spamOf = async (n: string) =>
|
||||
(await admin('GET', '/api/calls?direction=inbound'))
|
||||
.json()
|
||||
.items.find((c: { fromNumber: string }) => c.fromNumber === n)?.spam;
|
||||
expect(await spamOf(SPAMMER)).toMatchObject({
|
||||
risk: 'high',
|
||||
provider: 'ipqs',
|
||||
info: { spammer: true },
|
||||
});
|
||||
expect(await spamOf(REPORTED)).toMatchObject({
|
||||
risk: 'high',
|
||||
provider: 'ftc',
|
||||
info: { complaints: 3 },
|
||||
});
|
||||
expect(await spamOf(SHADY)).toBeNull(); // the admin's contact
|
||||
// Not in the cache, never reported: low (the page shows nothing for it).
|
||||
expect(await spamOf(CLEAN)).toMatchObject({ risk: 'low', provider: 'ftc' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Settings → Spam check', () => {
|
||||
const body = (s: Record<string, unknown> = {}) => ({
|
||||
spam: {
|
||||
enabled: true,
|
||||
ipqs: { key: IPQS_KEY },
|
||||
ftc: null,
|
||||
cache: { flaggedDays: 14, cleanDays: 3 },
|
||||
deskPhoneLabels: { high: 'SPAM LIKELY', medium: 'Spam risk' },
|
||||
...s,
|
||||
},
|
||||
});
|
||||
|
||||
it('Download now: due at the next minute; refused without the FTC list', async () => {
|
||||
await settings(keys.withIpqs);
|
||||
const r = await admin('POST', '/api/settings/spam/ftc/sync');
|
||||
expect(r.statusCode).toBe(400);
|
||||
expect(r.body).toMatch('Set up the FTC list first');
|
||||
await settings(keys.withFtc);
|
||||
await setSetting(t.db, 'spam.state', {
|
||||
...(await getSetting(t.db, 'spam.state')),
|
||||
ftcLastSyncAt: new Date().toISOString(),
|
||||
});
|
||||
expect((await admin('POST', '/api/settings/spam/ftc/sync')).statusCode).toBe(200);
|
||||
expect((await getSetting(t.db, 'spam.state')).ftcLastSyncAt).toBeNull();
|
||||
});
|
||||
|
||||
it('keys are write-only, and empty keeps the saved one', async () => {
|
||||
await setSetting(t.db, 'spam', SPAM_DEFAULTS);
|
||||
const r = await admin('PATCH', '/api/settings', body());
|
||||
expect(r.statusCode).toBe(200);
|
||||
expect(r.json().spam).toEqual({
|
||||
...SPAM_DEFAULTS,
|
||||
enabled: true,
|
||||
ipqs: { hasKey: true },
|
||||
});
|
||||
expect(r.body).not.toContain(IPQS_KEY);
|
||||
const saved = (await getSetting(t.db, 'spam')).ipqs!.keyEnc;
|
||||
expect((await admin('PATCH', '/api/settings', body({ ipqs: {} }))).statusCode).toBe(200);
|
||||
expect((await getSetting(t.db, 'spam')).ipqs!.keyEnc).toBe(saved);
|
||||
const [a] = await t.db
|
||||
.select()
|
||||
.from(auditLog)
|
||||
.where(eq(auditLog.summary, 'Changed settings: spam'));
|
||||
expect(JSON.stringify(a!.diff)).not.toContain(saved);
|
||||
});
|
||||
|
||||
it('refuses a provider without a key, nothing to check with, and odd labels', async () => {
|
||||
await setSetting(t.db, 'spam', SPAM_DEFAULTS);
|
||||
const no = async (b: unknown, error: RegExp) => {
|
||||
const r = await admin('PATCH', '/api/settings', b);
|
||||
expect(r.statusCode).toBe(400);
|
||||
expect(r.body).toMatch(error);
|
||||
};
|
||||
await no(body({ ipqs: {} }), /Enter the IPQualityScore key/);
|
||||
await no(
|
||||
body({ ipqs: null, ftc: { syncHours: 24, windowDays: 30 } }),
|
||||
/Enter the api.data.gov key/,
|
||||
);
|
||||
await no(body({ ipqs: null }), /Set up IPQualityScore or the FTC list/);
|
||||
await no(body({ deskPhoneLabels: { high: 'Spam?', medium: 'Spam risk' } }), /Letters/);
|
||||
await no(
|
||||
body({ ftc: { key: FTC_KEY, syncHours: 0, windowDays: 30 } }),
|
||||
/syncHours|too small|>=1/i,
|
||||
);
|
||||
await no({ 'spam.state': { ipqsPausedAt: null } }, /can't be changed/);
|
||||
// Off with no provider is fine.
|
||||
expect(
|
||||
(await admin('PATCH', '/api/settings', body({ enabled: false, ipqs: null }))).statusCode,
|
||||
).toBe(200);
|
||||
});
|
||||
|
||||
it('Test uses the typed key, else the saved one, and never shows it', async () => {
|
||||
await setSetting(t.db, 'spam', SPAM_DEFAULTS);
|
||||
const test = async (provider: string, key?: string) =>
|
||||
(await admin('POST', `/api/settings/spam/${provider}/test`, { key })).json();
|
||||
expect(await test('ipqs')).toEqual({ ok: false, error: 'Enter the key first' });
|
||||
expect(await test('ipqs', IPQS_KEY)).toEqual({
|
||||
ok: true,
|
||||
detail: 'Works: 912 credits left this billing period.',
|
||||
});
|
||||
const bad = await test('ipqs', 'wrong-key-789');
|
||||
expect(bad.ok).toBe(false);
|
||||
expect(JSON.stringify(bad)).not.toContain('wrong-key-789');
|
||||
await settings({ ...keys.withIpqs, ...keys.withFtc });
|
||||
expect((await test('ipqs')).ok).toBe(true);
|
||||
expect(await test('ftc')).toEqual({ ok: true, detail: 'The FTC answered.' });
|
||||
expect(await test('ftc', 'nope')).toEqual({
|
||||
ok: false,
|
||||
error: 'api.data.gov refused the key',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@selfphoneng/probe",
|
||||
"version": "0.0.9",
|
||||
"version": "0.0.10",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@selfphoneng/web",
|
||||
"version": "0.0.9",
|
||||
"version": "0.0.10",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import type {
|
||||
LdapInput,
|
||||
LdapTestResult,
|
||||
SpamCheck,
|
||||
SpamTestResult,
|
||||
AuthMethods,
|
||||
OidcInput,
|
||||
OidcTestResult,
|
||||
@@ -255,10 +257,16 @@ export const settingsApi = {
|
||||
'/api/settings/emergency/test',
|
||||
),
|
||||
warnings: () => api<{ items: Warning[] }>('GET', '/api/system/warnings').then((r) => r.items),
|
||||
dismissWarning: (code: string) =>
|
||||
api<{ ok: true }>('POST', `/api/system/warnings/${code}/dismiss`),
|
||||
testOidc: (v: OidcInput) => api<OidcTestResult>('POST', '/api/settings/auth/oidc/test', v),
|
||||
/** 12 §4.1 */
|
||||
testLdap: (v: LdapInput & { testLogin?: string; testPassword?: string }) =>
|
||||
api<LdapTestResult>('POST', '/api/settings/auth/ldap/test', v),
|
||||
/** 14 §7.1: the typed key, else the saved one. */
|
||||
syncFtc: () => api<{ ok: true }>('POST', '/api/settings/spam/ftc/sync'),
|
||||
testSpam: (provider: 'ipqs' | 'ftc', key?: string) =>
|
||||
api<SpamTestResult>('POST', `/api/settings/spam/${provider}/test`, { key }),
|
||||
};
|
||||
|
||||
/** Sign out, and when the session came from OpenID, at the provider too (12 §3.5). */
|
||||
@@ -299,6 +307,12 @@ export const meApi = {
|
||||
calls: (cursor?: string) =>
|
||||
api<ListResponse<MyCallDto>>('GET', `/api/me/calls${cursor ? `?cursor=${cursor}` : ''}`),
|
||||
get: () => api<MeDto>('GET', '/api/me'),
|
||||
/** 14 §5.1: only for a caller ringing me now. */
|
||||
callerCheck: (number: string) =>
|
||||
api<{ check: SpamCheck | null }>(
|
||||
'GET',
|
||||
`/api/me/caller-check?number=${encodeURIComponent(number)}`,
|
||||
).then((r) => r.check),
|
||||
softphoneQr: () => api<SoftphoneQr>('GET', '/api/me/softphone-qr'),
|
||||
update: (m: MeUpdate) => api<MeDto>('PATCH', '/api/me', m),
|
||||
connectContrack: (token: string) => api<MeDto>('PUT', '/api/me/contrack', { token }),
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
import { expect, it } from 'vitest';
|
||||
import { spamLine } from './SpamNote.tsx';
|
||||
|
||||
it('says what the spam check knows, in one line (14 §5.1)', () => {
|
||||
expect(
|
||||
spamLine({
|
||||
risk: 'high',
|
||||
provider: 'ipqs',
|
||||
info: {
|
||||
fraudScore: 92,
|
||||
spammer: true,
|
||||
recentAbuse: false,
|
||||
lineType: 'VOIP',
|
||||
carrier: 'Verizon',
|
||||
name: null,
|
||||
city: 'Austin',
|
||||
region: 'TX',
|
||||
},
|
||||
}),
|
||||
).toBe('Fraud score 92 · spammer · VOIP · Verizon · Austin, TX');
|
||||
const ftc = (complaints: number, robocalls: number, subject: string | null) =>
|
||||
spamLine({
|
||||
risk: 'high',
|
||||
provider: 'ftc',
|
||||
info: { complaints, robocalls, lastReportedAt: '2026-10-20T15:00:00Z', subject },
|
||||
});
|
||||
expect(ftc(4, 3, 'Warranties')).toMatch(
|
||||
/^Reported to the FTC 4 times \(3 robocalls\), last on .+: Warranties$/,
|
||||
);
|
||||
expect(ftc(1, 0, null)).toMatch(/^Reported to the FTC once, last on [^:]+$/);
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// What the spam check said about a caller (14 §5.1): a badge and a line, for high and medium
|
||||
// risk only. Shown in the softphone and call history.
|
||||
import type { SpamCheck } from '@selfphoneng/shared';
|
||||
|
||||
const day = (iso: string) =>
|
||||
new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||
|
||||
/** "Fraud score 92 · spammer · VoIP · Verizon · Austin, TX", or "Reported to the FTC 4 times…". */
|
||||
export function spamLine(c: SpamCheck): string {
|
||||
if (c.provider === 'ftc') {
|
||||
const i = c.info;
|
||||
const times = i.complaints === 1 ? 'once' : `${i.complaints} times`;
|
||||
const robo = i.robocalls ? ` (${i.robocalls} robocall${i.robocalls === 1 ? '' : 's'})` : '';
|
||||
const last = i.lastReportedAt ? `, last on ${day(i.lastReportedAt)}` : '';
|
||||
return `Reported to the FTC ${times}${robo}${last}${i.subject ? `: ${i.subject}` : ''}`;
|
||||
}
|
||||
const i = c.info;
|
||||
const place = [i.city, i.region].filter(Boolean).join(', ');
|
||||
return [
|
||||
i.name,
|
||||
`Fraud score ${i.fraudScore}`,
|
||||
i.spammer && 'spammer',
|
||||
i.recentAbuse && 'recent abuse',
|
||||
i.lineType,
|
||||
i.carrier,
|
||||
place,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
export function SpamNote({ check }: { check: SpamCheck | null | undefined }) {
|
||||
if (!check || check.risk === 'low') return null;
|
||||
const high = check.risk === 'high';
|
||||
return (
|
||||
<span className="flex flex-wrap items-center gap-x-2 text-sm">
|
||||
<span
|
||||
className={`rounded px-1.5 py-0.5 text-xs font-semibold ${
|
||||
high
|
||||
? 'bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-200'
|
||||
: 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-200'
|
||||
}`}
|
||||
>
|
||||
{high ? 'Likely spam' : 'Possible spam'}
|
||||
</span>
|
||||
<span className="text-zinc-600 dark:text-zinc-400">{spamLine(check)}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type CallFilter,
|
||||
} from '@selfphoneng/shared';
|
||||
import { callsApi, extensionsApi, recordingsApi } from '../../api/client.ts';
|
||||
import { SpamNote } from '../../components/SpamNote.tsx';
|
||||
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
|
||||
|
||||
export const fmtNumber = (n: string | null) =>
|
||||
@@ -170,6 +171,7 @@ export function CallsPage() {
|
||||
{c.answeredBy && (
|
||||
<span className="text-zinc-500"> · answered by {c.answeredBy.name}</span>
|
||||
)}
|
||||
<SpamNote check={c.spam} />
|
||||
</span>
|
||||
<StatusBadge status={c.status} />
|
||||
<span className="w-12 text-right tabular-nums">{fmtDuration(c.talkSeconds)}</span>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Dashboard (05 §2). Warnings, telephony link, trunks, calls in progress (hang up / transfer)
|
||||
// queue tiles, parked calls and extensions online.
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import type { ActiveCall, QueueLive } from '@selfphoneng/shared';
|
||||
import type { ActiveCall, QueueLive, Warning } from '@selfphoneng/shared';
|
||||
import { Link } from 'react-router';
|
||||
import { liveApi, settingsApi, type SpyMode } from '../../api/client.ts';
|
||||
import { ConfirmDialog } from '../../components/Dialog.tsx';
|
||||
@@ -144,7 +144,12 @@ function Warnings({ trunkDown }: { trunkDown: boolean }) {
|
||||
queryFn: settingsApi.warnings,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
const items = [
|
||||
const qc = useQueryClient();
|
||||
const dismiss = useMutation({
|
||||
mutationFn: settingsApi.dismissWarning,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['warnings'] }),
|
||||
});
|
||||
const items: Warning[] = [
|
||||
...(trunkDown
|
||||
? [
|
||||
{
|
||||
@@ -168,6 +173,19 @@ function Warnings({ trunkDown }: { trunkDown: boolean }) {
|
||||
<Link to={w.link} className="font-medium underline">
|
||||
Fix
|
||||
</Link>
|
||||
{w.dismissable && (
|
||||
<>
|
||||
{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="font-medium underline"
|
||||
disabled={dismiss.isPending}
|
||||
onClick={() => dismiss.mutate(w.code)}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Settings (05 §2). M3: Email and Emergency; M7: Recording; M11: Network (public IP watch), Security (phone ACL, provisioning, SIP TLS), Backups,
|
||||
// Webhooks, API tokens; AI (spec 10).
|
||||
// Webhooks, API tokens; AI (spec 10); Spam check (spec 14).
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Fragment, useState } from 'react';
|
||||
import { NavLink, useParams } from 'react-router';
|
||||
@@ -43,6 +43,7 @@ const TABS = [
|
||||
{ id: 'ai', label: 'AI' },
|
||||
{ id: 'contacts', label: 'Contacts' },
|
||||
{ id: 'sign-in', label: 'Sign-in' },
|
||||
{ id: 'spam', label: 'Spam check' },
|
||||
] as const;
|
||||
|
||||
export function SettingsPage() {
|
||||
@@ -79,6 +80,7 @@ export function SettingsPage() {
|
||||
{tab === 'tokens' && <TokensTab />}
|
||||
{settings.data && tab === 'ai' && <AiTab s={settings.data} />}
|
||||
{settings.data && tab === 'contacts' && <ContactsTab s={settings.data} />}
|
||||
{settings.data && tab === 'spam' && <SpamTab s={settings.data} />}
|
||||
{settings.data && tab === 'sign-in' && (
|
||||
<div className="flex flex-col gap-10">
|
||||
<SignInTab s={settings.data} />
|
||||
@@ -1373,6 +1375,279 @@ export function ContactsTab({ s }: { s: SettingsDto }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Test for one spam check provider (14 §7.1): the typed key, else the saved one. */
|
||||
function SpamTest({ provider, keyText }: { provider: 'ipqs' | 'ftc'; keyText: string }) {
|
||||
const test = useMutation({
|
||||
mutationFn: () => settingsApi.testSpam(provider, keyText || undefined),
|
||||
});
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button onClick={() => test.mutate()} disabled={test.isPending}>
|
||||
Test
|
||||
</Button>
|
||||
{test.isError && <Alert>{test.error.message}</Alert>}
|
||||
{test.data && (
|
||||
<span
|
||||
role="status"
|
||||
className={`text-sm ${test.data.ok ? 'text-green-700 dark:text-green-400' : 'text-red-700 dark:text-red-400'}`}
|
||||
>
|
||||
{test.data.ok ? test.data.detail : test.data.error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const when = (iso: string | null) => (iso ? new Date(iso).toLocaleString() : null);
|
||||
|
||||
/** Where the FTC download is, and Download now (14 §2.2). */
|
||||
function FtcSyncStatus({ st }: { st: SettingsDto['spam.state'] }) {
|
||||
const qc = useQueryClient();
|
||||
const sync = useMutation({
|
||||
mutationFn: settingsApi.syncFtc,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['settings'] }),
|
||||
});
|
||||
return (
|
||||
<div className="flex flex-col gap-1 text-sm">
|
||||
<p className="text-zinc-500">
|
||||
{st.ftcCursor
|
||||
? `Downloading: at the complaints of ${st.ftcCursor.day}.`
|
||||
: st.ftcLastSyncAt
|
||||
? `Last download ${when(st.ftcLastSyncAt)}.`
|
||||
: 'Not downloaded yet: it starts within a minute.'}
|
||||
</p>
|
||||
{st.ftcLastError && (
|
||||
<p className="text-amber-700 dark:text-amber-400">
|
||||
The last try failed: {st.ftcLastError}. It tries again every minute.
|
||||
</p>
|
||||
)}
|
||||
{!st.ftcCursor && (
|
||||
<div>
|
||||
<Button onClick={() => sync.mutate()} disabled={sync.isPending}>
|
||||
Download now
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{sync.isError && <Alert>{sync.error.message}</Alert>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Spam check (spec 14 §7.1): IPQualityScore first, then the FTC list. Information only. */
|
||||
function SpamTab({ s }: { s: SettingsDto }) {
|
||||
const save = useSave();
|
||||
const sp = s.spam;
|
||||
const st = s['spam.state'];
|
||||
const [v, setV] = useState({
|
||||
enabled: sp.enabled,
|
||||
useIpqs: sp.ipqs !== null,
|
||||
ipqsKey: '',
|
||||
useFtc: sp.ftc !== null,
|
||||
ftcKey: '',
|
||||
syncHours: String(sp.ftc?.syncHours ?? 24),
|
||||
windowDays: String(sp.ftc?.windowDays ?? 30),
|
||||
flaggedDays: String(sp.cache.flaggedDays),
|
||||
cleanDays: String(sp.cache.cleanDays),
|
||||
high: sp.deskPhoneLabels.high,
|
||||
medium: sp.deskPhoneLabels.medium,
|
||||
});
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const text = (k: keyof typeof v) => (e: { target: { value: string } }) =>
|
||||
setV({ ...v, [k]: e.target.value });
|
||||
const flag = (k: 'enabled' | 'useIpqs' | 'useFtc') => (e: { target: { checked: boolean } }) =>
|
||||
setV({ ...v, [k]: e.target.checked });
|
||||
const submit = () => {
|
||||
setConfirming(false);
|
||||
save.mutate(
|
||||
{
|
||||
spam: {
|
||||
enabled: v.enabled,
|
||||
ipqs: v.useIpqs ? { key: v.ipqsKey || undefined } : null,
|
||||
ftc: v.useFtc
|
||||
? {
|
||||
key: v.ftcKey || undefined,
|
||||
syncHours: Number(v.syncHours),
|
||||
windowDays: Number(v.windowDays),
|
||||
}
|
||||
: null,
|
||||
cache: { flaggedDays: Number(v.flaggedDays), cleanDays: Number(v.cleanDays) },
|
||||
deskPhoneLabels: { high: v.high, medium: v.medium },
|
||||
},
|
||||
},
|
||||
{ onSuccess: () => setV((x) => ({ ...x, ipqsKey: '', ftcKey: '' })) },
|
||||
);
|
||||
};
|
||||
// 14 §8: sending callers' numbers out is confirmed once, when IPQualityScore starts being used.
|
||||
const sendsOut = v.enabled && v.useIpqs && !(sp.enabled && sp.ipqs);
|
||||
const num = (k: 'syncHours' | 'windowDays' | 'flaggedDays' | 'cleanDays', label: string) => (
|
||||
<Field label={label}>
|
||||
{(a) => (
|
||||
<input {...a} inputMode="numeric" className={inputClass} value={v[k]} onChange={text(k)} />
|
||||
)}
|
||||
</Field>
|
||||
);
|
||||
const keyField = (k: 'ipqsKey' | 'ftcKey', label: string, saved: boolean, hint: string) => (
|
||||
<Field label={label} hint={saved ? 'Saved. Paste a new one to replace it.' : hint}>
|
||||
{(a) => (
|
||||
<input
|
||||
{...a}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
placeholder={saved ? '••••••••' : undefined}
|
||||
className={inputClass}
|
||||
value={v[k]}
|
||||
onChange={text(k)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
);
|
||||
return (
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
noValidate
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (sendsOut) setConfirming(true);
|
||||
else submit();
|
||||
}}
|
||||
>
|
||||
<p className="text-sm text-zinc-500">
|
||||
When an outside caller from the US isn’t in anyone’s contacts, look the number
|
||||
up and show what’s known about them while it rings. Information only: calls are never
|
||||
blocked or sent elsewhere. IPQualityScore answers first when it’s set up; the FTC list
|
||||
answers when it isn’t, or can’t.
|
||||
</p>
|
||||
<label className="flex items-center gap-2 text-sm font-medium">
|
||||
<input type="checkbox" checked={v.enabled} onChange={flag('enabled')} className="size-4" />
|
||||
Spam check on
|
||||
</label>
|
||||
|
||||
<fieldset className="flex flex-col gap-3">
|
||||
<legend className="mb-1 font-medium">IPQualityScore</legend>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={v.useIpqs}
|
||||
onChange={flag('useIpqs')}
|
||||
className="size-4"
|
||||
/>
|
||||
Use IPQualityScore: a live lookup with a fraud score. It sends the caller’s number
|
||||
to IPQualityScore. The free plan has 1,000 lookups a month.
|
||||
</label>
|
||||
{v.useIpqs && (
|
||||
<>
|
||||
{keyField(
|
||||
'ipqsKey',
|
||||
'API key',
|
||||
!!sp.ipqs?.hasKey,
|
||||
'From your IPQualityScore account settings.',
|
||||
)}
|
||||
<SpamTest provider="ipqs" keyText={v.ipqsKey} />
|
||||
{st.ipqsPausedAt && (
|
||||
<p className="text-sm text-amber-700 dark:text-amber-400">
|
||||
Paused since {when(st.ipqsPausedAt)}: its lookups are used up until they come back.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="flex flex-col gap-3">
|
||||
<legend className="mb-1 font-medium">FTC complaint list</legend>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={v.useFtc} onChange={flag('useFtc')} className="size-4" />
|
||||
Use the FTC list: complaints people filed with the FTC about unwanted calls, downloaded to
|
||||
this server. Free, and nothing about your calls is sent out. The reports aren’t
|
||||
verified, and spammers fake real people’s numbers.
|
||||
</label>
|
||||
{v.useFtc && (
|
||||
<>
|
||||
{keyField(
|
||||
'ftcKey',
|
||||
'api.data.gov key',
|
||||
!!sp.ftc?.hasKey,
|
||||
'Free, from api.data.gov/signup.',
|
||||
)}
|
||||
<SpamTest provider="ftc" keyText={v.ftcKey} />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{num('syncHours', 'Download again every (hours, 1–168)')}
|
||||
{num('windowDays', 'Keep complaints for (days, 7–365)')}
|
||||
</div>
|
||||
{sp.ftc && <FtcSyncStatus st={st} />}
|
||||
</>
|
||||
)}
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="flex flex-col gap-3">
|
||||
<legend className="mb-1 font-medium">Keep IPQualityScore answers</legend>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{num('flaggedDays', 'Possible or likely spam (days, 1–365)')}
|
||||
{num('cleanDays', 'Everything else (days, 1–365)')}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="flex flex-col gap-3">
|
||||
<legend className="mb-1 font-medium">Desk phones</legend>
|
||||
<p className="text-sm text-zinc-500">
|
||||
Shown in front of the caller’s name when the answer comes in before the phone rings.
|
||||
Letters, digits, spaces and . ' & @ - only.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Likely spam">
|
||||
{(a) => (
|
||||
<input
|
||||
{...a}
|
||||
maxLength={20}
|
||||
className={inputClass}
|
||||
value={v.high}
|
||||
onChange={text('high')}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Possible spam">
|
||||
{(a) => (
|
||||
<input
|
||||
{...a}
|
||||
maxLength={20}
|
||||
className={inputClass}
|
||||
value={v.medium}
|
||||
onChange={text('medium')}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{save.isError && <Alert>{save.error.message}</Alert>}
|
||||
{save.isSuccess && (
|
||||
<p role="status" className="text-sm text-green-700 dark:text-green-400">
|
||||
Saved.
|
||||
</p>
|
||||
)}
|
||||
<div>
|
||||
<Button type="submit" variant="primary" disabled={save.isPending}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
<ConfirmDialog
|
||||
open={confirming}
|
||||
title="Send callers' numbers to IPQualityScore?"
|
||||
message={
|
||||
<>
|
||||
The numbers of outside callers who aren’t in your contacts will be sent to
|
||||
IPQualityScore. You may need to say so in your privacy notice, and to have a data
|
||||
processing agreement with them. In the EU and UK the GDPR applies.
|
||||
</>
|
||||
}
|
||||
confirmLabel="Turn on"
|
||||
onConfirm={submit}
|
||||
onCancel={() => setConfirming(false)}
|
||||
busy={save.isPending}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
const lines = (s: string) =>
|
||||
s
|
||||
.split(/[\n,]/)
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Alert, Button } from '../../components/ui.tsx';
|
||||
import { softphone } from '../../softphone/SoftphoneEngine.ts';
|
||||
import { DIRECTION_TEXT, StatusBadge, fmtDuration, fmtNumber } from '../admin/CallsPage.tsx';
|
||||
import { AddToContacts, useContactName } from './ContactsPage.tsx';
|
||||
import { SpamNote } from '../../components/SpamNote.tsx';
|
||||
|
||||
/** The other side of the call, from where I sit. */
|
||||
export const otherParty = (c: MyCallDto) =>
|
||||
@@ -84,6 +85,7 @@ export function MyCallsPage() {
|
||||
{!known && other.number && c.direction !== 'internal' && (
|
||||
<AddToContacts number={other.number} name={other.name} />
|
||||
)}
|
||||
{c.myRole === 'in' && !known && <SpamNote check={c.spam} />}
|
||||
</span>
|
||||
{c.recordingId && (
|
||||
<audio
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
// Softphone on every /me page (05 §5.3): a bar at the bottom that opens a panel with the
|
||||
// dialpad and audio devices; call cards with mute/hold/keypad/transfer; the incoming call dialog.
|
||||
// Not yet: DND toggle (M6), Record via *1 and recent calls (M7).
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { formatNumber } from '@selfphoneng/shared';
|
||||
import { meApi } from '../api/client.ts';
|
||||
import { Dialog } from '../components/Dialog.tsx';
|
||||
import { SpamNote } from '../components/SpamNote.tsx';
|
||||
import { Button, inputClass } from '../components/ui.tsx';
|
||||
import { useLive } from '../live/store.ts';
|
||||
import { onTakeOver, stepDown, takeOver, tryLead } from './leader.ts';
|
||||
@@ -78,6 +80,19 @@ function useNameOf() {
|
||||
(c.remote.length > 6 ? formatNumber(c.remote) : c.remote || 'Unknown');
|
||||
}
|
||||
|
||||
/** The spam check for an outside caller (14 §5.1): asked once, as it starts ringing. */
|
||||
function CallerCheck({ call }: { call: CallInfo }) {
|
||||
const contact = useContactName();
|
||||
const check = useQuery({
|
||||
queryKey: ['caller-check', call.remote],
|
||||
queryFn: () => meApi.callerCheck(call.remote),
|
||||
enabled: call.direction === 'in' && call.remote.length >= 10 && !contact(call.remote),
|
||||
staleTime: Infinity,
|
||||
retry: false,
|
||||
});
|
||||
return <SpamNote check={check.data} />;
|
||||
}
|
||||
|
||||
function useNow(on: boolean) {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
@@ -257,6 +272,7 @@ export function SoftphoneDock() {
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{dialogCall && <CallerCheck call={dialogCall} />}
|
||||
</Dialog>
|
||||
</section>
|
||||
);
|
||||
@@ -361,6 +377,7 @@ function CallCard({
|
||||
{call.poorQuality && (
|
||||
<span className="ml-2 text-amber-700 dark:text-amber-400">Poor connection</span>
|
||||
)}
|
||||
<CallerCheck call={call} />
|
||||
</span>
|
||||
{call.state === 'incoming' ? (
|
||||
<>
|
||||
|
||||
@@ -13,6 +13,7 @@ keeping the system safe, legal and up to date.
|
||||
8. [Local AI](ai.md): voicemail transcripts
|
||||
9. [Contacts](contacts.md): the builtin phonebook, or Contrack
|
||||
10. [Sign-in](sign-in.md): passwords, and OpenID Connect (Keycloak and others)
|
||||
11. [Spam check](spam-check.md): IPQualityScore and the FTC complaint list
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ needs inside (`selfphoneng-probe-linux-x64`, or `-arm64` for ARM machines), next
|
||||
With Node 22 or newer installed you can take the small `selfphoneng-probe.cjs` instead.
|
||||
|
||||
```sh
|
||||
v=v0.0.9 # the release
|
||||
v=v0.0.10 # the release
|
||||
curl -fLO https://git.dou.bet/spng/selfphoneng/releases/download/$v/selfphoneng-probe-linux-x64
|
||||
curl -fLO https://git.dou.bet/spng/selfphoneng/releases/download/$v/SHA256SUMS
|
||||
sha256sum --check --ignore-missing SHA256SUMS
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# Spam check
|
||||
|
||||
When an outside caller from the US isn't anyone's contact, SelfPhoneNG can look the number up
|
||||
and show what's known about it while it rings:
|
||||
|
||||
- **Browser phone:** a red **Likely spam** or amber **Possible spam** badge under the caller's
|
||||
name, with a line such as "Fraud score 92 · spammer · VOIP · Austin, TX" or "Reported to the
|
||||
FTC 4 times (3 robocalls), last on Oct 20: Warranties".
|
||||
- **Desk phones:** the caller's name shows as **SPAM LIKELY** or **Spam risk** (you can change
|
||||
these), if the answer comes in before the phone starts ringing (about a second).
|
||||
- **Call history:** the same badge and line, for good: what was known when the call ended.
|
||||
|
||||
It's **information only**. Calls are never blocked, delayed or sent elsewhere, and a lookup that
|
||||
fails or is slow only means nothing is shown (and one warning in the api log). Low-risk callers,
|
||||
contacts, internal calls, withheld numbers and numbers outside the US show nothing.
|
||||
|
||||
It's off until you turn it on in **Settings → Spam check**.
|
||||
|
||||
## The two sources
|
||||
|
||||
You can use either one, or both. With both, IPQualityScore answers first, and the FTC list
|
||||
answers when IPQualityScore isn't set up, fails, or has run out of lookups.
|
||||
|
||||
| | IPQualityScore | FTC complaint list |
|
||||
|---|---|---|
|
||||
| What it is | A live lookup service with a fraud score per number | Complaints people filed with the FTC about unwanted calls |
|
||||
| Cost | 1,000 lookups a month free, then paid plans | Free |
|
||||
| Sends your callers' numbers out | **Yes**, to IPQualityScore | No: the list is downloaded to this server |
|
||||
| Likely spam | Flagged as a spammer, or fraud score 85+ | 3+ complaints in the window |
|
||||
| Possible spam | Fraud score 75+, recent abuse, or risky | 1–2 complaints |
|
||||
|
||||
The FTC's reports aren't verified, and spammers fake real people's numbers: a number with
|
||||
complaints may belong to someone whose number was faked. That's why the badge says what was
|
||||
reported, not that the caller is a spammer.
|
||||
|
||||
## IPQualityScore
|
||||
|
||||
1. Sign up at <https://www.ipqualityscore.com/> and copy your API key from the account settings.
|
||||
2. In **Settings → Spam check**, tick **Use IPQualityScore**, paste the key and press **Test**.
|
||||
It shows how many lookups are left this billing period.
|
||||
3. Tick **Spam check on** and **Save**. You're asked to confirm that callers' numbers will be
|
||||
sent to IPQualityScore (see [Privacy](#privacy)).
|
||||
|
||||
Each number is looked up once and the answer kept: 14 days for possible and likely spam, 3 days
|
||||
for the rest (**Keep IPQualityScore answers** changes these). Five phones ringing for the same
|
||||
call cost one lookup.
|
||||
|
||||
**When the lookups run out**, SelfPhoneNG stops asking IPQualityScore and the dashboard says so
|
||||
(**Dismiss** hides it until the next time). Checks use the FTC list meanwhile, if it's set up.
|
||||
Every 6 hours it asks IPQualityScore whether lookups are back and carries on by itself. Saving a
|
||||
new key carries on at once.
|
||||
|
||||
## FTC complaint list
|
||||
|
||||
1. Get a free key at <https://api.data.gov/signup/>.
|
||||
2. In **Settings → Spam check**, tick **Use the FTC list**, paste the key, press **Test**, then
|
||||
**Save** (with **Spam check on**).
|
||||
|
||||
The first download takes the last 30 days (**Keep complaints for**) and runs for about 7 hours:
|
||||
api.data.gov allows a key 1,000 requests an hour, and there are 5,000 to 15,000 complaints a day.
|
||||
The tab shows how far it got. After that it fetches what's new every 24 hours (**Download again
|
||||
every**); **Download now** starts one at once. If the FTC refuses the key or is down, the tab
|
||||
shows why and it tries again every minute from where it stopped.
|
||||
|
||||
## Privacy
|
||||
|
||||
- With **IPQualityScore**, the number of every outside caller who isn't a contact is sent to
|
||||
IPQualityScore, a company in the US. If your privacy notice lists who you share data with, add
|
||||
it. In the EU and UK the GDPR applies: you need a lawful basis (usually legitimate interest in
|
||||
protecting staff from fraud) and a data processing agreement with IPQualityScore.
|
||||
- With the **FTC list** only, nothing about your calls leaves the server.
|
||||
- Only a number that's ringing one of the user's own phones is looked up: nobody can use the
|
||||
browser phone to look up numbers that never called.
|
||||
- The keys are stored encrypted and never shown again; the audit log hides them.
|
||||
|
||||
## If nothing shows
|
||||
|
||||
- Is **Spam check on**, and is the caller in someone's contacts (contacts always win)?
|
||||
- Check the api log for `spam check failed` (it says which source and why) or
|
||||
`FTC complaint download failed`:
|
||||
|
||||
```sh
|
||||
docker compose logs api | grep -iE 'spam|ftc'
|
||||
```
|
||||
|
||||
- Desk phones only show the label when the answer is quick. A first call from a number may show
|
||||
nothing there while the browser phone shows it; the next call uses the kept answer.
|
||||
@@ -0,0 +1,14 @@
|
||||
# 0006 — Spam check: IPQualityScore and the FTC complaint list
|
||||
|
||||
Owner, 2026-10-27: show what's known about an outside caller who isn't in anyone's contacts
|
||||
(14). The callers are in the US, so the providers are US ones: **IPQualityScore** (a live
|
||||
lookup with a fraud score, spammer and recent-abuse flags, line type and carrier; free plan of
|
||||
1,000 lookups a month) and the **FTC Do Not Call complaint data** (free, synced daily into a local
|
||||
table through api.ftc.gov, so a check sends nothing out). IPQualityScore answers first when set
|
||||
up; the FTC list answers when it isn't or can't. Rejected: OpenSpam (Spanish numbers;
|
||||
kept for later), Hiya and Telnyx Number Reputation (contract only, or only for your own numbers),
|
||||
Truecaller (no open API), CallerAPI, Nomorobo, Trestle and YouMail (no free plan, prices on
|
||||
request or per query only). No package is added: both are plain HTTPS with `fetch`. IPQualityScore
|
||||
is closed-source, but it's an optional outside service the admin turns on, like an SMTP server,
|
||||
not a component we ship. It sends the caller's number out of the server, so it is off by default
|
||||
and asks the admin to confirm (14 §8).
|
||||
@@ -8,7 +8,7 @@ Conventions:
|
||||
- Primary keys: `uuid` with `default gen_random_uuid()` unless stated (built into PostgreSQL 18, so no pgcrypto extension is needed).
|
||||
- Every table has `created_at timestamptz not null default now()` and
|
||||
`updated_at timestamptz not null default now()` (left out below to save space,
|
||||
**but add them**). Exceptions: `cdr`, `audit_log`, `system_events`, `webhook_deliveries`, `generator_runs` (append-only, `created_at` only).
|
||||
**but add them**). Exceptions: `cdr`, `audit_log`, `system_events`, `webhook_deliveries`, `generator_runs`, `ftc_complaints` (append-only, `created_at` only).
|
||||
- Columns ending in `_enc` hold secrets encrypted with AES-256-GCM using
|
||||
`APP_SECRET` (format: `base64(iv[12] | tag[16] | ciphertext)`). Only
|
||||
`crypto/secrets.ts` encrypts and decrypts. API responses never include `_enc`
|
||||
@@ -451,10 +451,35 @@ create table calls ( -- one row per logical cal
|
||||
did_id uuid references dids(id) on delete set null,
|
||||
answered_by_extension_id uuid references extensions(id) on delete set null,
|
||||
talk_seconds int not null default 0,
|
||||
recording_id uuid references recordings(id) on delete set null
|
||||
recording_id uuid references recordings(id) on delete set null,
|
||||
spam_risk text, -- 14 §6: 'low' | 'medium' | 'high'; null = not checked
|
||||
spam_info jsonb -- 14 §6: what the check said
|
||||
);
|
||||
create index on calls (started_at desc);
|
||||
|
||||
-- Spam check (14 §7): IPQualityScore answers, and the local copy of the FTC complaints.
|
||||
create table spam_lookups ( -- IPQualityScore answers (14 §4)
|
||||
number text primary key, -- 1NXXNXXXXXX
|
||||
provider text not null, -- 'ipqs'
|
||||
risk text not null check (risk in ('low', 'medium', 'high')),
|
||||
info jsonb not null, -- the 14 §2.1 fields only
|
||||
expires_at timestamptz not null, -- updated_at is when it was checked
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
create index on spam_lookups (expires_at);
|
||||
|
||||
create table ftc_complaints ( -- the FTC copy (14 §2.2), kept windowDays; append-only
|
||||
id text primary key, -- the FTC's id
|
||||
number text not null, -- 1NXXNXXXXXX
|
||||
reported_at timestamptz not null, -- the complaint's created-date
|
||||
subject text,
|
||||
robocall boolean,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
create index on ftc_complaints (number);
|
||||
create index on ftc_complaints (reported_at);
|
||||
|
||||
create table call_extensions ( -- which extensions were involved (for per-user history)
|
||||
call_id uuid not null references calls(id) on delete cascade,
|
||||
extension_id uuid not null references extensions(id) on delete cascade,
|
||||
@@ -654,3 +679,5 @@ with a zod schema per key and a default. Missing rows return the default.
|
||||
| `provisioning.lanOnly` | boolean | false | only serve `/prov` to clients from local nets |
|
||||
| `provisioning.enabled` | boolean | true | |
|
||||
| `system.restartRequired` | boolean | false | set by generator |
|
||||
| `spam` | object | off, no providers | spam check (14 §7): `enabled`, `ipqs {keyEnc}`, `ftc {keyEnc, syncHours, windowDays}`, `cache {flaggedDays, cleanDays}`, `deskPhoneLabels {high, medium}` |
|
||||
| `spam.state` | object | `{}` | set by the system (14 §3, §2.2): IPQualityScore paused, the warning dismissed, the FTC sync's progress |
|
||||
@@ -391,6 +391,7 @@ Auth & self:
|
||||
| GET | `/api/me/greetings/:kind/audio` | user | the wav |
|
||||
| PUT/DELETE | `/api/me/greetings/:kind` | user | PUT body is the raw audio (a MediaRecorder Blob or a picked File, any `Content-Type`, max 20 MB), not multipart: no extra dependency. ffmpeg converts it (§6) |
|
||||
| GET | `/api/me/voicemail/:id/audio?download=1` | user | same, as an attachment |
|
||||
| GET | `/api/me/caller-check?number=` | user | the spam check for a caller ringing one of my extensions now, else `{check: null}` (14 §5.1, §9.4) |
|
||||
| GET/POST/PATCH/DELETE | `/api/me/contacts[/:id]` | user | my contacts; GET lists the company's too (11 §3). `POST /import`, `POST /share`, `GET /shares`, `POST /shares/accept\|deny` (11 §2.1, §3.1), `POST /copy-to-contrack` (11 §6.2) |
|
||||
| POST | `/api/me/queues/:id/pause`, `/unpause` | user | log out of / in to a queue I'm an agent of (Home, 05 §4), same as `*45<queue>` |
|
||||
| POST | `/api/me/click-to-call` | user | `{number}` → AMI `Originate Channel: PJSIP/<ext>, Context: sp-from-ext, Exten: <normalized>, Priority: 1, CallerID: "<number>" <<ext>>, Async: true`. The user's phone rings first, then the call goes out |
|
||||
@@ -433,8 +434,11 @@ As built (M11-T6): `routes/reports.ts`. `from`/`to` are company-time-zone days,
|
||||
| GET | `/api/audit-log` | |
|
||||
| GET/PATCH | `/api/settings` | whole settings object; PATCH merges known keys only. `smtp` comes back as `{host, port, secure, username, from, hasPassword}` and is written with an optional `password` (empty keeps the stored one). `system.*`, `sms.webhookToken` and `setup.completed` are read-only. References (DIDs, extensions, destinations) are checked, and a transport a trunk uses can't be turned off |
|
||||
| POST | `/api/settings/smtp/test` | `{to}` sends a test email |
|
||||
| POST | `/api/settings/spam/ftc/sync` | Download now: the FTC download starts at the next minute (14 §9.3) |
|
||||
| POST | `/api/settings/spam/ipqs/test`, `/api/settings/spam/ftc/test` | `{key?}`: the typed key, else the saved one; `{ok, detail}` or `{ok: false, error}` (14 §7.1) |
|
||||
| POST | `/api/settings/emergency/test` | runs 04 §5.3 steps 2–4 with `TEST` in front (email, ring-out, banner); records nothing and dials nothing (06 §6.5). Returns `{notified}` |
|
||||
| GET | `/api/system/warnings` | `{items: [{code, message, link}]}` for the dashboard banner: `restart_required`, `e911_cid_missing` (a trunk exists, no default E911 caller ID), `e911_address_unconfirmed` (that DID has no `e911_address`), `smtp_missing`, `tls_cert` (TLS on and the cert missing or expiring within 14 days, M11-T2), `backup_old` (backups on and today's not made yet though the job had its chance, M11-T3), `directory_check` (12 §4.2, §4.5: the directory unreachable at a sign-in or by `directoryCheck`, or that job refusing to disable most people). "Trunk down" comes from live state in the browser |
|
||||
| GET | `/api/system/warnings` | `{items: [{code, message, link, dismissable?}]}` for the dashboard banner: `restart_required`, `e911_cid_missing` (a trunk exists, no default E911 caller ID), `e911_address_unconfirmed` (that DID has no `e911_address`), `smtp_missing`, `tls_cert` (TLS on and the cert missing or expiring within 14 days, M11-T2), `backup_old` (backups on and today's not made yet though the job had its chance, M11-T3), `directory_check` (12 §4.2, §4.5: the directory unreachable at a sign-in or by `directoryCheck`, or that job refusing to disable most people), `spam_quota` (14 §3, dismissable). "Trunk down" comes from live state in the browser |
|
||||
| POST | `/api/system/warnings/spam_quota/dismiss` | hides `spam_quota` until IPQualityScore's credits run out again (14 §3) |
|
||||
| POST | `/api/settings/network/detect-public-ip` | |
|
||||
| GET | `/api/system/status` | `{ db: ok, ami: connected, asteriskVersion, uptime, trunks: [...], disk: {free, total}, restartRequired, lastGeneratorRun }` |
|
||||
| GET | `/api/system/tls` | the SIP TLS certificate in `TLS_DIR`: `{ok: true, subject, names, notAfter}` or `{ok: false, error}` (M11-T2) |
|
||||
@@ -465,7 +469,7 @@ Provisioning and inbound hooks (outside `/api`, no session):
|
||||
| Job | Schedule | Does |
|
||||
|---|---|---|
|
||||
| `nightlyRegenerate` | 00:05 daily | `generator.apply({trigger:'nightly', force:true})` (holidays roll over), aggregate orphan CDRs |
|
||||
| `retentionCleanup` | 02:00 daily | delete recordings older than `recordings.retentionDays` (file + row), voicemail older than `voicemail.retentionDays` if > 0, sessions past expiry, webhook_deliveries older than 30 days |
|
||||
| `retentionCleanup` | 02:00 daily | delete recordings older than `recordings.retentionDays` (file + row), voicemail older than `voicemail.retentionDays` if > 0, sessions past expiry, webhook_deliveries older than 30 days, expired `spam_lookups` (14 §4) |
|
||||
| `backup` | `backup.time` daily if enabled | §8.1 |
|
||||
| `publicIpWatch` | every 5 min if enabled | detect IP; if changed: update settings, apply, set restartRequired, email admins |
|
||||
| `webhookDelivery` | every 10 s | send pending deliveries (§9) |
|
||||
@@ -474,6 +478,8 @@ Provisioning and inbound hooks (outside `/api`, no session):
|
||||
| `contactsWarm` | every 5 min, Contrack mode | refresh the company's and each connected person's Contrack book, so a ringing phone's caller-name lookup (11 §6.1) finds them |
|
||||
| `contrackLog` | every 60 s, Contrack mode | calls ended over 1 min ago (and within 2 days) of people logging calls: one `call` interaction on the matching contact's Contrack timeline (11 §4.4) |
|
||||
| `callReports` | every 60 s | each user whose call report period (05 §4) ended over 5 min ago and isn't reported yet: email their calls of that period (by hang-up time) if there were any; `extensions.call_report_sent_at` = the period's end. Changing the schedule sets it to now, so the first report is the next period's |
|
||||
| `spamFtcSync` | every 60 s, when due or under way | the FTC complaint download, 15 pages a minute (14 §2.2, §9.3) |
|
||||
| `spamQuotaCheck` | every 6 hours, while IPQualityScore is paused | ask its Credit Usage API; credits back → resume (14 §3) |
|
||||
|
||||
Jobs must never overlap with themselves (use a running flag).
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ Left sidebar navigation, in this order:
|
||||
|
||||
| Page | Path | Contents |
|
||||
|---|---|---|
|
||||
| Dashboard | `/admin` | Cards: trunks (registered?), extensions online/total, active calls, calls today, missed today, voicemails waiting, disk free. Live call list (from, to, state, duration ticking, recording dot; admin buttons Hang up / Transfer / Listen). Queue tiles (waiting, longest wait, agents available). Warnings banner (restart required, SMTP not set, E911 caller ID not set, trunk down) |
|
||||
| Dashboard | `/admin` | Cards: trunks (registered?), extensions online/total, active calls, calls today, missed today, voicemails waiting, disk free. Live call list (from, to, state, duration ticking, recording dot; admin buttons Hang up / Transfer / Listen). Queue tiles (waiting, longest wait, agents available). Warnings banner (restart required, SMTP not set, E911 caller ID not set, trunk down; IPQualityScore out of credits, with Dismiss, 14 §3) |
|
||||
| Users & Extensions | `/admin/users` | Table: number, name, email, role, online (contacts count + state dot), DND, devices. Create/edit drawer with tabs: **General** (name, email, role, number with "next free" button), **Phone** (ring seconds, webrtc on/off, pickup group, BLF numbers multiselect, language, MOH class), **Voicemail** (enabled, PIN, email options), **Calling** (outbound class, outbound caller ID DID, recording mode), **Emergency** (E911 caller ID DID, location text), **Credentials** (SIP username/server/password reveal, regenerate, softphone QR), **Forwarding** (current DND/CFU/CFB/CFNA, editable). As built (owner follow-up to M11-T7, 2026-10-20): the Calling tab's recording mode was missing until then; it is a **Record calls** select (Never, Calls from outside, Calls to outside, All outside calls = `recordMode` never/inbound/outbound/all). Calls inside the company are never recorded, and no recording notice plays for it (only numbers set to record play one) |
|
||||
| Trunks | `/admin/trunks` | List with status pill. Form: provider (voip.ms / generic). Choosing voip.ms pre-fills port, transport, codecs and shows a POP server dropdown (06 §2) plus the voip.ms-side checklist. Test button. API credentials (optional) + "Import DIDs" |
|
||||
| Phone numbers (DIDs) | `/admin/numbers` | Table: number, label, trunk, destination summary ("→ IVR Main menu"), SMS on/off. Form: number, label, trunk, **DestinationPicker**, caller-name prefix, record inbound, SMS enabled + users allowed |
|
||||
@@ -47,8 +47,8 @@ Left sidebar navigation, in this order:
|
||||
| Reports | `/admin/reports` | Summary charts (calls per day stacked by direction, missed per day, busiest hours heatmap), queue report table. As built (M11-T6): From/To dates (the last 30 days by default, a year at most); totals (calls, missed, average talk time); the charts are plain bars, no chart library, each bar labeled for screen readers; the heatmap is a weekday × hour table in the company time zone; the queue table has the same numbers as the queue page. Owner follow-up (2026-10-21, "total minutes, calltime, missed calls"): totals also have answered, talk time and outside minutes (in and out: inbound from arrival, since a receptionist or voicemail answers too; outbound from the answer; roughly what the provider bills), an outside-minutes-per-day chart, and one tile per voip.ms trunk with its account balance (`GET /api/reports/balance`, `getBalance`; a trunk without the API login says what to add) |
|
||||
| SMS | `/admin/sms` | Same UI as the user one, with all DIDs |
|
||||
| Blocked numbers | `/admin/blocked` | |
|
||||
| Settings | `/admin/settings` | Tabs: General, Network (public IP detect, LAN IP, local nets, STUN/TURN), SIP (ports, TCP/TLS), Numbering ranges, Email (SMTP + test), Voicemail, Recording (retention, announcement sound + **legal notice text, 08 §5**), Emergency (default E911 caller ID, notify emails/extensions + a **test notification** button that does NOT dial 911), Backups (schedule, keep, include recordings, list + download + "Back up now"), Webhooks, API tokens. Security: desk phone networks, **outside calls at once** (per extension, whole system; 0 = no limit), phone setup files, SIP TLS |
|
||||
| System | `/admin/system` | Status JSON pretty-printed, Asterisk version, generator runs list, "Apply config now", "Rollback", "Restart telephony", generated-files viewer (read-only, masked), allowlisted CLI dropdown (04 §7.3) with output panel, audit log. As built (owner follow-ups to M11-T7, 2026-10-20), laid out after 3CX's admin dashboard (not copied): a **System information** panel (version, host name, company, public IP static/dynamic, LAN IPs, ports SIP/SIPS/media/HTTPS, telephony running, restart needed; copy buttons) with how to restart (`docker compose restart asterisk` on the server; no restart button),, a **Diagnostics** panel (08 §2.6: the server's own checks, then a Quick or Complete network test through the probe, each line with how to fix it), and an **Event log** panel (severity, event code, time, source, details; Load more; Download = `/api/system/events.csv`). Events (`system_events`, kept 90 days): `trunk.down`/`trunk.up` (registration changes), `telephony.down`/`telephony.up` (AMI), `telephony.restartNeeded`, `config.failed`, `emergency.call`/`emergency.test`, `network.publicIpChanged`, `backup.done`/`backup.failed`, `disk.low`. Admin edits stay in the audit log (not shown yet). The dashboard's `restart_required` warning links here. Generator runs, generated files and CLI views aren't built; the admin guide's troubleshooting page covers them from the shell |
|
||||
| Settings | `/admin/settings` | Tabs: General, Network (public IP detect, LAN IP, local nets, STUN/TURN), SIP (ports, TCP/TLS), Numbering ranges, Email (SMTP + test), Voicemail, Recording (retention, announcement sound + **legal notice text, 08 §5**), Emergency (default E911 caller ID, notify emails/extensions + a **test notification** button that does NOT dial 911), Backups (schedule, keep, include recordings, list + download + "Back up now"), Webhooks, API tokens. Security: desk phone networks, **outside calls at once** (per extension, whole system; 0 = no limit), phone setup files, SIP TLS. Spam check (14 §7.1) |
|
||||
| System | `/admin/system` | Status JSON pretty-printed, Asterisk version, generator runs list, "Apply config now", "Rollback", "Restart telephony", generated-files viewer (read-only, masked), allowlisted CLI dropdown (04 §7.3) with output panel, audit log. As built (owner follow-ups to M11-T7, 2026-10-20), laid out after 3CX's admin dashboard (not copied): a **System information** panel (version, host name, company, public IP static/dynamic, LAN IPs, ports SIP/SIPS/media/HTTPS, telephony running, restart needed; copy buttons) with how to restart (`docker compose restart asterisk` on the server; no restart button),, a **Diagnostics** panel (08 §2.6: the server's own checks, then a Quick or Complete network test through the probe, each line with how to fix it), and an **Event log** panel (severity, event code, time, source, details; Load more; Download = `/api/system/events.csv`). Events (`system_events`, kept 90 days): `trunk.down`/`trunk.up` (registration changes), `telephony.down`/`telephony.up` (AMI), `telephony.restartNeeded`, `config.failed`, `emergency.call`/`emergency.test`, `network.publicIpChanged`, `backup.done`/`backup.failed`, `disk.low`, `spam.quotaExhausted`/`spam.quotaRestored` (14 §3). Admin edits stay in the audit log (not shown yet). The dashboard's `restart_required` warning links here. Generator runs, generated files and CLI views aren't built; the admin guide's troubleshooting page covers them from the shell |
|
||||
|
||||
### 2.1 First-run setup wizard (`/setup`)
|
||||
|
||||
|
||||
@@ -9,6 +9,9 @@ Legend: 📖 read · 🔨 build · ✅ done when
|
||||
---
|
||||
|
||||
## Backlog (owner's order, 2026-10-21)
|
||||
0. **Spam check** (owner, 2026-10-27: SD1–SD5 done; the owner's real-key checks are left), tasks SD1–SD5
|
||||
in [14 §9](14-spam.md#9-order-of-work): IPQualityScore and the FTC complaint list for outside
|
||||
callers who aren't in anyone's contacts; information only, off by default.
|
||||
0. **Mobile app** (owner, 2026-10-26: next), tasks MB1–MB8 in [13 §5](13-mobile.md#5-order-of-work):
|
||||
Android and iPhone from one Expo codebase in the `mobile` repo; user portal only, no push in v1.
|
||||
0. **Single sign-on** (owner, 2026-10-25: next), tasks S1–S5 in [12 §8](12-sso.md#8-order-of-work):
|
||||
|
||||
@@ -302,6 +302,8 @@ As built (C5, `contacts/timeline.ts`):
|
||||
agent or a forward does its own lookup in its own user's book, and never sees another's.
|
||||
- The prefix of a number, ring group or queue ("Sales:") is kept in front:
|
||||
`__SP_CNAM_PREFIX` grows the same way `CALLERID(name)` does.
|
||||
- No contact: with spam check on, the route waits up to 1.2 s for its answer and names a likely
|
||||
or possible spammer with `spam.deskPhoneLabels` (14 §5.2, §9.4).
|
||||
- Globals `SP_CNAM_URL` (http://127.0.0.1:API_PORT/…) and `SP_CNAM_TOKEN` (HMAC-SHA256 of
|
||||
APP_SECRET, so it survives restarts). The route answers 404 unless the request comes from this
|
||||
machine without `X-Forwarded-For` (not through the reverse proxy) and with the key, and isn't
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
# 14 — Spam check
|
||||
|
||||
Owner, 2026-10-27: when an outside caller isn't in anyone's contacts, look their number up and show
|
||||
what's known about them. Why these providers and why it may leave the server:
|
||||
[decision 0006](../decisions/0006-spam-check.md).
|
||||
|
||||
## 1. What it does
|
||||
|
||||
- An outside call rings a user, and the number is none of: an extension, a contact of that user, a
|
||||
company phonebook entry (the 11 §1 lookup finds no name). Then the api **checks the number**
|
||||
with a provider and the user sees the answer while it rings.
|
||||
- **Information only** (owner): a check never blocks, reroutes or delays a call. The call rings at
|
||||
once; the answer appears when it arrives.
|
||||
- **US numbers only** (owner: the callers are in the US): `1NXXNXXXXXX` after `normalizeNumber`
|
||||
(00 §8). Anything else, a withheld number, and internal calls are never checked.
|
||||
- Shown in the **browser softphone** and **call history**; on **desk phones** only when the answer
|
||||
is in before their caller-name lookup gives up (§5.2).
|
||||
- **Off by default** (owner). The admin turns it on in Settings → Spam check (§8).
|
||||
- **Never in the way:** a provider that fails or is slow only means no answer is shown. Each
|
||||
failure is one `warn` log line with the provider and the reason (HTTP status, timeout, the
|
||||
provider's message), never the key or the request URL (IPQualityScore puts its key in the path).
|
||||
|
||||
## 2. Providers
|
||||
|
||||
The admin sets up either provider or both. The order is fixed (owner, 2026-10-27):
|
||||
**IPQualityScore first** when it's set up; the **FTC list** answers when IPQualityScore isn't set
|
||||
up or can't answer. v1 has these two:
|
||||
|
||||
| | IPQualityScore | FTC Do Not Call complaints |
|
||||
|---|---|---|
|
||||
| What | Fraud score and flags for any number, live | Unwanted-call complaints people filed with the FTC |
|
||||
| Where it runs | An HTTPS request per check | A local copy, synced by a job; a check is a database query |
|
||||
| Sends out | The caller's number | Nothing (it only downloads public data) |
|
||||
| Key | IPQualityScore API key | api.data.gov key (free) |
|
||||
| Cost | Free plan 1,000 lookups a month; paid from about $99 a month | Free |
|
||||
|
||||
"Can't answer" means: not set up, paused (§3), or this check failed. The FTC list is then asked
|
||||
for that check only; nothing is remembered about the failure. Neither set up → no checks.
|
||||
|
||||
### 2.1 IPQualityScore (`spam/ipqs.ts`)
|
||||
|
||||
- `GET https://www.ipqualityscore.com/api/json/phone/{key}/{number}?country[]=US`, the number as
|
||||
11 digits, 4 s timeout.
|
||||
- `success: false` whose message says the credits are used up ("You have insufficient credits to
|
||||
make this query…") → **exhausted**, §3. Any other `success: false`, an HTTP error or a timeout →
|
||||
a failure (logged, not cached, the FTC list asked).
|
||||
- Risk (owner, 2026-10-27): **high** if `spammer` or `fraud_score >= 85`; **medium** if
|
||||
`fraud_score >= 75`, `recent_abuse` or `risky`; else **low**. These follow IPQualityScore's
|
||||
general advice (75 suspicious, 85 high risk).
|
||||
- Kept (and nothing else from the answer): `fraud_score`, `spammer`, `recent_abuse`,
|
||||
`line_type`, `carrier`, `name`, `city`, `region`.
|
||||
- Credit Usage API: `GET https://www.ipqualityscore.com/api/json/account/{key}` → `credits` (left
|
||||
in this billing period), `usage`, `phone_usage`. Used by Test, the Settings tab and §3.
|
||||
`// TODO(spec): confirm with the owner's real key that this call costs no credit (SD2).`
|
||||
|
||||
### 2.2 FTC Do Not Call complaints (`spam/ftc.ts`)
|
||||
|
||||
- Source: `GET https://api.ftc.gov/v0/dnc-complaints`, key in `X-Api-Key`,
|
||||
`created_date_from` / `created_date_to`, `sort_order=ASC`, `items_per_page=50` (the maximum),
|
||||
`offset`. Kept per complaint: `id`, `company-phone-number` (10 digits, stored with the leading
|
||||
1), `created-date`, `subject`, `recorded-message-or-robocall`.
|
||||
- Not the daily CSV files on ftc.gov: the site answers scripted downloads with 403 (seen
|
||||
2026-10-27).
|
||||
- **Sync job** `spamFtcSync`, every `syncHours` hours (default 24; the FTC posts weekday data by
|
||||
about noon Eastern, weekend data on Monday). It asks from the last synced `created-date` minus
|
||||
one day (complaint ids make the overlap harmless) up to now, then deletes complaints older than
|
||||
`windowDays` (default 30). The first sync fills the whole window. **Sync now** on the Settings
|
||||
tab starts one at once.
|
||||
- Volume: 5,000 to 15,000 complaints a day (probed 2026-10-26), so 100–300 requests a day, and
|
||||
about 6,000 for the first 30 days. api.data.gov allows 1,000 requests an hour per key, so the
|
||||
sync paces itself to 900 an hour, saves where it got to, and the next run carries on (the first
|
||||
fill takes about 7 hours). A 429 ends the run there; the next run carries on.
|
||||
- Risk from complaints in the window (owner, 2026-10-27): **high** at 3 or more, **medium** at
|
||||
1–2, **low** at none.
|
||||
- Kept for the answer: the complaint count, how many were robocalls, the last complaint's date,
|
||||
the most common subject.
|
||||
- The reports aren't verified, and spammers fake real people's numbers: the softphone and the
|
||||
admin guide say "reported to the FTC", never "is spam".
|
||||
|
||||
## 3. When IPQualityScore's credits run out
|
||||
|
||||
IPQualityScore counts credits per billing period, which needn't be a calendar month, and the free
|
||||
plan may also have a daily cap. So rather than wait for the month to end, the api asks
|
||||
IPQualityScore when credits are back:
|
||||
|
||||
1. A check answered "insufficient credits" → IPQualityScore is **paused** (`spam.state.ipqsPausedAt`):
|
||||
- one `system_events` row, `spam.quotaExhausted` (warning, source "Spam check");
|
||||
- one `warn` log line;
|
||||
- the dashboard warning `spam_quota` (04 §7, `GET /api/system/warnings`).
|
||||
2. While paused, no request goes to IPQualityScore. Checks go to the FTC list if it's set up,
|
||||
else no checks are made. Nothing is logged per call.
|
||||
3. Job `spamQuotaCheck`, every 6 hours while paused, asks the Credit Usage API. `credits > 0` →
|
||||
resumed: `ipqsPausedAt` cleared, a `spam.quotaRestored` info event, the warning goes away. If
|
||||
the account call fails, the job tries again next time.
|
||||
|
||||
The warning, shown once (owner): "IPQualityScore's lookups are used up. Spam checks use the FTC
|
||||
list until they come back." (or "…are off until they come back." without the FTC list). It has a
|
||||
**Dismiss** button (`POST /api/system/warnings/spam_quota/dismiss`, admin), which sets
|
||||
`spam.state.warningDismissedAt`. Dismissed, it stays hidden until the next time the credits run out.
|
||||
|
||||
## 4. Cache (IPQualityScore only)
|
||||
|
||||
- `spam_lookups` (§7) keeps each answer, so a caller who rings again costs no credit:
|
||||
- **flagged** (medium or high) for `cache.flaggedDays`, default **14**;
|
||||
- **low** for `cache.cleanDays`, default **3**.
|
||||
- Both 1–365 days (owner: configurable).
|
||||
- Failures aren't cached. Expired rows go in `retentionCleanup` (04 §8).
|
||||
- One request per number at a time: a check already under way is shared (an in-memory map), so a
|
||||
ring group ringing five people costs one credit.
|
||||
- The FTC needs no cache: its check is already a local query.
|
||||
|
||||
## 5. Where checks happen
|
||||
|
||||
Everything goes through `spam/check.ts` `checkNumber(number, { cachedOnly })` → `{ risk,
|
||||
provider, info } | null`. It runs the §1 rules, then the cache, then the providers (§2). It
|
||||
doesn't do the contacts test: `checkCaller` (§9.4) does that for the user being rung.
|
||||
|
||||
### 5.1 Browser softphone
|
||||
|
||||
- `GET /api/me/caller-check?number=` → `{ check: { risk, provider, info } | null }`. It answers
|
||||
`null` when the number is in this user's books (11 §1), isn't checked (§1), or no provider
|
||||
answered within 5 s.
|
||||
- Only for a number that is ringing at one of the user's extensions, or rang there in the last
|
||||
2 minutes (live state, 04 §5.1); otherwise `null` without checking. A user can't spend
|
||||
credits on numbers that never called.
|
||||
- The softphone asks once per incoming call, as it starts ringing, and doesn't retry.
|
||||
- Shown under the caller's name, in the ringing dialog and on the call card, for **high** and
|
||||
**medium** only; **low** shows nothing (owner, 2026-10-27):
|
||||
- a red "Likely spam" (high) or amber "Possible spam" (medium) badge;
|
||||
- a line with what's known: IPQualityScore "Fraud score 92 · spammer · VoIP · Verizon · Austin,
|
||||
TX" (and the name, if it has one); FTC "Reported to the FTC 4 times (3 robocalls), last on Oct
|
||||
20: Warranties & protection plans".
|
||||
- Not in the browser notification: that appears as the call starts, before the answer.
|
||||
|
||||
### 5.2 Desk phones
|
||||
|
||||
- In `GET /api/internal/caller-name` (11 §6.1): when there's no contact name, it waits for the
|
||||
check for up to 1.2 s (inside the dialplan's 2 s). A high or medium answer puts its label in
|
||||
front of the caller ID name: `deskPhoneLabels`, by default "SPAM LIKELY" and "Spam risk"
|
||||
(owner, 2026-10-27: configurable; no `?`, which `phoneSafeName` drops). The whole name goes
|
||||
through `phoneSafeName`, as now. A later answer is still cached for the softphone.
|
||||
- The dialplan doesn't change, so no generator change and no new golden file.
|
||||
|
||||
## 6. Call history
|
||||
|
||||
- `calls.spam_risk` and `calls.spam_info` (§7) are written with the call row (CDR aggregation,
|
||||
incoming calls only) from `checkNumber(from_number, { cachedOnly: true })`: the cache and the
|
||||
local FTC copy, never a request out.
|
||||
- The Calls pages (`/me/calls`, `/admin/calls`) show the §5.1 badge and line, unless the viewer's
|
||||
books name the number (contacts come first, 11 §1). The call list API adds
|
||||
`spam: { risk, provider, info } | null` to each item. The mobile app's call history (13 MB5)
|
||||
gets it from the same API.
|
||||
|
||||
## 7. Data and settings
|
||||
|
||||
Migration (SD1):
|
||||
|
||||
```sql
|
||||
create table spam_lookups ( -- IPQualityScore answers (14 §4)
|
||||
number text primary key, -- 1NXXNXXXXXX
|
||||
provider text not null, -- 'ipqs'
|
||||
risk text not null check (risk in ('low', 'medium', 'high')),
|
||||
info jsonb not null, -- the 14 §2.1 fields only
|
||||
expires_at timestamptz not null, -- updated_at is when it was checked
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
create index on spam_lookups (expires_at);
|
||||
|
||||
create table ftc_complaints ( -- the FTC copy (14 §2.2), kept windowDays; append-only
|
||||
id text primary key, -- the FTC's id
|
||||
number text not null, -- 1NXXNXXXXXX
|
||||
reported_at timestamptz not null, -- the complaint's created-date
|
||||
subject text,
|
||||
robocall boolean,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
create index on ftc_complaints (number);
|
||||
create index on ftc_complaints (reported_at);
|
||||
|
||||
alter table calls add column spam_risk text, add column spam_info jsonb;
|
||||
```
|
||||
|
||||
Settings (02 §3):
|
||||
|
||||
| Key | Type | Default | Notes |
|
||||
|---|---|---|---|
|
||||
| `spam` | object | below | the admin's choices |
|
||||
| `spam.state` | object | `{}` | read-only to the API, like `system.*`: `ipqsPausedAt`, `warningDismissedAt`, `ftcCursor` (`{day, offset}` while a download is under way), `ftcLastSyncAt`, `ftcLastError` |
|
||||
|
||||
```ts
|
||||
spam = {
|
||||
enabled: false,
|
||||
ipqs: { keyEnc: string } | null, // null = not used; first when set (§2)
|
||||
ftc: { keyEnc: string, syncHours: 24, windowDays: 30 } | null, // 1–168 hours, 7–365 days
|
||||
cache: { flaggedDays: 14, cleanDays: 3 }, // 1–365 each
|
||||
deskPhoneLabels: { high: 'SPAM LIKELY', medium: 'Spam risk' }, // 1–20 characters, §5.2
|
||||
}
|
||||
```
|
||||
|
||||
Keys are `_enc` (02, top: `crypto/secrets.ts`): the API returns `hasKey`, never the key; the audit
|
||||
log masks it. Validated with zod (`packages/shared`).
|
||||
|
||||
### 7.1 Settings → Spam check
|
||||
|
||||
- **Spam check on** (the switch asks to confirm the first time, §8).
|
||||
- **Use IPQualityScore**: key, **Test** (the Credit Usage API: shows the credits left without
|
||||
spending one), "paused until credits come back" while paused.
|
||||
- **Use the FTC list**: api.data.gov key (a link to its sign-up), **Test** (one request),
|
||||
re-download every N hours, keep N days, last sync time and result, **Sync now**. With both on,
|
||||
the tab says IPQualityScore answers first and the FTC list when it can't.
|
||||
- Keep answers: flagged N days, others N days.
|
||||
- Desk phone labels: high, medium. Saving refuses a label `phoneSafeName` would change.
|
||||
- Test buttons use the stricter Test rate limit, as the others do (04 §2.2).
|
||||
|
||||
## 8. Privacy
|
||||
|
||||
- Spam check is off until the admin turns it on. With IPQualityScore it is an exception to
|
||||
"nothing leaves the server": the caller's number (and nothing else: no name, no extension)
|
||||
goes to IPQualityScore. The FTC list sends nothing about calls.
|
||||
- Turning on IPQualityScore asks once: "The numbers of outside
|
||||
callers who aren't in your contacts will be sent to IPQualityScore. You may need to say so in
|
||||
your privacy notice, and to have a data processing agreement with them. In the EU and UK the
|
||||
GDPR applies." The audit log records who turned it on.
|
||||
- GDPR covers businesses in the EU or UK and calls from people there, so it isn't the owner's case
|
||||
(US callers only). The admin guide says so for everyone else, along with US state privacy laws
|
||||
such as California's. It's not legal advice.
|
||||
- Admin guide: a new page `docs/admin-guide/spam-check.md` (what each provider is, what leaves the
|
||||
server, the FTC caveat, the free allowance) and a line in `legal.md`.
|
||||
|
||||
## 9. Order of work
|
||||
|
||||
| Task | What | Done when |
|
||||
|---|---|---|
|
||||
| SD1 | Decision 0006; this spec; the §7 migration and settings; `spam/check.ts` with both providers' lookups, the cache and the shared requests; Settings → Spam check with keys, Test, privacy confirmation | `pnpm lint && typecheck && test` pass; checks, cache times, the order (IPQualityScore, then the FTC list) and failures are tested against fake HTTP. Done, see §9.1 |
|
||||
| SD2 | §3: pause, events, the `spam_quota` warning and Dismiss, `spamQuotaCheck` | an exhausted fake pauses, falls back to the FTC list, warns once and resumes; the owner's real key shows the credit call is free. Done but the real-key check, see §9.2 |
|
||||
| SD3 | §2.2 sync: paging, pacing, the saved cursor, the window, Sync now | a fake FTC API with 3 days of data syncs, resumes after a 429 and drops old complaints; one real sync with the owner's key. Done but the real sync, see §9.3 |
|
||||
| SD4 | §5: `/api/me/caller-check`, the softphone badge and line, the desk phone labels | an unknown number rings the softphone and shows its answer; a contact or a low risk shows nothing; a desk phone shows "SPAM LIKELY" when the answer is in time. Done but a real call, see §9.4 |
|
||||
| SD5 | §6 call history; the admin guide page | a checked call keeps its badge in call history after its cache expires. Done, see §9.5 |
|
||||
|
||||
### 9.1 As built: SD1
|
||||
|
||||
- `spam/check.ts` `checkNumber(deps, number, { cachedOnly })`. There's no `wait`: a caller that
|
||||
can't wait (14 §5.1, §5.2) races it against its own timer, and the check carries on and fills
|
||||
the cache. The cache is only read while IPQualityScore is set up; turning it off leaves the
|
||||
rows to expire.
|
||||
- `spam/ipqs.ts`, `spam/ftc.ts` (the local lookup and Test; the sync is §9.3), `spam/http.ts`
|
||||
(`getJson`, 4 s timeout, and `SpamError`, the only error text that is logged or shown: never
|
||||
the URL or fetch's own error). A provider message is cut to 200 characters with the key taken
|
||||
out, in case it quotes it.
|
||||
- "Insufficient credits": see §9.2.
|
||||
- Settings: the `spam` key holds the admin's choices, `spam.state` is read-only to PATCH (like
|
||||
`system.*`). PATCH refuses a provider with no key typed or saved ("Enter the IPQualityScore
|
||||
key", "Enter the api.data.gov key") and spam check on with neither provider. Labels are 1–20 of
|
||||
`phoneSafeName`'s characters, runs of spaces made one. The audit log masks `keyEnc` (its
|
||||
`…Enc` rule).
|
||||
- `POST /api/settings/spam/ipqs/test` and `/ftc/test` `{ key? }` → `{ ok, detail | error }`: the
|
||||
typed key, else the saved one. IPQualityScore's is the Credit Usage API ("Works: 912 credits
|
||||
left this billing period."); the FTC's is one request for one complaint.
|
||||
- Settings → Spam check (`SpamTab`): the switch, a section per provider with its key and Test,
|
||||
the cache days and the desk phone labels. Saving with IPQualityScore newly in use opens the
|
||||
14 §8 confirmation first.
|
||||
- `retentionCleanup` deletes expired `spam_lookups` rows.
|
||||
- Tests: `test/spam.test.ts` with a stubbed `fetch` (risk, kept fields, cache days and expiry,
|
||||
the shared request, IPQualityScore down / timing out → the FTC list, warnings without the key,
|
||||
`cachedOnly`, the settings and Test routes).
|
||||
|
||||
### 9.2 As built: SD2
|
||||
|
||||
- `spam/check.ts` `patchSpamState(db, patch, onlyIf?)` merges into `spam.state` in one
|
||||
statement (`value || patch`), so a check and a job never overwrite each other's fields; SD3's
|
||||
cursor uses it too. Pausing is `onlyIf` not paused yet, so of several checks running out at
|
||||
once, one logs the warning and the `spam.quotaExhausted` event.
|
||||
- Paused: the cache still answers (14 §4), IPQualityScore isn't asked, nothing is logged.
|
||||
- `spamQuotaCheck` runs at start and every 6 hours; it does nothing unless paused. A failing
|
||||
account call throws `SpamError` (no key in it), which the job's log line shows.
|
||||
- Also resumed (owner's intent, not in §3): saving a newly typed IPQualityScore key, so a new
|
||||
account isn't paused for up to 6 hours.
|
||||
- `Warning` has `dismissable`; the dashboard shows **Dismiss** next to **Fix** for it. Pausing
|
||||
clears `warningDismissedAt`, so the warning comes back the next time.
|
||||
- Not done: the owner's real key showing that the Credit Usage API costs no credit (the TODO in
|
||||
§2.1). If it does cost one, 4 calls a day while paused is the price.
|
||||
- Tests: `test/spam.test.ts` (two checks running out at once: one warning, one event; the FTC
|
||||
list answering; no requests while paused; the warning and Dismiss; the job leaving it paused at
|
||||
0 credits and resuming above; the "are off" text without the FTC list; a new key; the job
|
||||
asking nothing unless paused and failing without the key).
|
||||
|
||||
### 9.3 As built: SD3
|
||||
|
||||
- `spam/ftc.ts` `ftcSync(db, { now, budget })`, job `spamFtcSync` **every minute**. It does
|
||||
nothing unless spam check and the FTC list are on and a download is due (`ftcLastSyncAt`
|
||||
older than `syncHours`, or never) or under way (`ftcCursor`).
|
||||
- One FTC date at a time (`created_date_from` / `_to` that day, `sort_order=ASC`, 50 a page,
|
||||
`offset`), so an offset never grows past one day's complaints. A short page moves on to the
|
||||
next day; today's short page ends the download: complaints older than `windowDays` are
|
||||
deleted and `ftcLastSyncAt` set.
|
||||
- Pacing: at most 15 pages a minute (900 an hour), and the place (`ftcCursor`) saved after each
|
||||
run, so a restart carries on. The first 30 days take about 7 hours.
|
||||
- Where a download starts: the newest complaint kept, less a day (for ones posted late, as on
|
||||
weekends), but no further back than `windowDays`. Ids make the overlap harmless.
|
||||
- The FTC's dates have no zone: taken as Eastern, so a day is midnight to midnight in
|
||||
Washington. Complaints without a 10-digit US number or a full date are skipped.
|
||||
- A failure (403 "api.data.gov refused the key", a 429 "…over its hourly limit", a timeout)
|
||||
keeps the place and is saved in `ftcLastError`; the next minute tries again from the same page.
|
||||
The job logs it (`warn`) only when it differs from the last one, so a long outage is one line.
|
||||
- Settings → Spam check: "Downloading: at the complaints of {day}", "Last download {time}", the
|
||||
last failure, and **Download now** (`POST /api/settings/spam/ftc/sync`, which clears
|
||||
`ftcLastSyncAt` so the next minute starts one; 400 without the FTC list).
|
||||
- Not done: one real download with the owner's api.data.gov key.
|
||||
- Tests: `test/ftc-sync.test.ts` against a fake api.ftc.gov (off; the first download a day at a
|
||||
time with three pages on one day, old complaints dropped, robocall and Eastern time; not again
|
||||
until `syncHours`, then from the newest day less one; 15 pages a minute with the place saved; a
|
||||
429 stopping and the next minute carrying on without duplicates; the same failure logged once;
|
||||
a refused key). `test/spam.test.ts`: Download now.
|
||||
|
||||
### 9.4 As built: SD4
|
||||
|
||||
- `spam/check.ts` `checkCaller(deps, userId, number, waitMs)`: the contacts test (that user's
|
||||
books and the company's, `contactNames` without waiting on Contrack; books that can't be read
|
||||
count as "known", so nothing is shown), then `checkNumber` raced against `waitMs`. A late
|
||||
answer still fills the cache. `usNumber` normalizes (00 §8) or gives ''.
|
||||
- `GET /api/me/caller-check?number=` (5 s): only for an inbound call in live state (04 §5.1)
|
||||
from that number with one of my extensions among its channels (the browser's
|
||||
`PJSIP/101-web-…` and ring group / queue `Local/101@…` legs count). Live state already keeps
|
||||
the caller's number (the first channel's caller ID), so nothing was added there.
|
||||
- Not built: "or rang there in the last 2 minutes". The softphone asks as the call starts
|
||||
ringing, so it's in live state; a call gone before its question arrives has nothing to show.
|
||||
- `GET /api/internal/caller-name`: no contact → `checkCaller(…, 1200)`; high or medium →
|
||||
`deskPhoneLabels` and, from IPQualityScore, its name for the line. The label **replaces** the
|
||||
carrier's caller ID name rather than going in front of it: the dialplan doesn't send that
|
||||
name, and §5.2 keeps the dialplan as it is. Its failures log through the app's logger (the
|
||||
route's own is silent: the token is in the query).
|
||||
- Softphone: `CallerCheck` in `SoftphoneDock.tsx`, in the ringing dialog and on each call card
|
||||
(so the note stays after answering). Asked once per number (react-query, no retry), only for
|
||||
an incoming call from 10+ digits that the browser doesn't already know as a contact.
|
||||
- `components/SpamNote.tsx`: the badge and line (`spamLine`), for SD5's call history too.
|
||||
- Tests: `test/spam.test.ts` "while it rings" (only callers ringing me, +1 formatting, someone
|
||||
else's call, not ringing, a contact, low; desk labels, a contact's name, a number nobody has,
|
||||
a late answer then the cache, custom labels from the FTC list), `SpamNote.test.ts` (the line).
|
||||
- Not checked: a real call in the browser and on a desk phone.
|
||||
|
||||
### 9.5 As built: SD5
|
||||
|
||||
- `aggregateCall` (04 §5.4) runs `checkNumber(…, { cachedOnly: true })` for inbound calls and
|
||||
writes `spam_risk` and `spam_info` (`{ provider, info }`) with the row. A low answer is kept
|
||||
too (the FTC list answers low for a number nobody reported); null means not checked.
|
||||
- `CallDto.spam` on `/api/calls`, `/api/calls/:id` and `/api/me/calls`: null when the viewer's
|
||||
books (theirs and the company's) name the number, or when the books can't be read.
|
||||
- Calls pages: `SpamNote` under the caller, on `/me/calls` only for calls to me that the browser
|
||||
doesn't know as a contact. The CSV export has no spam columns.
|
||||
- Admin guide: `docs/admin-guide/spam-check.md`.
|
||||
- Tests: `test/spam.test.ts` (four calls aggregated: IPQualityScore's answer and the FTC's kept
|
||||
after the cache is emptied, a contact's call shows nothing, an unreported number low, and no
|
||||
request out).
|
||||
|
||||
Later, not now:
|
||||
- **Report as spam** (owner, 2026-10-27: not now): a button in call history that reports the
|
||||
number to a provider that takes reports (OpenSpam has `POST /api/report`).
|
||||
- **OpenSpam** as a provider for Spanish numbers (the owner has a key; it has little US data).
|
||||
- **Low-risk details** (owner, 2026-10-27: nothing for now): show what IPQualityScore knows
|
||||
about a low-risk caller too (line type, carrier, city, name), without a badge.
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "selfphoneng",
|
||||
"version": "0.0.9",
|
||||
"version": "0.0.10",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@selfphoneng/shared",
|
||||
"version": "0.0.9",
|
||||
"version": "0.0.10",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Call history (02 calls, call_extensions; 04 §5.4, §7.3).
|
||||
import { z } from 'zod';
|
||||
import type { SpamCheck } from './spam.ts';
|
||||
|
||||
export const CALL_DIRECTIONS = ['inbound', 'outbound', 'internal'] as const;
|
||||
export const CALL_STATUSES = [
|
||||
@@ -43,6 +44,8 @@ export type CallDto = {
|
||||
answeredBy: { id: string; number: string; name: string } | null;
|
||||
talkSeconds: number;
|
||||
recordingId: string | null;
|
||||
/** 14 §6: what the spam check knew as it ended; null when not checked or in the viewer's books. */
|
||||
spam: SpamCheck | null;
|
||||
};
|
||||
|
||||
/** One raw CDR leg, for the expanded row. */
|
||||
|
||||
@@ -27,3 +27,4 @@ export * from './blocked.ts';
|
||||
export * from './calls.ts';
|
||||
export * from './contacts.ts';
|
||||
export * from './integrations.ts';
|
||||
export * from './spam.ts';
|
||||
@@ -2,6 +2,12 @@
|
||||
// One zod schema (with its default) per settings key (spec 02 §3).
|
||||
import { z } from 'zod';
|
||||
import { DestinationSchema } from './destination.ts';
|
||||
import {
|
||||
SPAM_DEFAULTS,
|
||||
SpamSettingsSchema,
|
||||
SpamStateSchema,
|
||||
type SpamSettingsDto,
|
||||
} from './spam.ts';
|
||||
|
||||
const range = (from: number, to: number) =>
|
||||
z
|
||||
@@ -151,6 +157,10 @@ export const SettingsSchemas = {
|
||||
})
|
||||
.nullable()
|
||||
.default(null),
|
||||
/** Spam check (spec 14 §7): off until the admin turns it on. */
|
||||
spam: SpamSettingsSchema.default(SPAM_DEFAULTS),
|
||||
/** What the system keeps about the spam check's providers (14 §3, §2.2). */
|
||||
'spam.state': SpamStateSchema.default(SpamStateSchema.parse({})),
|
||||
/** Why the directory couldn't be used (12 §4.2, §4.5), for the dashboard; null = fine. */
|
||||
'system.directoryWarning': z.string().nullable().default(null),
|
||||
// Not in 02 §3's table but used by the generator (03 §3).
|
||||
@@ -317,12 +327,13 @@ export type SettingsDto = {
|
||||
[
|
||||
K in Exclude<
|
||||
SettingKey,
|
||||
'smtp' | 'sms.webhookToken' | 'contacts.contrack' | 'auth.oidc' | 'auth.ldap'
|
||||
'smtp' | 'sms.webhookToken' | 'contacts.contrack' | 'auth.oidc' | 'auth.ldap' | 'spam'
|
||||
>
|
||||
]: SettingValue<K>;
|
||||
} & {
|
||||
smtp: (Omit<SmtpInput, 'password'> & { hasPassword: boolean }) | null;
|
||||
'contacts.contrack': { url: string; publicUrl: string | null; hasCompanyToken: boolean } | null;
|
||||
spam: SpamSettingsDto;
|
||||
'auth.oidc':
|
||||
(Omit<OidcInput, 'clientSecret' | 'clearClientSecret'> & { hasClientSecret: boolean }) | null;
|
||||
'auth.ldap':
|
||||
@@ -337,7 +348,8 @@ export type AiTestResult =
|
||||
{ ok: true; ms: number; detail: string; audio?: string } | { ok: false; error: string };
|
||||
|
||||
/** GET /api/system/warnings: shown as the dashboard banner (05 §2). */
|
||||
export type Warning = { code: string; message: string; link: string };
|
||||
/** `dismissable`: POST /api/system/warnings/{code}/dismiss hides it (14 §3). */
|
||||
export type Warning = { code: string; message: string; link: string; dismissable?: boolean };
|
||||
|
||||
/** GET /api/system/backups (04 §8.1). */
|
||||
export type BackupDto = { name: string; size: number; createdAt: string };
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Spam check (spec 14): what a check says about an outside caller, and its settings.
|
||||
import { z } from 'zod';
|
||||
|
||||
export type SpamRisk = 'low' | 'medium' | 'high';
|
||||
export type SpamProvider = 'ipqs' | 'ftc';
|
||||
|
||||
/** IPQualityScore's answer, only the fields 14 §2.1 keeps. */
|
||||
export type IpqsInfo = {
|
||||
fraudScore: number;
|
||||
spammer: boolean;
|
||||
recentAbuse: boolean;
|
||||
lineType: string | null;
|
||||
carrier: string | null;
|
||||
name: string | null;
|
||||
city: string | null;
|
||||
region: string | null;
|
||||
};
|
||||
|
||||
/** The FTC complaints in the window (14 §2.2). */
|
||||
export type FtcInfo = {
|
||||
complaints: number;
|
||||
robocalls: number;
|
||||
lastReportedAt: string | null;
|
||||
subject: string | null;
|
||||
};
|
||||
|
||||
export type SpamCheck =
|
||||
| { risk: SpamRisk; provider: 'ipqs'; info: IpqsInfo }
|
||||
| { risk: SpamRisk; provider: 'ftc'; info: FtcInfo };
|
||||
|
||||
/** The desk phone labels go through phoneSafeName (api), so only what it keeps. */
|
||||
const label = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(20)
|
||||
.regex(/^[\p{L}\p{M}\p{N} .'&@-]+$/u, "Letters, digits, spaces and . ' & @ - only")
|
||||
.transform((s) => s.replace(/ +/g, ' '));
|
||||
|
||||
export const SpamLabelsSchema = z.object({ high: label, medium: label });
|
||||
|
||||
export const SpamCacheSchema = z.object({
|
||||
flaggedDays: z.number().int().min(1).max(365),
|
||||
cleanDays: z.number().int().min(1).max(365),
|
||||
});
|
||||
|
||||
/** The stored `spam` setting (14 §7). Keys encrypted; a null provider isn't used. */
|
||||
export const SpamSettingsSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
ipqs: z.object({ keyEnc: z.string() }).nullable(),
|
||||
ftc: z
|
||||
.object({
|
||||
keyEnc: z.string(),
|
||||
syncHours: z.number().int().min(1).max(168),
|
||||
windowDays: z.number().int().min(7).max(365),
|
||||
})
|
||||
.nullable(),
|
||||
cache: SpamCacheSchema,
|
||||
deskPhoneLabels: SpamLabelsSchema,
|
||||
});
|
||||
export type SpamSettings = z.infer<typeof SpamSettingsSchema>;
|
||||
|
||||
export const SPAM_DEFAULTS: SpamSettings = {
|
||||
enabled: false,
|
||||
ipqs: null,
|
||||
ftc: null,
|
||||
cache: { flaggedDays: 14, cleanDays: 3 },
|
||||
deskPhoneLabels: { high: 'SPAM LIKELY', medium: 'Spam risk' },
|
||||
};
|
||||
|
||||
/** What the system keeps about the providers (`spam.state`, read-only to PATCH). */
|
||||
export const SpamStateSchema = z.object({
|
||||
ipqsPausedAt: z.string().nullable().default(null),
|
||||
warningDismissedAt: z.string().nullable().default(null),
|
||||
/** A sync under way (14 §2.2): the FTC's date (Eastern) being read and how far into it. */
|
||||
ftcCursor: z
|
||||
.object({ day: z.iso.date(), offset: z.number().int().min(0) })
|
||||
.nullable()
|
||||
.default(null),
|
||||
ftcLastSyncAt: z.string().nullable().default(null),
|
||||
ftcLastError: z.string().nullable().default(null),
|
||||
});
|
||||
export type SpamState = z.infer<typeof SpamStateSchema>;
|
||||
|
||||
/** PATCH /api/settings `spam`: keys are write-only (empty keeps the stored one). */
|
||||
export const SpamInputSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
ipqs: z.object({ key: z.string().trim().max(200).optional() }).nullable(),
|
||||
ftc: z
|
||||
.object({
|
||||
key: z.string().trim().max(200).optional(),
|
||||
syncHours: z.number().int().min(1).max(168),
|
||||
windowDays: z.number().int().min(7).max(365),
|
||||
})
|
||||
.nullable(),
|
||||
cache: SpamCacheSchema,
|
||||
deskPhoneLabels: SpamLabelsSchema,
|
||||
});
|
||||
export type SpamInput = z.input<typeof SpamInputSchema>;
|
||||
|
||||
/** GET /api/settings `spam`: keys replaced by hasKey. */
|
||||
export type SpamSettingsDto = Omit<SpamSettings, 'ipqs' | 'ftc'> & {
|
||||
ipqs: { hasKey: boolean } | null;
|
||||
ftc: { hasKey: boolean; syncHours: number; windowDays: number } | null;
|
||||
};
|
||||
|
||||
/** POST /api/settings/spam/{ipqs,ftc}/test. */
|
||||
export type SpamTestResult = { ok: true; detail: string } | { ok: false; error: string };
|
||||
Reference in new issue
Block a user