308 lines
7.9 KiB
TypeScript
308 lines
7.9 KiB
TypeScript
import { prisma, SpamRule, SpamFeedback, User } from '@shieldsai/shared-db';
|
|
import { spamShieldEnv, SpamDecision, ConfidenceLevel } from './spamshield.config';
|
|
|
|
// Number reputation service (Hiya API integration)
|
|
export class NumberReputationService {
|
|
/**
|
|
* Check number reputation using Hiya API
|
|
*/
|
|
async checkReputation(phoneNumber: string): Promise<{
|
|
isSpam: boolean;
|
|
confidence: number;
|
|
spamType?: string;
|
|
reportCount: number;
|
|
}> {
|
|
try {
|
|
// TODO: Integrate with Hiya API
|
|
// const response = await fetch(`${spamShieldEnv.HIYA_API_URL}/lookup`, {
|
|
// headers: { 'X-API-Key': spamShieldEnv.HIYA_API_KEY },
|
|
// method: 'POST',
|
|
// body: JSON.stringify({ phone: phoneNumber }),
|
|
// });
|
|
|
|
// Simulated response for now
|
|
return {
|
|
isSpam: false,
|
|
confidence: 0.1,
|
|
spamType: undefined,
|
|
reportCount: 0,
|
|
};
|
|
} catch (error) {
|
|
console.error('Error checking number reputation:', error);
|
|
return {
|
|
isSpam: false,
|
|
confidence: 0.0,
|
|
reportCount: 0,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check number against multiple reputation sources
|
|
*/
|
|
async checkMultiSource(phoneNumber: string): Promise<{
|
|
hiya: { isSpam: boolean; confidence: number };
|
|
truecaller: { isSpam: boolean; confidence: number } | null;
|
|
combinedScore: number;
|
|
}> {
|
|
const hiyaResult = await this.checkReputation(phoneNumber);
|
|
|
|
let truecallerResult: { isSpam: boolean; confidence: number } | null = null;
|
|
if (spamShieldEnv.TRUECALLER_API_KEY) {
|
|
// TODO: Integrate Truecaller
|
|
truecallerResult = {
|
|
isSpam: false,
|
|
confidence: 0.0,
|
|
};
|
|
}
|
|
|
|
// Weighted average: Hiya 70%, Truecaller 30%
|
|
const combinedScore = hiyaResult.confidence * 0.7 +
|
|
(truecallerResult?.confidence ?? 0) * 0.3;
|
|
|
|
return {
|
|
hiya: { isSpam: hiyaResult.isSpam, confidence: hiyaResult.confidence },
|
|
truecaller: truecallerResult,
|
|
combinedScore,
|
|
};
|
|
}
|
|
}
|
|
|
|
// SMS content classifier (BERT-based)
|
|
export class SMSClassifierService {
|
|
private model: any = null; // BERT model placeholder
|
|
|
|
/**
|
|
* Initialize the BERT model
|
|
*/
|
|
async initialize(): Promise<void> {
|
|
// TODO: Load BERT model from path
|
|
// this.model = await loadBERTModel(spamShieldEnv.BERT_MODEL_PATH);
|
|
console.log('SMS classifier initialized');
|
|
}
|
|
|
|
/**
|
|
* Classify SMS text as spam or ham
|
|
*/
|
|
async classify(smsText: string): Promise<{
|
|
isSpam: boolean;
|
|
confidence: number;
|
|
spamFeatures: string[];
|
|
}> {
|
|
if (!this.model) {
|
|
await this.initialize();
|
|
}
|
|
|
|
// Extract features
|
|
const features = this.extractFeatures(smsText);
|
|
|
|
// TODO: Run through BERT model
|
|
// const prediction = await this.model.predict(smsText);
|
|
|
|
// Simulated prediction
|
|
const confidence = this.calculateConfidence(features);
|
|
const isSpam = confidence >= spamShieldEnv.SPAM_THRESHOLD_AUTO_BLOCK;
|
|
|
|
return {
|
|
isSpam,
|
|
confidence,
|
|
spamFeatures: features,
|
|
};
|
|
}
|
|
|
|
private extractFeatures(text: string): string[] {
|
|
const features: string[] = [];
|
|
const lowerText = text.toLowerCase();
|
|
|
|
// URL presence
|
|
if (/(http|www)\./i.test(text)) {
|
|
features.push('url_present');
|
|
}
|
|
|
|
// Emoji density
|
|
const emojiCount = (text.match(/[\p{Emoji}]/gu) || []).length;
|
|
if (emojiCount / text.length > 0.1) {
|
|
features.push('high_emoji_density');
|
|
}
|
|
|
|
// Urgency keywords
|
|
const urgencyWords = ['now', 'urgent', 'limited', 'act fast', 'today'];
|
|
if (urgencyWords.some(word => lowerText.includes(word))) {
|
|
features.push('urgency_keyword');
|
|
}
|
|
|
|
// Excessive capitalization
|
|
if (/[A-Z]{3,}/.test(text)) {
|
|
features.push('excessive_caps');
|
|
}
|
|
|
|
return features;
|
|
}
|
|
|
|
private calculateConfidence(features: string[]): number {
|
|
const baseConfidence = 0.5;
|
|
const featureWeights: Record<string, number> = {
|
|
url_present: 0.1,
|
|
high_emoji_density: 0.15,
|
|
urgency_keyword: 0.2,
|
|
excessive_caps: 0.15,
|
|
};
|
|
|
|
return Math.min(1.0, baseConfidence +
|
|
features.reduce((sum, f) => sum + (featureWeights[f] || 0), 0));
|
|
}
|
|
}
|
|
|
|
// Call analysis service
|
|
export class CallAnalysisService {
|
|
/**
|
|
* Analyze incoming call for spam indicators
|
|
*/
|
|
async analyzeCall(callData: {
|
|
phoneNumber: string;
|
|
duration?: number;
|
|
callTime: Date;
|
|
isVoip?: boolean;
|
|
}): Promise<{
|
|
decision: SpamDecision;
|
|
confidence: number;
|
|
reasons: string[];
|
|
}> {
|
|
const reasons: string[] = [];
|
|
let spamScore = 0.0;
|
|
|
|
// Number reputation check
|
|
const reputationService = new NumberReputationService();
|
|
const reputation = await reputationService.checkMultiSource(callData.phoneNumber);
|
|
|
|
if (reputation.combinedScore > 0.7) {
|
|
spamScore += reputation.combinedScore * 0.4;
|
|
reasons.push('high_spam_reputation');
|
|
}
|
|
|
|
// Behavioral analysis
|
|
if (callData.duration && callData.duration < 10) {
|
|
spamScore += 0.2;
|
|
reasons.push('short_duration');
|
|
}
|
|
|
|
if (callData.isVoip) {
|
|
spamScore += 0.15;
|
|
reasons.push('voip_number');
|
|
}
|
|
|
|
// Time-of-day anomaly (simplified)
|
|
const hour = callData.callTime.getHours();
|
|
if (hour < 6 || hour > 22) {
|
|
spamScore += 0.1;
|
|
reasons.push('unusual_hours');
|
|
}
|
|
|
|
// Determine decision
|
|
let decision: SpamDecision;
|
|
if (spamScore >= spamShieldEnv.SPAM_THRESHOLD_AUTO_BLOCK) {
|
|
decision = SpamDecision.BLOCK;
|
|
} else if (spamScore >= spamShieldEnv.SPAM_THRESHOLD_FLAG) {
|
|
decision = SpamDecision.FLAG;
|
|
} else {
|
|
decision = SpamDecision.ALLOW;
|
|
}
|
|
|
|
return {
|
|
decision,
|
|
confidence: spamScore,
|
|
reasons,
|
|
};
|
|
}
|
|
}
|
|
|
|
// User feedback service
|
|
export class SpamFeedbackService {
|
|
/**
|
|
* Record user feedback on spam detection
|
|
*/
|
|
async recordFeedback(
|
|
userId: string,
|
|
phoneNumber: string,
|
|
isSpam: boolean,
|
|
confidence?: number,
|
|
metadata?: Record<string, any>
|
|
): Promise<SpamFeedback> {
|
|
const phoneNumberHash = this.hashPhoneNumber(phoneNumber);
|
|
|
|
const feedback = await prisma.spamFeedback.create({
|
|
data: {
|
|
userId,
|
|
phoneNumber,
|
|
phoneNumberHash,
|
|
isSpam,
|
|
confidence,
|
|
feedbackType: 'user_confirmation',
|
|
metadata,
|
|
},
|
|
});
|
|
|
|
return feedback;
|
|
}
|
|
|
|
/**
|
|
* Get spam history for a user
|
|
*/
|
|
async getSpamHistory(
|
|
userId: string,
|
|
options?: {
|
|
limit?: number;
|
|
isSpam?: boolean;
|
|
startDate?: Date;
|
|
}
|
|
): Promise<SpamFeedback[]> {
|
|
return prisma.spamFeedback.findMany({
|
|
where: {
|
|
userId,
|
|
...(options?.isSpam !== undefined && { isSpam: options.isSpam }),
|
|
...(options?.startDate && { createdAt: { gte: options.startDate } }),
|
|
},
|
|
orderBy: { createdAt: 'desc' },
|
|
take: options?.limit ?? 100,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get statistics for a user
|
|
*/
|
|
async getStatistics(userId: string): Promise<{
|
|
totalAnalyses: number;
|
|
spamCount: number;
|
|
hamCount: number;
|
|
spamPercentage: number;
|
|
}> {
|
|
const [total, spam] = await Promise.all([
|
|
prisma.spamFeedback.count({ where: { userId } }),
|
|
prisma.spamFeedback.count({ where: { userId, isSpam: true } }),
|
|
]);
|
|
|
|
return {
|
|
totalAnalyses: total,
|
|
spamCount: spam,
|
|
hamCount: total - spam,
|
|
spamPercentage: total > 0 ? (spam / total) * 100 : 0,
|
|
};
|
|
}
|
|
|
|
private hashPhoneNumber(phoneNumber: string): string {
|
|
// Simple hash for demonstration
|
|
let hash = 0;
|
|
for (let i = 0; i < phoneNumber.length; i++) {
|
|
hash = ((hash << 5) - hash) + phoneNumber.charCodeAt(i);
|
|
hash |= 0;
|
|
}
|
|
return `hash_${Math.abs(hash)}`;
|
|
}
|
|
}
|
|
|
|
// Export instances
|
|
export const numberReputationService = new NumberReputationService();
|
|
export const smsClassifierService = new SMSClassifierService();
|
|
export const callAnalysisService = new CallAnalysisService();
|
|
export const spamFeedbackService = new SpamFeedbackService();
|