Feat/spam #5

Merged
iamdoubz merged 7 commits from feat/spam into main 2026-10-27 18:35:05 -05:00
11 changed files with 295 additions and 13 deletions
Showing only changes of commit e05065599e - Show all commits

No files matched your search

+46 -4
View File
@@ -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) };
},
);
+38
View File
@@ -6,12 +6,14 @@ import { and, eq, gt, isNull, sql, type SQL } from 'drizzle-orm';
import type { FastifyBaseLogger } from 'fastify';
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';
@@ -26,6 +28,15 @@ export type SpamDeps = { db: Db; log: Pick<FastifyBaseLogger, 'warn'> };
/** 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>>();
@@ -72,6 +83,33 @@ export async function checkNumber(
}
}
/**
* 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,
+71 -3
View File
@@ -1,5 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// SD1, SD2: the spam check (14): IPQualityScore first, then the local FTC copy, against a fake fetch.
// SD1, SD2, SD4: the spam check (14): IPQualityScore first, then the local FTC copy, against a fake fetch.
import { eq, like, sql } from 'drizzle-orm';
import type { LightMyRequestResponse } from 'fastify';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -11,7 +11,10 @@ import {
settings as settingsTable,
spamLookups,
systemEvents,
users,
} from '../src/db/schema.ts';
import type { ActiveCall } from '@selfphoneng/shared';
import { callerNameToken } from '../src/contacts/callername.ts';
import { checkNumber, spamQuotaCheck } from '../src/spam/check.ts';
import { getSetting, setSetting } from '../src/services/settings.ts';
import { hasTestDb } from './db.ts';
@@ -37,7 +40,7 @@ describe.skipIf(!hasTestDb)('spam check', () => {
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' = 'ok';
let ipqs: 'ok' | 'down' | 'exhausted' | 'slow' | 'late' = 'ok';
let credits = 912;
const log = { warn: vi.fn() };
const d = () => ({ db: t.db, log });
@@ -102,7 +105,7 @@ describe.skipIf(!hasTestDb)('spam check', () => {
success: false,
message: 'You have insufficient credits to make this query.',
});
await new Promise((r) => setTimeout(r, 20));
await new Promise((r) => setTimeout(r, ipqs === 'late' ? 1500 : 20));
return json({ success: true, valid: true, ...(ANSWERS[phone[1]!] ?? { fraud_score: 0 }) });
});
});
@@ -303,6 +306,71 @@ describe.skipIf(!hasTestDb)('spam check', () => {
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');
});
});
describe('Settings → Spam check', () => {
const body = (s: Record<string, unknown> = {}) => ({
spam: {
+7
View File
@@ -3,6 +3,7 @@
import type {
LdapInput,
LdapTestResult,
SpamCheck,
SpamTestResult,
AuthMethods,
OidcInput,
@@ -306,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 }),
+32
View File
@@ -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 [^:]+$/);
});
+50
View File
@@ -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>
);
}
+17
View File
@@ -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' ? (
<>
+1
View File
@@ -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 |
+1 -1
View File
@@ -9,7 +9,7 @@ Legend: 📖 read · 🔨 build · ✅ done when
---
## Backlog (owner's order, 2026-10-21)
0. **Spam check** (owner, 2026-10-27: SD1–SD3 done; SD4 next), tasks SD1–SD5
0. **Spam check** (owner, 2026-10-27: SD1–SD4 done; SD5 next), 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):
+2
View File
@@ -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
+30 -5
View File
@@ -111,9 +111,9 @@ list until they come back." (or "…are off until they come back." without the F
## 5. Where checks happen
Everything goes through `spam/check.ts` `checkNumber(number, { wait })` → `{ risk, provider,
info } | null`. It runs the §1 rules, then the cache, then the providers (§2). It doesn't do the
contacts test: each caller of it does that for its own user.
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
@@ -123,7 +123,6 @@ contacts test: each caller of it does that for its own user.
- 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.
`// TODO(spec): live state must keep the caller's number per ringing channel; check in SD4.`
- 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):
@@ -236,7 +235,7 @@ log masks it. Validated with zod (`packages/shared`).
| 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 |
| 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 |
### 9.1 As built: SD1
@@ -314,6 +313,32 @@ log masks it. Validated with zod (`packages/shared`).
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.
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`).