Feat/spam #5
No files matched your search
@@ -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) };
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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' ? (
|
||||
<>
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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`).
|
||||
|
||||
Reference in new issue
Block a user