feat: add SpamShield router for spam detection and call analysis
- Create tRPC router with checkNumber, classifySMS, classifyCall endpoints - Add protected procedures for rule CRUD, feedback submission, and stats - Implement service layer with phone number normalization and audit logging - Add ML engine with BERT stub, feature extraction, and rule engine - Implement reputation API module with circuit breaker and caching - Write comprehensive tests (34 tests) for all layers - Wire spamshield router into app root router
This commit is contained in:
@@ -4,6 +4,7 @@ import { billingRouter } from "./routers/billing";
|
||||
import { notificationRouter } from "./routers/notification";
|
||||
import { darkwatchRouter } from "./routers/darkwatch";
|
||||
import { voiceprintRouter } from "./routers/voiceprint";
|
||||
import { spamshieldRouter } from "./routers/spamshield";
|
||||
import { createTRPCRouter } from "./utils";
|
||||
|
||||
export const appRouter = createTRPCRouter({
|
||||
@@ -13,6 +14,7 @@ export const appRouter = createTRPCRouter({
|
||||
notification: notificationRouter,
|
||||
darkwatch: darkwatchRouter,
|
||||
voiceprint: voiceprintRouter,
|
||||
spamshield: spamshieldRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
218
web/src/server/api/routers/spamshield.test.ts
Normal file
218
web/src/server/api/routers/spamshield.test.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { initTRPC, TRPCError } from "@trpc/server";
|
||||
import { wrap } from "@typeschema/valibot";
|
||||
import {
|
||||
CheckNumberSchema,
|
||||
ClassifySMSSchema,
|
||||
ClassifyCallSchema,
|
||||
CreateRuleSchema,
|
||||
DeleteRuleSchema,
|
||||
FeedbackSchema,
|
||||
StatsFilterSchema,
|
||||
} from "../schemas/spamshield";
|
||||
|
||||
vi.mock("~/server/services/spamshield.service", () => ({
|
||||
checkNumberReputation: vi.fn(),
|
||||
classifySMS: vi.fn(),
|
||||
classifyCall: vi.fn(),
|
||||
getRules: vi.fn(),
|
||||
createRule: vi.fn(),
|
||||
deleteRule: vi.fn(),
|
||||
submitFeedback: vi.fn(),
|
||||
getStats: vi.fn(),
|
||||
}));
|
||||
|
||||
import * as spamshieldService from "~/server/services/spamshield.service";
|
||||
|
||||
const mockCheckNumber = vi.mocked(spamshieldService.checkNumberReputation);
|
||||
const mockClassifySMS = vi.mocked(spamshieldService.classifySMS);
|
||||
const mockClassifyCall = vi.mocked(spamshieldService.classifyCall);
|
||||
const mockGetRules = vi.mocked(spamshieldService.getRules);
|
||||
const mockCreateRule = vi.mocked(spamshieldService.createRule);
|
||||
const mockDeleteRule = vi.mocked(spamshieldService.deleteRule);
|
||||
const mockSubmitFeedback = vi.mocked(spamshieldService.submitFeedback);
|
||||
const mockGetStats = vi.mocked(spamshieldService.getStats);
|
||||
|
||||
type User = {
|
||||
id: string; email: string; name: string | null; image: string | null;
|
||||
role: string; emailVerified: Date | null; deletedAt: Date | null;
|
||||
stripeCustomerId: string | null;
|
||||
createdAt: Date; updatedAt: Date;
|
||||
};
|
||||
type Ctx = { db: object; user: User | null; apiKey: string | null };
|
||||
|
||||
function createCaller(user: User | null) {
|
||||
const t = initTRPC.context<Ctx>().create();
|
||||
const isAuthed = t.middleware(({ ctx, next }) => {
|
||||
if (!ctx.user) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
return next({ ctx: { ...ctx, user: ctx.user } });
|
||||
});
|
||||
|
||||
const router = t.router({
|
||||
checkNumber: t.procedure
|
||||
.input(wrap(CheckNumberSchema))
|
||||
.query(async ({ input }) => {
|
||||
return mockCheckNumber(input.phoneNumber);
|
||||
}),
|
||||
classifySMS: t.procedure
|
||||
.input(wrap(ClassifySMSSchema))
|
||||
.query(async ({ input }) => {
|
||||
return mockClassifySMS(input.text);
|
||||
}),
|
||||
classifyCall: t.procedure
|
||||
.input(wrap(ClassifyCallSchema))
|
||||
.query(async ({ input }) => {
|
||||
return mockClassifyCall(input.callerNumber, input.duration, input.timeOfDay);
|
||||
}),
|
||||
getRules: t.procedure.use(isAuthed).query(async ({ ctx }) => {
|
||||
return mockGetRules(ctx.user.id);
|
||||
}),
|
||||
createRule: t.procedure.use(isAuthed)
|
||||
.input(wrap(CreateRuleSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return mockCreateRule(ctx.user.id, input.ruleType, input.pattern, input.action, input.priority);
|
||||
}),
|
||||
deleteRule: t.procedure.use(isAuthed)
|
||||
.input(wrap(DeleteRuleSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return mockDeleteRule(ctx.user.id, input.ruleId);
|
||||
}),
|
||||
submitFeedback: t.procedure.use(isAuthed)
|
||||
.input(wrap(FeedbackSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return mockSubmitFeedback(ctx.user.id, input.phoneNumber, input.isSpam, input.feedbackType);
|
||||
}),
|
||||
getStats: t.procedure.use(isAuthed)
|
||||
.input(wrap(StatsFilterSchema))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return mockGetStats(ctx.user.id, input.period);
|
||||
}),
|
||||
});
|
||||
|
||||
const caller = t.createCallerFactory(router);
|
||||
return caller({ db: {} as never, user, apiKey: null });
|
||||
}
|
||||
|
||||
const baseUser: User = {
|
||||
id: "user-1", email: "a@b.com", name: "Test", image: null,
|
||||
role: "user", emailVerified: null, deletedAt: null,
|
||||
stripeCustomerId: null,
|
||||
createdAt: new Date(), updatedAt: new Date(),
|
||||
};
|
||||
|
||||
function makeUser(overrides: Partial<User> = {}): User {
|
||||
return { ...baseUser, ...overrides };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("spamshield.checkNumber", () => {
|
||||
it("returns reputation for a phone number", async () => {
|
||||
const rep = { phoneNumber: "+1234567890", isSpam: false, confidence: 0.95, source: "internal", score: 0, category: "unknown" };
|
||||
mockCheckNumber.mockResolvedValue(rep);
|
||||
const api = createCaller(null);
|
||||
const result = await api.checkNumber({ phoneNumber: "+1234567890" });
|
||||
expect(result).toEqual(rep);
|
||||
});
|
||||
|
||||
it("normalizes phone number", async () => {
|
||||
mockCheckNumber.mockResolvedValue({ phoneNumber: "+1234567890", isSpam: false, confidence: 1, source: "internal", score: 0, category: "unknown" });
|
||||
const api = createCaller(null);
|
||||
await api.checkNumber({ phoneNumber: "234567890" });
|
||||
expect(mockCheckNumber).toHaveBeenCalledWith("234567890");
|
||||
});
|
||||
});
|
||||
|
||||
describe("spamshield.classifySMS", () => {
|
||||
it("classifies SMS text", async () => {
|
||||
const result = { isSpam: false, confidence: 1.0, score: 0.0 };
|
||||
mockClassifySMS.mockResolvedValue(result);
|
||||
const api = createCaller(null);
|
||||
const res = await api.classifySMS({ text: "Hello friend" });
|
||||
expect(res.isSpam).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("spamshield.classifyCall", () => {
|
||||
it("classifies call metadata", async () => {
|
||||
const result = { isSpam: false, confidence: 0.5, callerNumber: "+1234567890", matchedRule: null, reputation: null, features: { areaCode: "+12", duration: 30, timeOfDay: 14 } };
|
||||
mockClassifyCall.mockResolvedValue(result);
|
||||
const api = createCaller(null);
|
||||
const res = await api.classifyCall({ callerNumber: "+1234567890", duration: 30, timeOfDay: 14 });
|
||||
expect(res.isSpam).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("spamshield.getRules", () => {
|
||||
it("returns rules for authenticated user", async () => {
|
||||
const rules = { userRules: [], globalRules: [] };
|
||||
mockGetRules.mockResolvedValue(rules);
|
||||
const api = createCaller(makeUser());
|
||||
expect(await api.getRules()).toEqual(rules);
|
||||
});
|
||||
|
||||
it("rejects unauthenticated", async () => {
|
||||
const api = createCaller(null);
|
||||
await expect(api.getRules()).rejects.toThrow(TRPCError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("spamshield.createRule", () => {
|
||||
it("creates a custom rule", async () => {
|
||||
const rule = { id: "r1", ruleType: "prefix" as const, pattern: "+123", action: "block" as const, priority: 0, userId: "user-1", isActive: true, isGlobal: false, createdAt: new Date(), updatedAt: new Date() };
|
||||
mockCreateRule.mockResolvedValue(rule);
|
||||
const api = createCaller(makeUser());
|
||||
const result = await api.createRule({ ruleType: "prefix", pattern: "+123", action: "block" });
|
||||
expect(result.id).toBe("r1");
|
||||
});
|
||||
|
||||
it("rejects invalid rule type", async () => {
|
||||
const api = createCaller(makeUser());
|
||||
await expect(
|
||||
api.createRule({ ruleType: "invalid" as never, pattern: "test", action: "block" }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("spamshield.deleteRule", () => {
|
||||
it("deletes a rule", async () => {
|
||||
mockDeleteRule.mockResolvedValue({ id: "r1", isActive: false } as never);
|
||||
const api = createCaller(makeUser());
|
||||
const result = await api.deleteRule({ ruleId: "r1" });
|
||||
expect(result.isActive).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("spamshield.submitFeedback", () => {
|
||||
it("submits feedback", async () => {
|
||||
const fb = { id: "fb1", phoneNumber: "+1234567890", isSpam: true, feedbackType: "user_confirmation" };
|
||||
mockSubmitFeedback.mockResolvedValue(fb as never);
|
||||
const api = createCaller(makeUser());
|
||||
const result = await api.submitFeedback({ phoneNumber: "+1234567890", isSpam: true, feedbackType: "user_confirmation" });
|
||||
expect(result.isSpam).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects invalid feedback type", async () => {
|
||||
const api = createCaller(makeUser());
|
||||
await expect(
|
||||
api.submitFeedback({ phoneNumber: "+1234567890", isSpam: true, feedbackType: "invalid" as never }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("spamshield.getStats", () => {
|
||||
it("returns stats for authenticated user", async () => {
|
||||
const stats = { period: "month", totalDetections: 10, spamCount: 5, notSpamCount: 5, accuracy: 50, activeRules: 2 };
|
||||
mockGetStats.mockResolvedValue(stats);
|
||||
const api = createCaller(makeUser());
|
||||
const result = await api.getStats({ period: "month" });
|
||||
expect(result.accuracy).toBe(50);
|
||||
});
|
||||
|
||||
it("rejects unauthenticated", async () => {
|
||||
const api = createCaller(null);
|
||||
await expect(api.getStats({ period: "month" })).rejects.toThrow(TRPCError);
|
||||
});
|
||||
});
|
||||
75
web/src/server/api/routers/spamshield.ts
Normal file
75
web/src/server/api/routers/spamshield.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { wrap } from "@typeschema/valibot";
|
||||
import { createTRPCRouter, publicProcedure, protectedProcedure } from "../utils";
|
||||
import {
|
||||
CheckNumberSchema,
|
||||
ClassifySMSSchema,
|
||||
ClassifyCallSchema,
|
||||
CreateRuleSchema,
|
||||
DeleteRuleSchema,
|
||||
FeedbackSchema,
|
||||
StatsFilterSchema,
|
||||
} from "../schemas/spamshield";
|
||||
import * as spamshieldService from "~/server/services/spamshield.service";
|
||||
|
||||
export const spamshieldRouter = createTRPCRouter({
|
||||
checkNumber: publicProcedure
|
||||
.input(wrap(CheckNumberSchema))
|
||||
.query(async ({ input }) => {
|
||||
return spamshieldService.checkNumberReputation(input.phoneNumber);
|
||||
}),
|
||||
|
||||
classifySMS: publicProcedure
|
||||
.input(wrap(ClassifySMSSchema))
|
||||
.query(async ({ input }) => {
|
||||
return spamshieldService.classifySMS(input.text);
|
||||
}),
|
||||
|
||||
classifyCall: publicProcedure
|
||||
.input(wrap(ClassifyCallSchema))
|
||||
.query(async ({ input }) => {
|
||||
return spamshieldService.classifyCall(
|
||||
input.callerNumber,
|
||||
input.duration,
|
||||
input.timeOfDay,
|
||||
);
|
||||
}),
|
||||
|
||||
getRules: protectedProcedure.query(async ({ ctx }) => {
|
||||
return spamshieldService.getRules(ctx.user.id);
|
||||
}),
|
||||
|
||||
createRule: protectedProcedure
|
||||
.input(wrap(CreateRuleSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return spamshieldService.createRule(
|
||||
ctx.user.id,
|
||||
input.ruleType,
|
||||
input.pattern,
|
||||
input.action,
|
||||
input.priority,
|
||||
);
|
||||
}),
|
||||
|
||||
deleteRule: protectedProcedure
|
||||
.input(wrap(DeleteRuleSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return spamshieldService.deleteRule(ctx.user.id, input.ruleId);
|
||||
}),
|
||||
|
||||
submitFeedback: protectedProcedure
|
||||
.input(wrap(FeedbackSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return spamshieldService.submitFeedback(
|
||||
ctx.user.id,
|
||||
input.phoneNumber,
|
||||
input.isSpam,
|
||||
input.feedbackType,
|
||||
);
|
||||
}),
|
||||
|
||||
getStats: protectedProcedure
|
||||
.input(wrap(StatsFilterSchema))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return spamshieldService.getStats(ctx.user.id, input.period);
|
||||
}),
|
||||
});
|
||||
36
web/src/server/api/schemas/spamshield.ts
Normal file
36
web/src/server/api/schemas/spamshield.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { object, string, minLength, optional, number, boolean as vBoolean, picklist } from "valibot";
|
||||
|
||||
export const CheckNumberSchema = object({
|
||||
phoneNumber: string([minLength(1)]),
|
||||
});
|
||||
|
||||
export const ClassifySMSSchema = object({
|
||||
text: string([minLength(1)]),
|
||||
});
|
||||
|
||||
export const ClassifyCallSchema = object({
|
||||
callerNumber: string([minLength(1)]),
|
||||
duration: optional(number()),
|
||||
timeOfDay: optional(number()),
|
||||
});
|
||||
|
||||
export const CreateRuleSchema = object({
|
||||
ruleType: picklist(["phoneNumber", "areaCode", "prefix", "pattern", "reputation"]),
|
||||
pattern: string([minLength(1)]),
|
||||
action: picklist(["block", "flag", "allow", "challenge"]),
|
||||
priority: optional(number(), 0),
|
||||
});
|
||||
|
||||
export const DeleteRuleSchema = object({
|
||||
ruleId: string([minLength(1)]),
|
||||
});
|
||||
|
||||
export const FeedbackSchema = object({
|
||||
phoneNumber: string([minLength(1)]),
|
||||
isSpam: vBoolean(),
|
||||
feedbackType: picklist(["initial_detection", "user_confirmation", "user_rejection", "auto_learned"]),
|
||||
});
|
||||
|
||||
export const StatsFilterSchema = object({
|
||||
period: optional(picklist(["day", "week", "month", "year"]), "month"),
|
||||
});
|
||||
151
web/src/server/services/spamshield.service.test.ts
Normal file
151
web/src/server/services/spamshield.service.test.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const mockSelectFromWhereOrderBy = vi.fn();
|
||||
|
||||
vi.mock("~/server/db", () => ({
|
||||
db: {
|
||||
select: vi.fn(() => ({
|
||||
from: vi.fn(() => ({
|
||||
where: vi.fn(() => ({
|
||||
orderBy: mockSelectFromWhereOrderBy,
|
||||
limit: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
orderBy: vi.fn(() => ({
|
||||
limit: vi.fn(() => ({
|
||||
offset: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
insert: vi.fn(() => ({
|
||||
values: vi.fn(() => ({
|
||||
returning: vi.fn().mockResolvedValue([{ id: "new-id" }]),
|
||||
})),
|
||||
})),
|
||||
update: vi.fn(() => ({
|
||||
set: vi.fn(() => ({
|
||||
where: vi.fn(() => ({
|
||||
returning: vi.fn().mockResolvedValue([{ id: "updated-id" }]),
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("~/server/db/schema", () => ({
|
||||
spamFeedback: {},
|
||||
spamRules: {},
|
||||
auditLogs: {},
|
||||
}));
|
||||
|
||||
vi.mock("./spamshield/ml.engine", () => ({
|
||||
classifyTextBERT: vi.fn(),
|
||||
extractFeatures: vi.fn(),
|
||||
ruleEngine: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./spamshield/reputation.api", () => ({
|
||||
checkReputation: vi.fn(),
|
||||
lookupInternalDB: vi.fn(),
|
||||
lookupHiya: vi.fn(),
|
||||
lookupTruecaller: vi.fn(),
|
||||
cacheReputation: vi.fn(),
|
||||
}));
|
||||
|
||||
import * as spamshieldService from "./spamshield.service";
|
||||
import { classifyTextBERT, extractFeatures, ruleEngine } from "./spamshield/ml.engine";
|
||||
import { checkReputation } from "./spamshield/reputation.api";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("spamshield.checkNumberReputation", () => {
|
||||
it("normalizes phone number before lookup", async () => {
|
||||
vi.mocked(checkReputation).mockResolvedValue(null);
|
||||
const result = await spamshieldService.checkNumberReputation("234567890");
|
||||
expect(result.phoneNumber).toBe("+1234567890");
|
||||
});
|
||||
|
||||
it("returns reputation when found", async () => {
|
||||
vi.mocked(checkReputation).mockResolvedValue({
|
||||
source: "hiya",
|
||||
score: 0.9,
|
||||
isSpam: true,
|
||||
confidence: 0.85,
|
||||
category: "spam",
|
||||
});
|
||||
const result = await spamshieldService.checkNumberReputation("+1234567890");
|
||||
expect(result.isSpam).toBe(true);
|
||||
expect(result.source).toBe("hiya");
|
||||
});
|
||||
|
||||
it("returns default when reputation not found", async () => {
|
||||
vi.mocked(checkReputation).mockResolvedValue(null);
|
||||
const result = await spamshieldService.checkNumberReputation("+1234567890");
|
||||
expect(result.isSpam).toBe(false);
|
||||
expect(result.source).toBe("internal");
|
||||
});
|
||||
});
|
||||
|
||||
describe("spamshield.classifySMS", () => {
|
||||
it("classifies text as ham", async () => {
|
||||
vi.mocked(classifyTextBERT).mockResolvedValue({ isSpam: false, confidence: 1.0, score: 0.0 });
|
||||
const result = await spamshieldService.classifySMS("Hello friend");
|
||||
expect(result.isSpam).toBe(false);
|
||||
});
|
||||
|
||||
it("classifies text as spam", async () => {
|
||||
vi.mocked(classifyTextBERT).mockResolvedValue({ isSpam: true, confidence: 0.95, score: 0.95 });
|
||||
const result = await spamshieldService.classifySMS("Win free money now");
|
||||
expect(result.isSpam).toBe(true);
|
||||
expect(result.confidence).toBe(0.95);
|
||||
});
|
||||
});
|
||||
|
||||
describe("spamshield.classifyCall", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(extractFeatures).mockResolvedValue({
|
||||
callerNumber: "+1234567890",
|
||||
areaCode: "+12",
|
||||
prefix: "+12345",
|
||||
duration: 30,
|
||||
timeOfDay: 14,
|
||||
});
|
||||
vi.mocked(ruleEngine).mockResolvedValue(null);
|
||||
vi.mocked(checkReputation).mockResolvedValue(null);
|
||||
mockSelectFromWhereOrderBy.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("analyzes call metadata", async () => {
|
||||
const result = await spamshieldService.classifyCall("+1234567890", 30, 14);
|
||||
expect(result.callerNumber).toBe("+1234567890");
|
||||
});
|
||||
|
||||
it("flags blocked numbers", async () => {
|
||||
vi.mocked(ruleEngine).mockResolvedValue({
|
||||
matched: true,
|
||||
action: "block",
|
||||
ruleId: "r1",
|
||||
ruleType: "phoneNumber",
|
||||
});
|
||||
const result = await spamshieldService.classifyCall("+1234567890");
|
||||
expect(result.isSpam).toBe(true);
|
||||
expect(result.matchedRule?.action).toBe("block");
|
||||
});
|
||||
});
|
||||
|
||||
describe("spamshield.createRule", () => {
|
||||
it("creates rule in database", async () => {
|
||||
const fakeRule = { id: "r1", userId: "user-1", ruleType: "prefix", pattern: "+123", action: "block", priority: 0, isActive: true, isGlobal: false, createdAt: new Date(), updatedAt: new Date() };
|
||||
|
||||
const mockDb = await import("~/server/db");
|
||||
vi.mocked(mockDb.db.insert).mockReturnValue({
|
||||
values: vi.fn().mockReturnValue({ returning: vi.fn().mockResolvedValue([fakeRule]) }),
|
||||
} as never);
|
||||
|
||||
const result = await spamshieldService.createRule("user-1", "prefix", "+123", "block", 0);
|
||||
expect(result.id).toBe("r1");
|
||||
expect(result.pattern).toBe("+123");
|
||||
});
|
||||
});
|
||||
294
web/src/server/services/spamshield.service.ts
Normal file
294
web/src/server/services/spamshield.service.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { eq, and, desc, count, sql, gte } from "drizzle-orm";
|
||||
import { db } from "~/server/db";
|
||||
import { spamFeedback, spamRules, auditLogs } from "~/server/db/schema";
|
||||
import { classifyTextBERT, extractFeatures, ruleEngine } from "./spamshield/ml.engine";
|
||||
import { checkReputation } from "./spamshield/reputation.api";
|
||||
import type { ReputationResult } from "./spamshield/reputation.api";
|
||||
|
||||
function normalizePhoneNumber(phone: string): string {
|
||||
let cleaned = phone.replace(/[^\d+]/g, "");
|
||||
if (!cleaned.startsWith("+")) {
|
||||
if (cleaned.startsWith("1")) {
|
||||
cleaned = `+${cleaned}`;
|
||||
} else {
|
||||
cleaned = `+1${cleaned}`;
|
||||
}
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
async function logAudit(
|
||||
userId: string | undefined,
|
||||
action: string,
|
||||
input: unknown,
|
||||
output: unknown,
|
||||
confidence?: number,
|
||||
) {
|
||||
try {
|
||||
await db.insert(auditLogs).values({
|
||||
userId,
|
||||
action: `spamshield.${action}`,
|
||||
resource: "spamshield",
|
||||
metadata: {
|
||||
input,
|
||||
output,
|
||||
confidence,
|
||||
modelVersion: "1.0",
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[spamshield] Failed to write audit log:", err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkNumberReputation(phoneNumber: string) {
|
||||
const normalized = normalizePhoneNumber(phoneNumber);
|
||||
|
||||
let result: {
|
||||
source: string;
|
||||
score: number;
|
||||
isSpam: boolean;
|
||||
confidence: number;
|
||||
category: string;
|
||||
} | null = null;
|
||||
|
||||
if (normalized.length >= 10) {
|
||||
result = await checkReputation(normalized);
|
||||
}
|
||||
|
||||
const response = {
|
||||
phoneNumber: normalized,
|
||||
isSpam: result?.isSpam ?? false,
|
||||
confidence: result?.confidence ?? 0,
|
||||
source: result?.source ?? "internal",
|
||||
score: result?.score ?? 0,
|
||||
category: result?.category ?? "unknown",
|
||||
};
|
||||
|
||||
await logAudit(undefined, "checkNumber", { phoneNumber: normalized }, response, response.confidence);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function classifySMS(text: string) {
|
||||
const classification = await classifyTextBERT(text);
|
||||
|
||||
const response = {
|
||||
isSpam: classification.isSpam,
|
||||
confidence: classification.confidence,
|
||||
score: classification.score,
|
||||
};
|
||||
|
||||
await logAudit(undefined, "classifySMS", { textLength: text.length }, response, response.confidence);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function classifyCall(
|
||||
callerNumber: string,
|
||||
duration?: number,
|
||||
timeOfDay?: number,
|
||||
) {
|
||||
const normalized = normalizePhoneNumber(callerNumber);
|
||||
const features = await extractFeatures({ callerNumber: normalized, duration, timeOfDay });
|
||||
|
||||
const rules = await db
|
||||
.select()
|
||||
.from(spamRules)
|
||||
.where(and(eq(spamRules.isActive, true), eq(spamRules.isGlobal, true)))
|
||||
.orderBy(desc(spamRules.priority));
|
||||
|
||||
const ruleMatch = await ruleEngine(
|
||||
rules.map((r) => ({
|
||||
id: r.id,
|
||||
ruleType: r.ruleType,
|
||||
pattern: r.pattern,
|
||||
action: r.action as "block" | "flag" | "allow" | "challenge",
|
||||
priority: r.priority,
|
||||
})),
|
||||
{ phoneNumber: normalized },
|
||||
);
|
||||
|
||||
const reputation = await checkReputation(normalized);
|
||||
|
||||
const isSpam = ruleMatch?.action === "block" || (reputation?.isSpam ?? false);
|
||||
const confidence = Math.max(
|
||||
ruleMatch ? 0.9 : 0,
|
||||
reputation?.confidence ?? 0,
|
||||
);
|
||||
|
||||
const response = {
|
||||
isSpam,
|
||||
confidence,
|
||||
callerNumber: normalized,
|
||||
matchedRule: ruleMatch
|
||||
? { ruleId: ruleMatch.ruleId, action: ruleMatch.action, ruleType: ruleMatch.ruleType }
|
||||
: null,
|
||||
reputation: reputation
|
||||
? { source: reputation.source, score: reputation.score, category: reputation.category }
|
||||
: null,
|
||||
features: {
|
||||
areaCode: features.areaCode,
|
||||
duration: features.duration,
|
||||
timeOfDay: features.timeOfDay,
|
||||
},
|
||||
};
|
||||
|
||||
await logAudit(undefined, "classifyCall", { callerNumber: normalized, duration, timeOfDay }, response, confidence);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function getRules(userId: string) {
|
||||
const userRules = await db
|
||||
.select()
|
||||
.from(spamRules)
|
||||
.where(and(eq(spamRules.userId, userId), eq(spamRules.isActive, true)))
|
||||
.orderBy(desc(spamRules.priority));
|
||||
|
||||
const globalRules = await db
|
||||
.select()
|
||||
.from(spamRules)
|
||||
.where(and(eq(spamRules.isGlobal, true), eq(spamRules.isActive, true)))
|
||||
.orderBy(desc(spamRules.priority));
|
||||
|
||||
return { userRules, globalRules };
|
||||
}
|
||||
|
||||
export async function createRule(
|
||||
userId: string,
|
||||
ruleType: string,
|
||||
pattern: string,
|
||||
action: string,
|
||||
priority: number,
|
||||
) {
|
||||
const [rule] = await db
|
||||
.insert(spamRules)
|
||||
.values({
|
||||
userId,
|
||||
ruleType: ruleType as "phoneNumber" | "areaCode" | "prefix" | "pattern" | "reputation",
|
||||
pattern,
|
||||
action: action as "block" | "flag" | "allow" | "challenge",
|
||||
priority,
|
||||
})
|
||||
.returning();
|
||||
|
||||
await logAudit(userId, "createRule", { ruleType, pattern, action, priority }, rule);
|
||||
|
||||
return rule;
|
||||
}
|
||||
|
||||
export async function deleteRule(userId: string, ruleId: string) {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(spamRules)
|
||||
.where(and(eq(spamRules.id, ruleId), eq(spamRules.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (!existing) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Rule not found" });
|
||||
}
|
||||
|
||||
const [deleted] = await db
|
||||
.update(spamRules)
|
||||
.set({ isActive: false, updatedAt: new Date() })
|
||||
.where(eq(spamRules.id, ruleId))
|
||||
.returning();
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
export async function submitFeedback(
|
||||
userId: string,
|
||||
phoneNumber: string,
|
||||
isSpam: boolean,
|
||||
feedbackType: string,
|
||||
) {
|
||||
const normalized = normalizePhoneNumber(phoneNumber);
|
||||
const { createHash } = await import("node:crypto");
|
||||
const phoneNumberHash = createHash("sha256").update(normalized).digest("hex");
|
||||
|
||||
const [feedback] = await db
|
||||
.insert(spamFeedback)
|
||||
.values({
|
||||
userId,
|
||||
phoneNumber: normalized,
|
||||
phoneNumberHash,
|
||||
isSpam,
|
||||
feedbackType: feedbackType as "initial_detection" | "user_confirmation" | "user_rejection" | "auto_learned",
|
||||
})
|
||||
.returning();
|
||||
|
||||
await logAudit(userId, "submitFeedback", { phoneNumber: normalized, isSpam, feedbackType }, feedback);
|
||||
|
||||
return feedback;
|
||||
}
|
||||
|
||||
export async function getStats(userId: string, period: string = "month") {
|
||||
const now = new Date();
|
||||
let since: Date;
|
||||
|
||||
switch (period) {
|
||||
case "day":
|
||||
since = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
break;
|
||||
case "week":
|
||||
since = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
break;
|
||||
case "year":
|
||||
since = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate());
|
||||
break;
|
||||
case "month":
|
||||
default:
|
||||
since = new Date(now.getFullYear(), now.getMonth() - 1, now.getDate());
|
||||
break;
|
||||
}
|
||||
|
||||
const [totalResult] = await db
|
||||
.select({ count: count() })
|
||||
.from(spamFeedback)
|
||||
.where(and(eq(spamFeedback.userId, userId), gte(spamFeedback.createdAt, since)));
|
||||
|
||||
const [spamResult] = await db
|
||||
.select({ count: count() })
|
||||
.from(spamFeedback)
|
||||
.where(
|
||||
and(
|
||||
eq(spamFeedback.userId, userId),
|
||||
eq(spamFeedback.isSpam, true),
|
||||
gte(spamFeedback.createdAt, since),
|
||||
),
|
||||
);
|
||||
|
||||
const [notSpamResult] = await db
|
||||
.select({ count: count() })
|
||||
.from(spamFeedback)
|
||||
.where(
|
||||
and(
|
||||
eq(spamFeedback.userId, userId),
|
||||
eq(spamFeedback.isSpam, false),
|
||||
gte(spamFeedback.createdAt, since),
|
||||
),
|
||||
);
|
||||
|
||||
const rulesCount = await db
|
||||
.select({ count: count() })
|
||||
.from(spamRules)
|
||||
.where(and(eq(spamRules.userId, userId), eq(spamRules.isActive, true)));
|
||||
|
||||
const total = totalResult.count;
|
||||
const spam = spamResult.count;
|
||||
const notSpam = notSpamResult.count;
|
||||
const accuracy = total > 0 ? (spam / total) * 100 : 0;
|
||||
|
||||
return {
|
||||
period,
|
||||
totalDetections: total,
|
||||
spamCount: spam,
|
||||
notSpamCount: notSpam,
|
||||
accuracy: Math.round(accuracy * 100) / 100,
|
||||
activeRules: rulesCount[0].count,
|
||||
};
|
||||
}
|
||||
75
web/src/server/services/spamshield/ml.engine.test.ts
Normal file
75
web/src/server/services/spamshield/ml.engine.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { classifyTextBERT, extractFeatures, ruleEngine } from "./ml.engine";
|
||||
|
||||
describe("classifyTextBERT", () => {
|
||||
it("returns default classification", async () => {
|
||||
const result = await classifyTextBERT("Hello world");
|
||||
expect(result.isSpam).toBe(false);
|
||||
expect(result.confidence).toBe(1.0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractFeatures", () => {
|
||||
it("extracts area code from caller number", async () => {
|
||||
const features = await extractFeatures({ callerNumber: "+14155551234" });
|
||||
expect(features.areaCode).toBe("+14");
|
||||
});
|
||||
|
||||
it("uses provided duration and timeOfDay", async () => {
|
||||
const features = await extractFeatures({ callerNumber: "+1234567890", duration: 120, timeOfDay: 9 });
|
||||
expect(features.duration).toBe(120);
|
||||
expect(features.timeOfDay).toBe(9);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ruleEngine", () => {
|
||||
it("matches phone number rule", async () => {
|
||||
const rules = [
|
||||
{ id: "r1", ruleType: "phoneNumber", pattern: "+1234567890", action: "block" as const, priority: 10 },
|
||||
];
|
||||
const result = await ruleEngine(rules, { phoneNumber: "+1234567890" });
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.action).toBe("block");
|
||||
});
|
||||
|
||||
it("matches area code rule", async () => {
|
||||
const rules = [
|
||||
{ id: "r2", ruleType: "areaCode" as const, pattern: "+12", action: "flag" as const, priority: 5 },
|
||||
];
|
||||
const result = await ruleEngine(rules, { phoneNumber: "+1234567890" });
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.action).toBe("flag");
|
||||
});
|
||||
|
||||
it("matches prefix rule", async () => {
|
||||
const rules = [
|
||||
{ id: "r3", ruleType: "prefix" as const, pattern: "+12345", action: "challenge" as const, priority: 1 },
|
||||
];
|
||||
const result = await ruleEngine(rules, { phoneNumber: "+1234567890" });
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.action).toBe("challenge");
|
||||
});
|
||||
|
||||
it("matches pattern rule against text", async () => {
|
||||
const rules = [
|
||||
{ id: "r4", ruleType: "pattern" as const, pattern: "free money", action: "block" as const, priority: 10 },
|
||||
];
|
||||
const result = await ruleEngine(rules, { phoneNumber: "+1234567890", text: "Get free money now" });
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.action).toBe("block");
|
||||
});
|
||||
|
||||
it("respects priority ordering", async () => {
|
||||
const rules = [
|
||||
{ id: "r1", ruleType: "prefix" as const, pattern: "+12", action: "allow" as const, priority: 1 },
|
||||
{ id: "r2", ruleType: "phoneNumber" as const, pattern: "+1234567890", action: "block" as const, priority: 10 },
|
||||
];
|
||||
const result = await ruleEngine(rules, { phoneNumber: "+1234567890" });
|
||||
expect(result!.ruleId).toBe("r2");
|
||||
});
|
||||
|
||||
it("returns null when no rules match", async () => {
|
||||
const result = await ruleEngine([], { phoneNumber: "+1234567890" });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
97
web/src/server/services/spamshield/ml.engine.ts
Normal file
97
web/src/server/services/spamshield/ml.engine.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
export interface TextClassification {
|
||||
isSpam: boolean;
|
||||
confidence: number;
|
||||
score: number;
|
||||
}
|
||||
|
||||
export interface CallFeatures {
|
||||
callerNumber: string;
|
||||
areaCode: string;
|
||||
prefix: string;
|
||||
duration: number;
|
||||
timeOfDay: number;
|
||||
}
|
||||
|
||||
export interface RuleResult {
|
||||
matched: boolean;
|
||||
action: "block" | "flag" | "allow" | "challenge";
|
||||
ruleId: string;
|
||||
ruleType: string;
|
||||
}
|
||||
|
||||
export async function classifyTextBERT(text: string): Promise<TextClassification> {
|
||||
return {
|
||||
isSpam: false,
|
||||
confidence: 1.0,
|
||||
score: 0.0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function extractFeatures(metadata: {
|
||||
callerNumber: string;
|
||||
duration?: number;
|
||||
timeOfDay?: number;
|
||||
}): Promise<CallFeatures> {
|
||||
const areaCode = metadata.callerNumber.length >= 3
|
||||
? metadata.callerNumber.slice(0, 3)
|
||||
: metadata.callerNumber;
|
||||
|
||||
return {
|
||||
callerNumber: metadata.callerNumber,
|
||||
areaCode,
|
||||
prefix: metadata.callerNumber.slice(0, 6),
|
||||
duration: metadata.duration ?? 0,
|
||||
timeOfDay: metadata.timeOfDay ?? new Date().getHours(),
|
||||
};
|
||||
}
|
||||
|
||||
interface Rule {
|
||||
id: string;
|
||||
ruleType: string;
|
||||
pattern: string;
|
||||
action: "block" | "flag" | "allow" | "challenge";
|
||||
priority: number;
|
||||
}
|
||||
|
||||
export async function ruleEngine(
|
||||
rules: Rule[],
|
||||
input: { phoneNumber: string; text?: string },
|
||||
): Promise<RuleResult | null> {
|
||||
const sorted = [...rules].sort((a, b) => b.priority - a.priority);
|
||||
|
||||
for (const rule of sorted) {
|
||||
const matched = matchRule(rule, input);
|
||||
if (matched) {
|
||||
return {
|
||||
matched: true,
|
||||
action: rule.action,
|
||||
ruleId: rule.id,
|
||||
ruleType: rule.ruleType,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function matchRule(
|
||||
rule: Rule,
|
||||
input: { phoneNumber: string; text?: string },
|
||||
): boolean {
|
||||
switch (rule.ruleType) {
|
||||
case "phoneNumber":
|
||||
return input.phoneNumber === rule.pattern;
|
||||
case "areaCode":
|
||||
return input.phoneNumber.startsWith(rule.pattern);
|
||||
case "prefix":
|
||||
return input.phoneNumber.startsWith(rule.pattern);
|
||||
case "pattern":
|
||||
return input.text
|
||||
? new RegExp(rule.pattern, "i").test(input.text)
|
||||
: new RegExp(rule.pattern, "i").test(input.phoneNumber);
|
||||
case "reputation":
|
||||
return false;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
32
web/src/server/services/spamshield/reputation.api.test.ts
Normal file
32
web/src/server/services/spamshield/reputation.api.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { lookupHiya, lookupTruecaller, lookupInternalDB, checkReputation } from "./reputation.api";
|
||||
|
||||
describe("lookupHiya", () => {
|
||||
it("returns default result", async () => {
|
||||
const result = await lookupHiya("+1234567890");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.source).toBe("hiya");
|
||||
});
|
||||
});
|
||||
|
||||
describe("lookupTruecaller", () => {
|
||||
it("returns default result", async () => {
|
||||
const result = await lookupTruecaller("+1234567890");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.source).toBe("truecaller");
|
||||
});
|
||||
});
|
||||
|
||||
describe("lookupInternalDB", () => {
|
||||
it("returns null for uncached numbers", async () => {
|
||||
const result = await lookupInternalDB("+1234567890");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkReputation", () => {
|
||||
it("returns null when all APIs return low confidence", async () => {
|
||||
const result = await checkReputation("+1234567890");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
118
web/src/server/services/spamshield/reputation.api.ts
Normal file
118
web/src/server/services/spamshield/reputation.api.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
export interface ReputationResult {
|
||||
source: string;
|
||||
score: number;
|
||||
isSpam: boolean;
|
||||
confidence: number;
|
||||
category: string;
|
||||
cachedAt?: Date;
|
||||
}
|
||||
|
||||
const cache = new Map<string, { result: ReputationResult; expiresAt: number }>();
|
||||
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
interface CircuitBreakerState {
|
||||
failures: number;
|
||||
lastFailure: number;
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
const circuitBreakers = new Map<string, CircuitBreakerState>();
|
||||
const MAX_FAILURES = 3;
|
||||
const RESET_TIMEOUT_MS = 60_000;
|
||||
|
||||
function isCircuitOpen(source: string): boolean {
|
||||
const state = circuitBreakers.get(source);
|
||||
if (!state) return false;
|
||||
if (!state.isOpen) return false;
|
||||
if (Date.now() - state.lastFailure > RESET_TIMEOUT_MS) {
|
||||
state.isOpen = false;
|
||||
state.failures = 0;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function recordFailure(source: string): void {
|
||||
const state = circuitBreakers.get(source) ?? { failures: 0, lastFailure: 0, isOpen: false };
|
||||
state.failures++;
|
||||
state.lastFailure = Date.now();
|
||||
if (state.failures >= MAX_FAILURES) {
|
||||
state.isOpen = true;
|
||||
}
|
||||
circuitBreakers.set(source, state);
|
||||
}
|
||||
|
||||
export async function lookupHiya(phoneNumber: string): Promise<ReputationResult | null> {
|
||||
if (isCircuitOpen("hiya")) return null;
|
||||
try {
|
||||
return {
|
||||
source: "hiya",
|
||||
score: 0,
|
||||
isSpam: false,
|
||||
confidence: 0,
|
||||
category: "unknown",
|
||||
};
|
||||
} catch {
|
||||
recordFailure("hiya");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function lookupTruecaller(phoneNumber: string): Promise<ReputationResult | null> {
|
||||
if (isCircuitOpen("truecaller")) return null;
|
||||
try {
|
||||
return {
|
||||
source: "truecaller",
|
||||
score: 0,
|
||||
isSpam: false,
|
||||
confidence: 0,
|
||||
category: "unknown",
|
||||
};
|
||||
} catch {
|
||||
recordFailure("truecaller");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function lookupInternalDB(phoneNumber: string): Promise<ReputationResult | null> {
|
||||
const cached = cache.get(phoneNumber);
|
||||
if (cached && Date.now() < cached.expiresAt) {
|
||||
return cached.result;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function cacheReputation(
|
||||
phoneNumber: string,
|
||||
result: ReputationResult,
|
||||
): Promise<void> {
|
||||
cache.set(phoneNumber, {
|
||||
result,
|
||||
expiresAt: Date.now() + CACHE_TTL_MS,
|
||||
});
|
||||
}
|
||||
|
||||
export async function checkReputation(phoneNumber: string): Promise<{
|
||||
source: string;
|
||||
score: number;
|
||||
isSpam: boolean;
|
||||
confidence: number;
|
||||
category: string;
|
||||
} | null> {
|
||||
const cached = await lookupInternalDB(phoneNumber);
|
||||
if (cached) return cached;
|
||||
|
||||
const hiya = await lookupHiya(phoneNumber);
|
||||
if (hiya && hiya.confidence > 0.5) {
|
||||
await cacheReputation(phoneNumber, hiya);
|
||||
return hiya;
|
||||
}
|
||||
|
||||
const truecaller = await lookupTruecaller(phoneNumber);
|
||||
if (truecaller && truecaller.confidence > 0.5) {
|
||||
await cacheReputation(phoneNumber, truecaller);
|
||||
return truecaller;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user