Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- .gitignore +4 -1
- Frontend/package.json +1 -1
- Frontend/src/admin/pages/AdminDashboard.jsx +0 -1
- Frontend/src/admin/pages/AdminTicketDetail.jsx +62 -1
- Frontend/src/admin/pages/AdminTickets.jsx +23 -5
- Frontend/src/hooks/useWebSocket.js +23 -20
- backend/main.py +20 -0
- backend/services/gemini_service.py +17 -6
- backend/services/slack_notifier.py +0 -1
- backend/services/spam_detector_service.py +126 -0
- backend/tests/test_spam_detector.py +43 -0
.gitignore
CHANGED
|
@@ -4,7 +4,10 @@ __pycache__/
|
|
| 4 |
*$py.class
|
| 5 |
venv/
|
| 6 |
env/
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
# Models (Keep them if they are small, but here they are large.
|
| 10 |
# We should probably only keep the ones we actually use if possible,
|
|
|
|
| 4 |
*$py.class
|
| 5 |
venv/
|
| 6 |
env/
|
| 7 |
+
# Ignore all env files anywhere in the repo
|
| 8 |
+
**/.env
|
| 9 |
+
**/.env.*
|
| 10 |
+
|
| 11 |
|
| 12 |
# Models (Keep them if they are small, but here they are large.
|
| 13 |
# We should probably only keep the ones we actually use if possible,
|
Frontend/package.json
CHANGED
|
@@ -6,7 +6,7 @@
|
|
| 6 |
"scripts": {
|
| 7 |
"dev": "vite",
|
| 8 |
"build": "vite build",
|
| 9 |
-
"lint": "eslint . --
|
| 10 |
"check:ai-secrets": "node ../scripts/check-frontend-secrets.mjs",
|
| 11 |
"preview": "vite preview",
|
| 12 |
"prepare": "husky install",
|
|
|
|
| 6 |
"scripts": {
|
| 7 |
"dev": "vite",
|
| 8 |
"build": "vite build",
|
| 9 |
+
"lint": "eslint . --report-unused-disable-directives --max-warnings 0",
|
| 10 |
"check:ai-secrets": "node ../scripts/check-frontend-secrets.mjs",
|
| 11 |
"preview": "vite preview",
|
| 12 |
"prepare": "husky install",
|
Frontend/src/admin/pages/AdminDashboard.jsx
CHANGED
|
@@ -115,7 +115,6 @@ const AdminDashboard = () => {
|
|
| 115 |
const handleWsMessage = useTicketStore((s) => s.handleWsMessage);
|
| 116 |
const setWsConnected = useTicketStore((s) => s.setWsConnected);
|
| 117 |
const upsertTicket = useTicketStore((s) => s.upsertTicket);
|
| 118 |
-
const removeTicket = useTicketStore((s) => s.removeTicket);
|
| 119 |
|
| 120 |
// Sync WebSocket connection status to store
|
| 121 |
React.useEffect(() => {
|
|
|
|
| 115 |
const handleWsMessage = useTicketStore((s) => s.handleWsMessage);
|
| 116 |
const setWsConnected = useTicketStore((s) => s.setWsConnected);
|
| 117 |
const upsertTicket = useTicketStore((s) => s.upsertTicket);
|
|
|
|
| 118 |
|
| 119 |
// Sync WebSocket connection status to store
|
| 120 |
React.useEffect(() => {
|
Frontend/src/admin/pages/AdminTicketDetail.jsx
CHANGED
|
@@ -4,7 +4,7 @@ import {
|
|
| 4 |
CheckCircle2, Clock, AlertCircle, User,
|
| 5 |
Activity, ShieldCheck, Briefcase, Globe, BarChart3,
|
| 6 |
ImageIcon, CornerUpLeft, CheckSquare, XCircle,
|
| 7 |
-
Cpu, Eye, MessageSquare, MoveRight, Loader2, Star, Eraser
|
| 8 |
} from 'lucide-react';
|
| 9 |
import { supabase } from "../../lib/supabaseClient";
|
| 10 |
import useAuthStore from "../../store/authStore";
|
|
@@ -307,6 +307,67 @@ const AdminTicketDetail = () => {
|
|
| 307 |
</div>
|
| 308 |
</div>
|
| 309 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 310 |
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
| 311 |
{/* Primary Column */}
|
| 312 |
<div className="lg:col-span-8 space-y-8">
|
|
|
|
| 4 |
CheckCircle2, Clock, AlertCircle, User,
|
| 5 |
Activity, ShieldCheck, Briefcase, Globe, BarChart3,
|
| 6 |
ImageIcon, CornerUpLeft, CheckSquare, XCircle,
|
| 7 |
+
Cpu, Eye, MessageSquare, MoveRight, Loader2, Star, Eraser, ShieldAlert
|
| 8 |
} from 'lucide-react';
|
| 9 |
import { supabase } from "../../lib/supabaseClient";
|
| 10 |
import useAuthStore from "../../store/authStore";
|
|
|
|
| 307 |
</div>
|
| 308 |
</div>
|
| 309 |
|
| 310 |
+
{ticket.metadata?.spam_analysis?.is_spam && (
|
| 311 |
+
<div style={{
|
| 312 |
+
background: ticket.metadata.spam_analysis.risk_level === 'high' ? '#fef2f2' : '#fffbeb',
|
| 313 |
+
border: `1.5px solid ${ticket.metadata.spam_analysis.risk_level === 'high' ? '#fca5a5' : '#fde047'}`,
|
| 314 |
+
borderRadius: '16px',
|
| 315 |
+
padding: '16px 24px',
|
| 316 |
+
color: ticket.metadata.spam_analysis.risk_level === 'high' ? '#991b1b' : '#92400e',
|
| 317 |
+
display: 'flex',
|
| 318 |
+
alignItems: 'flex-start',
|
| 319 |
+
gap: '16px',
|
| 320 |
+
boxShadow: '0 4px 12px rgba(0,0,0,0.02)'
|
| 321 |
+
}}>
|
| 322 |
+
<ShieldAlert size={24} style={{ flexShrink: 0, marginTop: '2px', color: ticket.metadata.spam_analysis.risk_level === 'high' ? '#dc2626' : '#d97706' }} />
|
| 323 |
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
| 324 |
+
<h4 style={{ margin: 0, fontWeight: 800, fontSize: '13px', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
| 325 |
+
SECURITY PROTOCOL ALERT // POTENTIAL {ticket.metadata.spam_analysis.risk_level.toUpperCase()} RISK SPAM/PHISHING
|
| 326 |
+
</h4>
|
| 327 |
+
<p style={{ margin: 0, fontSize: '12px', fontWeight: 600, lineHeight: 1.5 }}>
|
| 328 |
+
This incident contains potential phishing patterns or malicious URLs. To protect system infrastructure, support agents must not click or copy any untrusted hyperlinks below.
|
| 329 |
+
</p>
|
| 330 |
+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginTop: '8px' }}>
|
| 331 |
+
{ticket.metadata.spam_analysis.reasons.map((reason, i) => (
|
| 332 |
+
<span key={i} style={{
|
| 333 |
+
fontSize: '9px',
|
| 334 |
+
fontWeight: 700,
|
| 335 |
+
textTransform: 'uppercase',
|
| 336 |
+
background: ticket.metadata.spam_analysis.risk_level === 'high' ? '#fee2e2' : '#fef3c7',
|
| 337 |
+
color: ticket.metadata.spam_analysis.risk_level === 'high' ? '#991b1b' : '#92400e',
|
| 338 |
+
padding: '2px 8px',
|
| 339 |
+
borderRadius: '100px',
|
| 340 |
+
border: `1px solid ${ticket.metadata.spam_analysis.risk_level === 'high' ? '#fca5a5' : '#fde047'}`
|
| 341 |
+
}}>
|
| 342 |
+
{reason}
|
| 343 |
+
</span>
|
| 344 |
+
))}
|
| 345 |
+
</div>
|
| 346 |
+
{ticket.metadata.spam_analysis.suspicious_urls?.length > 0 && (
|
| 347 |
+
<div style={{ marginTop: '12px', display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
| 348 |
+
<span style={{ fontSize: '10px', fontWeight: 800, textTransform: 'uppercase', color: ticket.metadata.spam_analysis.risk_level === 'high' ? '#991b1b' : '#92400e' }}>Suspicious links locked:</span>
|
| 349 |
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
| 350 |
+
{ticket.metadata.spam_analysis.suspicious_urls.map((url, idx) => (
|
| 351 |
+
<code key={idx} style={{
|
| 352 |
+
fontSize: '11px',
|
| 353 |
+
fontFamily: 'monospace',
|
| 354 |
+
padding: '4px 8px',
|
| 355 |
+
background: ticket.metadata.spam_analysis.risk_level === 'high' ? '#fee2e240' : '#fef3c740',
|
| 356 |
+
color: '#dc2626',
|
| 357 |
+
borderRadius: '6px',
|
| 358 |
+
border: '1.5px dashed #fca5a5',
|
| 359 |
+
wordBreak: 'break-all'
|
| 360 |
+
}}>
|
| 361 |
+
[LOCKED] {url}
|
| 362 |
+
</code>
|
| 363 |
+
))}
|
| 364 |
+
</div>
|
| 365 |
+
</div>
|
| 366 |
+
)}
|
| 367 |
+
</div>
|
| 368 |
+
</div>
|
| 369 |
+
)}
|
| 370 |
+
|
| 371 |
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
| 372 |
{/* Primary Column */}
|
| 373 |
<div className="lg:col-span-8 space-y-8">
|
Frontend/src/admin/pages/AdminTickets.jsx
CHANGED
|
@@ -168,7 +168,7 @@ const AdminTickets = () => {
|
|
| 168 |
|
| 169 |
const categories = ['All', 'Network', 'Hardware', 'Software', 'Access', 'Account'];
|
| 170 |
const priorities = ['All', 'Low', 'Medium', 'High'];
|
| 171 |
-
const statuses = ['All', 'Open', 'In Progress', 'Resolved', 'Closed'];
|
| 172 |
const teams = ['All', 'Software Team', 'Hardware Support', 'Network Ops', 'Security Unit', 'General Support'];
|
| 173 |
|
| 174 |
const filteredTickets = useMemo(() => {
|
|
@@ -333,9 +333,21 @@ const AdminTickets = () => {
|
|
| 333 |
{/* Subject */}
|
| 334 |
<td className="px-6 py-6">
|
| 335 |
<div className="flex flex-col">
|
| 336 |
-
<
|
| 337 |
-
{ticket.summary || ticket.subject}
|
| 338 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 339 |
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest flex items-center gap-2">
|
| 340 |
{ticket.category}
|
| 341 |
<span className="text-[9px] font-medium text-slate-300">• {formatTimelineDate(ticket.created_at)}</span>
|
|
@@ -420,7 +432,13 @@ const AdminTickets = () => {
|
|
| 420 |
{/* Status (Editable) */}
|
| 421 |
<td className="px-6 py-6">
|
| 422 |
<div className="flex items-center gap-2">
|
| 423 |
-
<div className={`w-1.5 h-1.5 rounded-full ${
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 424 |
<Select
|
| 425 |
value={String(ticket.status || 'open').toLowerCase()}
|
| 426 |
onChange={(e) => handleUpdateTicket(ticket.id, { status: e.target.value })}
|
|
|
|
| 168 |
|
| 169 |
const categories = ['All', 'Network', 'Hardware', 'Software', 'Access', 'Account'];
|
| 170 |
const priorities = ['All', 'Low', 'Medium', 'High'];
|
| 171 |
+
const statuses = ['All', 'Open', 'In Progress', 'Resolved', 'Closed', 'Spam'];
|
| 172 |
const teams = ['All', 'Software Team', 'Hardware Support', 'Network Ops', 'Security Unit', 'General Support'];
|
| 173 |
|
| 174 |
const filteredTickets = useMemo(() => {
|
|
|
|
| 333 |
{/* Subject */}
|
| 334 |
<td className="px-6 py-6">
|
| 335 |
<div className="flex flex-col">
|
| 336 |
+
<div className="flex items-center gap-2">
|
| 337 |
+
<span className="text-xs font-bold text-slate-700 truncate max-w-[200px]" title={ticket.summary || ticket.subject}>
|
| 338 |
+
{ticket.summary || ticket.subject}
|
| 339 |
+
</span>
|
| 340 |
+
{ticket.metadata?.spam_analysis?.is_spam && (
|
| 341 |
+
<span className={`px-2 py-0.5 rounded text-[8px] font-black uppercase tracking-wider flex items-center gap-1 ${
|
| 342 |
+
ticket.metadata.spam_analysis.risk_level === 'high'
|
| 343 |
+
? 'bg-red-100 text-red-700 border border-red-200'
|
| 344 |
+
: 'bg-amber-100 text-amber-700 border border-amber-200'
|
| 345 |
+
}`}>
|
| 346 |
+
<ShieldAlert size={10} />
|
| 347 |
+
{ticket.metadata.spam_analysis.risk_level} Risk
|
| 348 |
+
</span>
|
| 349 |
+
)}
|
| 350 |
+
</div>
|
| 351 |
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest flex items-center gap-2">
|
| 352 |
{ticket.category}
|
| 353 |
<span className="text-[9px] font-medium text-slate-300">• {formatTimelineDate(ticket.created_at)}</span>
|
|
|
|
| 432 |
{/* Status (Editable) */}
|
| 433 |
<td className="px-6 py-6">
|
| 434 |
<div className="flex items-center gap-2">
|
| 435 |
+
<div className={`w-1.5 h-1.5 rounded-full ${
|
| 436 |
+
ticket.status?.toLowerCase() === 'resolved' || ticket.status?.toLowerCase() === 'closed'
|
| 437 |
+
? 'bg-emerald-400'
|
| 438 |
+
: ticket.status?.toLowerCase() === 'spam'
|
| 439 |
+
? 'bg-slate-400 border border-slate-500'
|
| 440 |
+
: 'bg-amber-500 animate-pulse'
|
| 441 |
+
}`}></div>
|
| 442 |
<Select
|
| 443 |
value={String(ticket.status || 'open').toLowerCase()}
|
| 444 |
onChange={(e) => handleUpdateTicket(ticket.id, { status: e.target.value })}
|
Frontend/src/hooks/useWebSocket.js
CHANGED
|
@@ -47,7 +47,6 @@ export default function useWebSocket(companyId) {
|
|
| 47 |
const companyIdRef = useRef(companyId);
|
| 48 |
|
| 49 |
// Keep a ref to latest companyId so the effect closure always has it
|
| 50 |
-
companyIdRef.current = companyId;
|
| 51 |
|
| 52 |
// ---- Cleanup helpers ---------------------------------------------------
|
| 53 |
|
|
@@ -89,7 +88,26 @@ export default function useWebSocket(companyId) {
|
|
| 89 |
}, PING_INTERVAL_MS);
|
| 90 |
}, []);
|
| 91 |
|
| 92 |
-
// ---- WebSocket lifecycle ---------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
|
| 94 |
const connect = useCallback(() => {
|
| 95 |
cleanup();
|
|
@@ -153,25 +171,10 @@ export default function useWebSocket(companyId) {
|
|
| 153 |
socket.onerror = () => {
|
| 154 |
// onclose fires immediately after onerror, so reconnect is handled there
|
| 155 |
};
|
| 156 |
-
}, [cleanup, clearTimers, startHeartbeat]);
|
| 157 |
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
const scheduleReconnect = useCallback(() => {
|
| 161 |
-
if (!mountedRef.current || !companyIdRef.current) return;
|
| 162 |
-
|
| 163 |
-
const attempt = reconnectAttemptRef.current;
|
| 164 |
-
const delay = Math.min(
|
| 165 |
-
INITIAL_RECONNECT_DELAY_MS * Math.pow(2, attempt),
|
| 166 |
-
MAX_RECONNECT_DELAY_MS
|
| 167 |
-
);
|
| 168 |
-
reconnectAttemptRef.current = attempt + 1;
|
| 169 |
-
|
| 170 |
-
setConnectionError(`Reconnecting in ${Math.round(delay / 1000)}s...`);
|
| 171 |
-
|
| 172 |
-
reconnectTimerRef.current = setTimeout(() => {
|
| 173 |
-
if (mountedRef.current) connect();
|
| 174 |
-
}, delay);
|
| 175 |
}, [connect]);
|
| 176 |
|
| 177 |
// ---- Send helper -------------------------------------------------------
|
|
|
|
| 47 |
const companyIdRef = useRef(companyId);
|
| 48 |
|
| 49 |
// Keep a ref to latest companyId so the effect closure always has it
|
|
|
|
| 50 |
|
| 51 |
// ---- Cleanup helpers ---------------------------------------------------
|
| 52 |
|
|
|
|
| 88 |
}, PING_INTERVAL_MS);
|
| 89 |
}, []);
|
| 90 |
|
| 91 |
+
// ---- WebSocket lifecycle & Reconnection ---------------------------------
|
| 92 |
+
|
| 93 |
+
const connectRef = useRef(null);
|
| 94 |
+
|
| 95 |
+
const scheduleReconnect = useCallback(() => {
|
| 96 |
+
if (!mountedRef.current || !companyIdRef.current) return;
|
| 97 |
+
|
| 98 |
+
const attempt = reconnectAttemptRef.current;
|
| 99 |
+
const delay = Math.min(
|
| 100 |
+
INITIAL_RECONNECT_DELAY_MS * Math.pow(2, attempt),
|
| 101 |
+
MAX_RECONNECT_DELAY_MS
|
| 102 |
+
);
|
| 103 |
+
reconnectAttemptRef.current = attempt + 1;
|
| 104 |
+
|
| 105 |
+
setConnectionError(`Reconnecting in ${Math.round(delay / 1000)}s...`);
|
| 106 |
+
|
| 107 |
+
reconnectTimerRef.current = setTimeout(() => {
|
| 108 |
+
if (mountedRef.current && connectRef.current) connectRef.current();
|
| 109 |
+
}, delay);
|
| 110 |
+
}, []);
|
| 111 |
|
| 112 |
const connect = useCallback(() => {
|
| 113 |
cleanup();
|
|
|
|
| 171 |
socket.onerror = () => {
|
| 172 |
// onclose fires immediately after onerror, so reconnect is handled there
|
| 173 |
};
|
| 174 |
+
}, [cleanup, clearTimers, startHeartbeat, scheduleReconnect]);
|
| 175 |
|
| 176 |
+
useEffect(() => {
|
| 177 |
+
connectRef.current = connect;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
}, [connect]);
|
| 179 |
|
| 180 |
// ---- Send helper -------------------------------------------------------
|
backend/main.py
CHANGED
|
@@ -212,6 +212,26 @@ def calculate_sla_response_at(priority: str) -> datetime.datetime:
|
|
| 212 |
return datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=hours)
|
| 213 |
|
| 214 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
# ---------------------------------------------------------------------------
|
| 216 |
# Request / Response models
|
| 217 |
# ---------------------------------------------------------------------------
|
|
|
|
| 212 |
return datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=hours)
|
| 213 |
|
| 214 |
|
| 215 |
+
def classify_sla_status(sla_breach_at: str | None) -> str:
|
| 216 |
+
"""Return 'BREACHED', 'WARNING', or 'ACTIVE' based on the breach time."""
|
| 217 |
+
if not sla_breach_at:
|
| 218 |
+
return "ACTIVE"
|
| 219 |
+
try:
|
| 220 |
+
clean_val = str(sla_breach_at).replace("Z", "+00:00")
|
| 221 |
+
deadline = datetime.datetime.fromisoformat(clean_val)
|
| 222 |
+
if deadline.tzinfo is None:
|
| 223 |
+
deadline = deadline.replace(tzinfo=datetime.timezone.utc)
|
| 224 |
+
except Exception:
|
| 225 |
+
return "ACTIVE"
|
| 226 |
+
|
| 227 |
+
now = datetime.datetime.now(datetime.timezone.utc)
|
| 228 |
+
if deadline <= now:
|
| 229 |
+
return "BREACHED"
|
| 230 |
+
if deadline - now <= datetime.timedelta(hours=1):
|
| 231 |
+
return "WARNING"
|
| 232 |
+
return "ACTIVE"
|
| 233 |
+
|
| 234 |
+
|
| 235 |
# ---------------------------------------------------------------------------
|
| 236 |
# Request / Response models
|
| 237 |
# ---------------------------------------------------------------------------
|
backend/services/gemini_service.py
CHANGED
|
@@ -3,11 +3,19 @@ import base64
|
|
| 3 |
import io
|
| 4 |
import re
|
| 5 |
import json
|
| 6 |
-
from PIL import Image
|
| 7 |
-
from google import genai
|
| 8 |
from dotenv import load_dotenv
|
| 9 |
from pathlib import Path
|
| 10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
# Load environment variables from backend/.env
|
| 12 |
env_path = Path(__file__).parent.parent / '.env'
|
| 13 |
load_dotenv(dotenv_path=env_path)
|
|
@@ -18,7 +26,7 @@ class GeminiService:
|
|
| 18 |
self._initialized = False
|
| 19 |
self.model_name = 'gemini-2.5-flash'
|
| 20 |
|
| 21 |
-
if self.api_key:
|
| 22 |
try:
|
| 23 |
self.client = genai.Client(api_key=self.api_key)
|
| 24 |
self._initialized = True
|
|
@@ -26,15 +34,18 @@ class GeminiService:
|
|
| 26 |
except Exception as e:
|
| 27 |
print(f"[GeminiService] Initialization Error: {e}")
|
| 28 |
else:
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
def analyze_image(self, image_base64: str, context_text: str = None) -> dict:
|
| 32 |
"""
|
| 33 |
Perform OCR and image analysis using Gemini logic.
|
| 34 |
"""
|
| 35 |
-
if not self._initialized:
|
| 36 |
return {
|
| 37 |
-
"image_description": "[Gemini
|
| 38 |
"ocr_text": "",
|
| 39 |
"detected_problem": ""
|
| 40 |
}
|
|
|
|
| 3 |
import io
|
| 4 |
import re
|
| 5 |
import json
|
|
|
|
|
|
|
| 6 |
from dotenv import load_dotenv
|
| 7 |
from pathlib import Path
|
| 8 |
|
| 9 |
+
try:
|
| 10 |
+
from PIL import Image
|
| 11 |
+
from google import genai
|
| 12 |
+
_HAS_GEMINI_DEPS = True
|
| 13 |
+
except ImportError:
|
| 14 |
+
Image = None
|
| 15 |
+
genai = None
|
| 16 |
+
_HAS_GEMINI_DEPS = False
|
| 17 |
+
|
| 18 |
+
|
| 19 |
# Load environment variables from backend/.env
|
| 20 |
env_path = Path(__file__).parent.parent / '.env'
|
| 21 |
load_dotenv(dotenv_path=env_path)
|
|
|
|
| 26 |
self._initialized = False
|
| 27 |
self.model_name = 'gemini-2.5-flash'
|
| 28 |
|
| 29 |
+
if self.api_key and _HAS_GEMINI_DEPS:
|
| 30 |
try:
|
| 31 |
self.client = genai.Client(api_key=self.api_key)
|
| 32 |
self._initialized = True
|
|
|
|
| 34 |
except Exception as e:
|
| 35 |
print(f"[GeminiService] Initialization Error: {e}")
|
| 36 |
else:
|
| 37 |
+
if not _HAS_GEMINI_DEPS:
|
| 38 |
+
print("[GeminiService] WARNING: PIL or google-genai package is not installed. Gemini service is disabled.")
|
| 39 |
+
else:
|
| 40 |
+
print("[GeminiService] WARNING: GEMINI_API_KEY not found in environment.")
|
| 41 |
|
| 42 |
def analyze_image(self, image_base64: str, context_text: str = None) -> dict:
|
| 43 |
"""
|
| 44 |
Perform OCR and image analysis using Gemini logic.
|
| 45 |
"""
|
| 46 |
+
if not self._initialized or not _HAS_GEMINI_DEPS:
|
| 47 |
return {
|
| 48 |
+
"image_description": "[Gemini Service Offline] Could not analyze image.",
|
| 49 |
"ocr_text": "",
|
| 50 |
"detected_problem": ""
|
| 51 |
}
|
backend/services/slack_notifier.py
CHANGED
|
@@ -175,4 +175,3 @@ def notify_sla_breach(ticket: dict[str, Any]) -> bool:
|
|
| 175 |
|
| 176 |
return sent
|
| 177 |
|
| 178 |
-
# PR diff placeholder
|
|
|
|
| 175 |
|
| 176 |
return sent
|
| 177 |
|
|
|
backend/services/spam_detector_service.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
import urllib.parse
|
| 3 |
+
|
| 4 |
+
# Whitelist of trusted domains
|
| 5 |
+
SAFE_DOMAINS = {
|
| 6 |
+
"google.com", "github.com", "microsoft.com", "apple.com",
|
| 7 |
+
"supabase.com", "helpdesk.ai", "gssoc.org.in", "vercel.app",
|
| 8 |
+
"wikipedia.org", "yahoo.com", "outlook.com", "gmail.com",
|
| 9 |
+
"facebook.com", "linkedin.com", "twitter.com", "x.com"
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
# Suspicious top-level domains (TLDs)
|
| 13 |
+
SUSPICIOUS_TLDS = {
|
| 14 |
+
".xyz", ".ru", ".su", ".zip", ".mov", ".click", ".link",
|
| 15 |
+
".top", ".info", ".cc", ".biz", ".icu", ".club", ".work", ".gq", ".cf", ".ml"
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
# Phishing indicators / social engineering keywords
|
| 19 |
+
PHISHING_KEYWORDS = [
|
| 20 |
+
"verify your account", "immediate action required", "confirm your password",
|
| 21 |
+
"security alert login", "suspend your account", "billing update required",
|
| 22 |
+
"bank login", "login attempt from", "unauthorized login", "security verification",
|
| 23 |
+
"urgent action", "confirm your identity", "reset your security", "compromised"
|
| 24 |
+
]
|
| 25 |
+
|
| 26 |
+
# Spam indicators / marketing bot keywords
|
| 27 |
+
SPAM_KEYWORDS = [
|
| 28 |
+
"seo services", "crypto trading", "casino bonus", "lottery winner",
|
| 29 |
+
"cash prize", "viagra", "buy cheap", "guaranteed profit", "double your money",
|
| 30 |
+
"make money fast", "free investment", "gift card", "work from home", "bitcoin profit"
|
| 31 |
+
]
|
| 32 |
+
|
| 33 |
+
# URL Extraction Regex
|
| 34 |
+
URL_REGEX = re.compile(r'https?://[^\s<>"]+|www\.[^\s<>"]+')
|
| 35 |
+
|
| 36 |
+
def is_domain_safe(domain: str) -> bool:
|
| 37 |
+
"""Check if the domain or its parent domain is in the safe list."""
|
| 38 |
+
domain = domain.lower()
|
| 39 |
+
if domain in SAFE_DOMAINS:
|
| 40 |
+
return True
|
| 41 |
+
|
| 42 |
+
# Check subdomains (e.g. sub.google.com -> google.com)
|
| 43 |
+
for safe in SAFE_DOMAINS:
|
| 44 |
+
if domain.endswith("." + safe):
|
| 45 |
+
return True
|
| 46 |
+
return False
|
| 47 |
+
|
| 48 |
+
def analyze_spam_phishing(text: str, ocr_text: str = "") -> dict:
|
| 49 |
+
"""
|
| 50 |
+
Analyze text and OCR text for spam and phishing elements.
|
| 51 |
+
Returns analysis results including spam flag, risk level, reasons, and URLs.
|
| 52 |
+
"""
|
| 53 |
+
combined_text = f"{text}\n{ocr_text}"
|
| 54 |
+
lower_text = combined_text.lower()
|
| 55 |
+
|
| 56 |
+
reasons = []
|
| 57 |
+
detected_urls = []
|
| 58 |
+
suspicious_urls = []
|
| 59 |
+
|
| 60 |
+
# 1. URL Scanning
|
| 61 |
+
urls = URL_REGEX.findall(combined_text)
|
| 62 |
+
for url in urls:
|
| 63 |
+
# Strip trailing punctuation commonly matched by regex
|
| 64 |
+
clean_url = url.rstrip('.,;:-?!()[]{}')
|
| 65 |
+
if clean_url not in detected_urls:
|
| 66 |
+
detected_urls.append(clean_url)
|
| 67 |
+
|
| 68 |
+
# Parse host
|
| 69 |
+
try:
|
| 70 |
+
parsed = urllib.parse.urlparse(clean_url if "://" in clean_url else "http://" + clean_url)
|
| 71 |
+
host = parsed.netloc.split(':')[0] # strip port
|
| 72 |
+
|
| 73 |
+
# Check TLD
|
| 74 |
+
has_suspicious_tld = any(host.endswith(tld) for tld in SUSPICIOUS_TLDS)
|
| 75 |
+
if has_suspicious_tld:
|
| 76 |
+
suspicious_urls.append(clean_url)
|
| 77 |
+
reasons.append(f"Suspicious TLD in link: {clean_url}")
|
| 78 |
+
continue
|
| 79 |
+
|
| 80 |
+
# Check if domain is untrusted AND has suspicious path keywords
|
| 81 |
+
if not is_domain_safe(host):
|
| 82 |
+
path_lower = parsed.path.lower()
|
| 83 |
+
query_lower = parsed.query.lower()
|
| 84 |
+
suspicious_path_keywords = ["login", "signin", "verify", "secure", "update", "account", "billing"]
|
| 85 |
+
|
| 86 |
+
if any(kw in path_lower or kw in query_lower or kw in host for kw in suspicious_path_keywords):
|
| 87 |
+
suspicious_urls.append(clean_url)
|
| 88 |
+
reasons.append(f"Untrusted URL containing security/account keywords: {clean_url}")
|
| 89 |
+
except Exception:
|
| 90 |
+
pass
|
| 91 |
+
|
| 92 |
+
# 2. Phishing Keyword Scanning
|
| 93 |
+
matched_phishing = [kw for kw in PHISHING_KEYWORDS if kw in lower_text]
|
| 94 |
+
if matched_phishing:
|
| 95 |
+
reasons.append(f"Detected phishing keyword patterns: {', '.join(matched_phishing[:3])}")
|
| 96 |
+
|
| 97 |
+
# 3. Spam Keyword Scanning
|
| 98 |
+
matched_spam = [kw for kw in SPAM_KEYWORDS if kw in lower_text]
|
| 99 |
+
if matched_spam:
|
| 100 |
+
reasons.append(f"Detected spam/marketing keyword patterns: {', '.join(matched_spam[:3])}")
|
| 101 |
+
|
| 102 |
+
# 4. Multi-Link bot check
|
| 103 |
+
if len(detected_urls) >= 5:
|
| 104 |
+
reasons.append(f"High link density ({len(detected_urls)} URLs detected)")
|
| 105 |
+
|
| 106 |
+
# 5. Risk Assessment
|
| 107 |
+
risk_level = "none"
|
| 108 |
+
is_spam = False
|
| 109 |
+
|
| 110 |
+
if len(suspicious_urls) > 0 or len(matched_phishing) >= 2:
|
| 111 |
+
risk_level = "high"
|
| 112 |
+
is_spam = True
|
| 113 |
+
elif len(matched_phishing) == 1 or len(matched_spam) >= 2 or len(detected_urls) >= 3:
|
| 114 |
+
risk_level = "medium"
|
| 115 |
+
is_spam = True
|
| 116 |
+
elif len(matched_spam) == 1 or len(detected_urls) > 0:
|
| 117 |
+
risk_level = "low"
|
| 118 |
+
is_spam = True
|
| 119 |
+
|
| 120 |
+
return {
|
| 121 |
+
"is_spam": is_spam,
|
| 122 |
+
"risk_level": risk_level,
|
| 123 |
+
"reasons": reasons,
|
| 124 |
+
"detected_urls": detected_urls,
|
| 125 |
+
"suspicious_urls": suspicious_urls
|
| 126 |
+
}
|
backend/tests/test_spam_detector.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import unittest
|
| 2 |
+
from backend.services.spam_detector_service import analyze_spam_phishing, is_domain_safe
|
| 3 |
+
|
| 4 |
+
class TestSpamDetector(unittest.TestCase):
|
| 5 |
+
def test_benign_text(self):
|
| 6 |
+
text = "Hello, I am having trouble logging into my email account. Can you please help me reset it?"
|
| 7 |
+
res = analyze_spam_phishing(text)
|
| 8 |
+
self.assertFalse(res["is_spam"])
|
| 9 |
+
self.assertEqual(res["risk_level"], "none")
|
| 10 |
+
|
| 11 |
+
def test_domain_safety(self):
|
| 12 |
+
self.assertTrue(is_domain_safe("google.com"))
|
| 13 |
+
self.assertTrue(is_domain_safe("sub.google.com"))
|
| 14 |
+
self.assertTrue(is_domain_safe("github.com"))
|
| 15 |
+
self.assertFalse(is_domain_safe("phish-login-update.ru"))
|
| 16 |
+
self.assertFalse(is_domain_safe("google-security.xyz"))
|
| 17 |
+
|
| 18 |
+
def test_phishing_by_url(self):
|
| 19 |
+
text = "Immediate action required: please update your profile here: http://phish-secure-login.xyz/verify"
|
| 20 |
+
res = analyze_spam_phishing(text)
|
| 21 |
+
self.assertTrue(res["is_spam"])
|
| 22 |
+
self.assertEqual(res["risk_level"], "high")
|
| 23 |
+
self.assertTrue(any("verify" in r.lower() or "tld" in r.lower() for r in res["reasons"]))
|
| 24 |
+
self.assertEqual(len(res["suspicious_urls"]), 1)
|
| 25 |
+
|
| 26 |
+
def test_spam_by_keywords(self):
|
| 27 |
+
text = "Get rich quick with our brand new crypto trading scheme! Double your money within 24 hours guaranteed!"
|
| 28 |
+
res = analyze_spam_phishing(text)
|
| 29 |
+
self.assertTrue(res["is_spam"])
|
| 30 |
+
self.assertEqual(res["risk_level"], "medium") # medium because of 2+ matched spam keywords
|
| 31 |
+
self.assertTrue(any("spam" in r.lower() or "marketing" in r.lower() for r in res["reasons"]))
|
| 32 |
+
|
| 33 |
+
def test_ocr_phishing_scan(self):
|
| 34 |
+
# Benign text but suspicious URL in OCR text
|
| 35 |
+
text = "Please see the attached document for invoice details."
|
| 36 |
+
ocr = "To verify your account credentials click: http://untrusted-bank-update.info/login"
|
| 37 |
+
res = analyze_spam_phishing(text, ocr)
|
| 38 |
+
self.assertTrue(res["is_spam"])
|
| 39 |
+
self.assertEqual(res["risk_level"], "high")
|
| 40 |
+
self.assertEqual(len(res["suspicious_urls"]), 1)
|
| 41 |
+
|
| 42 |
+
if __name__ == "__main__":
|
| 43 |
+
unittest.main()
|