mirror of
https://git.victorphan.net/basketballcantho/zalo-monitor.git
synced 2026-08-06 22:43:11 +07:00
init
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { loginWithQR } from "@/lib/zalo";
|
||||
|
||||
export async function POST() {
|
||||
return new Promise((resolve) => {
|
||||
loginWithQR((qr) => {
|
||||
resolve(NextResponse.json({ qr }));
|
||||
}).catch(err => {
|
||||
console.error(err);
|
||||
resolve(NextResponse.json({ error: "Failed to initialize login" }, { status: 500 }));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import db from "@/lib/db";
|
||||
|
||||
export async function GET() {
|
||||
const logs = db.prepare("SELECT * FROM messages ORDER BY timestamp DESC LIMIT 100").all();
|
||||
return NextResponse.json({ logs });
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import db, { getSettings, saveSetting } from "@/lib/db";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
smtp_host: getSettings("smtp_host") || "",
|
||||
smtp_port: getSettings("smtp_port") || "465",
|
||||
smtp_user: getSettings("smtp_user") || "",
|
||||
smtp_pass: getSettings("smtp_pass") || "",
|
||||
report_email: getSettings("report_email") || ""
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const data = await req.json();
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
saveSetting(key, String(value));
|
||||
}
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isConnected } from "@/lib/zalo";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({ connected: isConnected() });
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,26 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Zalo Monitor & Reporter",
|
||||
description: "Real-time monitoring and daily reporting for Zalo messages",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<body className="min-h-full flex flex-col" suppressHydrationWarning>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Shield, MessageSquare, Settings, Mail, Bell, Play, Square, RefreshCcw, FileText } from "lucide-react";
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const [activeTab, setActiveTab] = useState("monitor");
|
||||
const [status, setStatus] = useState({ connected: false, loading: true });
|
||||
const [qrCode, setQrCode] = useState<string | null>(null);
|
||||
const [logs, setLogs] = useState<any[]>([]);
|
||||
const [isLoggingIn, setIsLoggingIn] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus();
|
||||
fetchLogs();
|
||||
const interval = setInterval(fetchLogs, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/status");
|
||||
const text = await res.text();
|
||||
if (!res.ok) throw new Error(`Status error: ${res.status}`);
|
||||
try {
|
||||
const data = JSON.parse(text);
|
||||
setStatus({ connected: data.connected, loading: false });
|
||||
} catch (e) {
|
||||
console.error("Status JSON Parse Error:", text);
|
||||
throw new Error("Invalid status response");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Status Check Error:", e);
|
||||
setStatus({ connected: false, loading: false });
|
||||
}
|
||||
};
|
||||
|
||||
const fetchLogs = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/logs");
|
||||
const text = await res.text();
|
||||
if (!res.ok) throw new Error(`Logs error: ${res.status}`);
|
||||
try {
|
||||
const data = JSON.parse(text);
|
||||
setLogs(data.logs || []);
|
||||
} catch (e) {
|
||||
console.error("Logs JSON Parse Error:", text);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Logs Fetch Error:", e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (isLoggingIn) return;
|
||||
setIsLoggingIn(true);
|
||||
setQrCode(null);
|
||||
try {
|
||||
const res = await fetch("/api/login", { method: "POST" });
|
||||
const text = await res.text();
|
||||
|
||||
if (!res.ok) {
|
||||
console.error("Server Error Body:", text);
|
||||
throw new Error(`Server returned ${res.status}: ${res.statusText}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(text);
|
||||
if (data.qr) {
|
||||
setQrCode(data.qr);
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.error("JSON Parse Error. Body was:", text);
|
||||
throw new Error("Invalid response format from server");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Login Error:", e);
|
||||
alert(e instanceof Error ? e.message : "Failed to initialize login. Please try again.");
|
||||
} finally {
|
||||
setIsLoggingIn(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0a0a0c] text-slate-200 font-sans selection:bg-blue-500/30">
|
||||
{/* Background Glow */}
|
||||
<div className="fixed inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute -top-[10%] -left-[10%] w-[40%] h-[40%] bg-blue-600/10 blur-[120px] rounded-full" />
|
||||
<div className="absolute top-[60%] -right-[10%] w-[40%] h-[40%] bg-purple-600/10 blur-[120px] rounded-full" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 flex h-screen overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-64 border-r border-white/5 bg-black/20 backdrop-blur-xl flex flex-col">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-purple-600 rounded-xl flex items-center justify-center shadow-lg shadow-blue-500/20">
|
||||
<Shield className="text-white w-6 h-6" />
|
||||
</div>
|
||||
<h1 className="text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-white to-slate-400">
|
||||
Zalo Monitor
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 px-4 py-4 space-y-2">
|
||||
<NavItem
|
||||
active={activeTab === "monitor"}
|
||||
icon={<MessageSquare size={20} />}
|
||||
label="Monitor"
|
||||
onClick={() => setActiveTab("monitor")}
|
||||
/>
|
||||
<NavItem
|
||||
active={activeTab === "logs"}
|
||||
icon={<Bell size={20} />}
|
||||
label="Activity Logs"
|
||||
onClick={() => setActiveTab("logs")}
|
||||
/>
|
||||
<NavItem
|
||||
active={activeTab === "settings"}
|
||||
icon={<Settings size={20} />}
|
||||
label="Configuration"
|
||||
onClick={() => setActiveTab("settings")}
|
||||
/>
|
||||
</nav>
|
||||
|
||||
<div className="p-4 border-t border-white/5">
|
||||
<div className="bg-white/5 rounded-2xl p-4 flex items-center gap-3">
|
||||
<div className={cn(
|
||||
"w-3 h-3 rounded-full animate-pulse",
|
||||
status.connected ? "bg-green-500 shadow-[0_0_10px_rgba(34,197,94,0.5)]" : "bg-red-500"
|
||||
)} />
|
||||
<span className="text-sm font-medium">
|
||||
{status.loading ? "Connecting..." : status.connected ? "Connected" : "Disconnected"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 overflow-y-auto p-8">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
{activeTab === "monitor" && (
|
||||
<section className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
<header className="flex justify-between items-end">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold text-white">System Monitor</h2>
|
||||
<p className="text-slate-400 mt-1">Real-time surveillance and status control.</p>
|
||||
</div>
|
||||
{!status.connected && (
|
||||
<button
|
||||
onClick={handleLogin}
|
||||
disabled={isLoggingIn}
|
||||
className={cn(
|
||||
"px-6 py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-xl font-semibold flex items-center gap-2 transition-all shadow-lg shadow-blue-600/20",
|
||||
isLoggingIn ? "opacity-50 cursor-not-allowed" : "hover:scale-105 active:scale-95"
|
||||
)}
|
||||
>
|
||||
{isLoggingIn ? <RefreshCcw className="animate-spin" size={18} /> : <Play size={18} />}
|
||||
{isLoggingIn ? "Initializing..." : "Start Monitor"}
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<StatCard label="Messages Logged" value={logs.length.toString()} icon={<MessageSquare className="text-blue-400" />} />
|
||||
<StatCard label="Last Report" value="Yesterday, 00:01" icon={<FileText className="text-purple-400" />} />
|
||||
<StatCard label="Email Delivery" value="Active" icon={<Mail className="text-green-400" />} />
|
||||
</div>
|
||||
|
||||
{!status.connected && qrCode && (
|
||||
<div className="bg-white/5 border border-white/10 rounded-3xl p-8 flex flex-col items-center gap-6 backdrop-blur-md">
|
||||
<h3 className="text-xl font-semibold">Scan QR Code to Login</h3>
|
||||
<div className="p-4 bg-white rounded-2xl shadow-2xl">
|
||||
<img src={qrCode} alt="Zalo QR" className="w-64 h-64" />
|
||||
</div>
|
||||
<p className="text-slate-400 text-sm max-w-md text-center">
|
||||
Open Zalo on your phone, go to Menu {'>'} QR Code, and scan this code to link your account.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white/5 border border-white/10 rounded-3xl overflow-hidden backdrop-blur-md">
|
||||
<div className="p-6 border-b border-white/10 flex justify-between items-center">
|
||||
<h3 className="font-semibold text-lg">Recent Messages</h3>
|
||||
<button onClick={fetchLogs} className="p-2 hover:bg-white/5 rounded-lg transition-colors">
|
||||
<RefreshCcw size={18} className="text-slate-400" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="divide-y divide-white/5">
|
||||
{logs.slice(0, 5).map((log, i) => (
|
||||
<div key={i} className="p-4 flex items-start gap-4 hover:bg-white/[0.02] transition-colors">
|
||||
<div className="w-10 h-10 rounded-full bg-blue-500/10 flex items-center justify-center text-blue-400 font-bold shrink-0">
|
||||
{log.senderName?.[0] || "?"}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className="font-medium text-slate-100">{log.senderName}</span>
|
||||
<span className="text-xs text-slate-500">{new Date(log.timestamp).toLocaleTimeString()}</span>
|
||||
</div>
|
||||
<p className="text-slate-400 text-sm truncate">{log.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{logs.length === 0 && (
|
||||
<div className="p-12 text-center text-slate-500">
|
||||
No activity recorded yet.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{activeTab === "logs" && <ActivityLogs logs={logs} />}
|
||||
{activeTab === "settings" && <SettingsPage />}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NavItem({ icon, label, active, onClick }: any) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-200",
|
||||
active
|
||||
? "bg-blue-600/10 text-blue-400 border border-blue-500/20"
|
||||
: "text-slate-400 hover:bg-white/5 hover:text-slate-200"
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
<span className="font-medium">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ label, value, icon }: any) {
|
||||
return (
|
||||
<div className="bg-white/5 border border-white/10 rounded-2xl p-6 backdrop-blur-md">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className="p-3 bg-white/5 rounded-xl">{icon}</div>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white">{value}</div>
|
||||
<div className="text-sm text-slate-400 mt-1">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityLogs({ logs }: any) {
|
||||
return (
|
||||
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
<h2 className="text-3xl font-bold text-white">Activity Logs</h2>
|
||||
<div className="bg-white/5 border border-white/10 rounded-3xl overflow-hidden backdrop-blur-md">
|
||||
<table className="w-full text-left">
|
||||
<thead className="bg-white/5 text-slate-400 text-sm uppercase tracking-wider">
|
||||
<tr>
|
||||
<th className="px-6 py-4 font-medium">Timestamp</th>
|
||||
<th className="px-6 py-4 font-medium">Sender</th>
|
||||
<th className="px-6 py-4 font-medium">Type</th>
|
||||
<th className="px-6 py-4 font-medium">Content</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">
|
||||
{logs.map((log: any, i: number) => (
|
||||
<tr key={i} className="hover:bg-white/[0.02] transition-colors">
|
||||
<td className="px-6 py-4 text-sm text-slate-400">
|
||||
{new Date(log.timestamp).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 font-medium text-slate-200">{log.senderName}</td>
|
||||
<td className="px-6 py-4 text-sm">
|
||||
<span className={cn(
|
||||
"px-2 py-1 rounded-md text-xs font-bold",
|
||||
log.threadType === "Group" ? "bg-purple-500/10 text-purple-400" : "bg-blue-500/10 text-blue-400"
|
||||
)}>
|
||||
{log.threadType}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-slate-400 text-sm max-w-md truncate">
|
||||
{log.content}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsPage() {
|
||||
const [settings, setSettings] = useState({
|
||||
smtp_host: "",
|
||||
smtp_port: "465",
|
||||
smtp_user: "",
|
||||
smtp_pass: "",
|
||||
report_email: ""
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/settings").then(res => res.json()).then(data => setSettings(data));
|
||||
}, []);
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
await fetch("/api/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(settings)
|
||||
});
|
||||
alert("Settings saved!");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
<h2 className="text-3xl font-bold text-white">Configuration</h2>
|
||||
<form onSubmit={handleSave} className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="bg-white/5 border border-white/10 rounded-3xl p-8 space-y-6 backdrop-blur-md">
|
||||
<h3 className="text-xl font-semibold flex items-center gap-2">
|
||||
<Mail size={20} className="text-blue-400" />
|
||||
Email Settings
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label="Recipient Email"
|
||||
value={settings.report_email}
|
||||
onChange={(v: string) => setSettings({...settings, report_email: v})}
|
||||
placeholder="your@email.com"
|
||||
/>
|
||||
<Input
|
||||
label="SMTP Host"
|
||||
value={settings.smtp_host}
|
||||
onChange={(v: string) => setSettings({...settings, smtp_host: v})}
|
||||
placeholder="smtp.gmail.com"
|
||||
/>
|
||||
<Input
|
||||
label="SMTP Port"
|
||||
value={settings.smtp_port}
|
||||
onChange={(v: string) => setSettings({...settings, smtp_port: v})}
|
||||
placeholder="465"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/5 border border-white/10 rounded-3xl p-8 space-y-6 backdrop-blur-md">
|
||||
<h3 className="text-xl font-semibold flex items-center gap-2">
|
||||
<Shield size={20} className="text-purple-400" />
|
||||
Authentication
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label="SMTP User"
|
||||
value={settings.smtp_user}
|
||||
onChange={(v: string) => setSettings({...settings, smtp_user: v})}
|
||||
placeholder="your@gmail.com"
|
||||
/>
|
||||
<Input
|
||||
label="SMTP Password"
|
||||
value={settings.smtp_pass}
|
||||
onChange={(v: string) => setSettings({...settings, smtp_pass: v})}
|
||||
type="password"
|
||||
placeholder="App Password"
|
||||
/>
|
||||
</div>
|
||||
<div className="pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full py-4 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-500 hover:to-purple-500 text-white rounded-2xl font-bold transition-all shadow-lg shadow-blue-600/20"
|
||||
>
|
||||
Save Configuration
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Input({ label, value, onChange, placeholder, type = "text" }: any) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-slate-400 ml-1">{label}</label>
|
||||
<input
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-slate-200 placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:border-blue-500/40 transition-all"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export async function register() {
|
||||
if (process.env.NEXT_RUNTIME === "node") {
|
||||
const { initCron } = await import("./lib/cron");
|
||||
const { getZaloApi } = await import("./lib/zalo");
|
||||
|
||||
initCron();
|
||||
|
||||
// Attempt to login if credentials exist
|
||||
getZaloApi().catch(err => {
|
||||
console.error("Initial Zalo login failed:", err);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import cron from "node-cron";
|
||||
import { processDailyReport } from "./reporting";
|
||||
|
||||
export function initCron() {
|
||||
// Run every day at 00:01 AM
|
||||
cron.schedule("1 0 * * *", () => {
|
||||
console.log("Running daily report job...");
|
||||
processDailyReport();
|
||||
});
|
||||
|
||||
console.log("Cron jobs initialized");
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
import Database from "better-sqlite3";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
|
||||
const DB_PATH = path.join(process.cwd(), "data/monitor.db");
|
||||
|
||||
// Ensure data directory exists
|
||||
if (!fs.existsSync(path.join(process.cwd(), "data"))) {
|
||||
fs.mkdirSync(path.join(process.cwd(), "data"));
|
||||
}
|
||||
|
||||
const db = new Database(DB_PATH);
|
||||
|
||||
// Initialize schema
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
threadId TEXT NOT NULL,
|
||||
senderId TEXT NOT NULL,
|
||||
senderName TEXT,
|
||||
content TEXT,
|
||||
timestamp INTEGER NOT NULL,
|
||||
threadType TEXT NOT NULL,
|
||||
isSelf INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS threads (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
type TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS credentials (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
cookie TEXT,
|
||||
imei TEXT,
|
||||
userAgent TEXT,
|
||||
updatedAt INTEGER
|
||||
);
|
||||
`);
|
||||
|
||||
export default db;
|
||||
|
||||
export function saveMessage(message: any) {
|
||||
const stmt = db.prepare(`
|
||||
INSERT OR REPLACE INTO messages (id, threadId, senderId, senderName, content, timestamp, threadType, isSelf)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
let content = "[Unknown Content]";
|
||||
try {
|
||||
content = typeof message.data.content === "string"
|
||||
? message.data.content
|
||||
: JSON.stringify(message.data.content);
|
||||
} catch (e) {
|
||||
content = "[Complex Object Content]";
|
||||
}
|
||||
|
||||
stmt.run(
|
||||
message.data.msgId || `tmp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
message.threadId,
|
||||
message.data.uidFrom,
|
||||
message.data.senderName || message.data.uidFrom,
|
||||
content,
|
||||
message.data.timestamp || Date.now(),
|
||||
message.type,
|
||||
message.isSelf ? 1 : 0
|
||||
);
|
||||
}
|
||||
|
||||
export function getSettings(key: string): string | null {
|
||||
const stmt = db.prepare("SELECT value FROM settings WHERE key = ?");
|
||||
const row = stmt.get(key) as { value: string } | undefined;
|
||||
return row ? row.value : null;
|
||||
}
|
||||
|
||||
export function saveSetting(key: string, value: string) {
|
||||
const stmt = db.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)");
|
||||
stmt.run(key, value);
|
||||
}
|
||||
|
||||
export function saveCredentials(cookie: any, imei: string, userAgent: string) {
|
||||
const stmt = db.prepare(`
|
||||
INSERT OR REPLACE INTO credentials (id, cookie, imei, userAgent, updatedAt)
|
||||
VALUES (1, ?, ?, ?, ?)
|
||||
`);
|
||||
stmt.run(JSON.stringify(cookie), imei, userAgent, Date.now());
|
||||
}
|
||||
|
||||
export function getCredentials() {
|
||||
const stmt = db.prepare("SELECT * FROM credentials WHERE id = 1");
|
||||
const row = stmt.get() as any;
|
||||
if (row) {
|
||||
return {
|
||||
cookie: JSON.parse(row.cookie),
|
||||
imei: row.imei,
|
||||
userAgent: row.userAgent
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import PDFDocument from "pdfkit";
|
||||
import nodemailer from "nodemailer";
|
||||
import db, { getSettings } from "./db";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export async function generateDailyReport(date: Date) {
|
||||
const startOfDay = new Date(date);
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
const endOfDay = new Date(date);
|
||||
endOfDay.setHours(23, 59, 59, 999);
|
||||
|
||||
const messages = db.prepare(`
|
||||
SELECT * FROM messages
|
||||
WHERE timestamp >= ? AND timestamp <= ?
|
||||
ORDER BY timestamp ASC
|
||||
`).all(startOfDay.getTime(), endOfDay.getTime()) as any[];
|
||||
|
||||
if (messages.length === 0) {
|
||||
console.log("No messages to report for", date.toDateString());
|
||||
return null;
|
||||
}
|
||||
|
||||
const doc = new PDFDocument();
|
||||
const fileName = `report-${date.toISOString().split("T")[0]}.pdf`;
|
||||
const filePath = path.join(process.cwd(), "data", fileName);
|
||||
const stream = fs.createWriteStream(filePath);
|
||||
|
||||
doc.pipe(stream);
|
||||
|
||||
// Title
|
||||
doc.fontSize(20).text(`Zalo Daily Report - ${date.toDateString()}`, { align: "center" });
|
||||
doc.moveDown();
|
||||
|
||||
// Group by thread
|
||||
const threads: Record<string, any[]> = {};
|
||||
messages.forEach(msg => {
|
||||
if (!threads[msg.threadId]) threads[msg.threadId] = [];
|
||||
threads[msg.threadId].push(msg);
|
||||
});
|
||||
|
||||
for (const [threadId, msgs] of Object.entries(threads)) {
|
||||
const threadName = msgs[0].threadType === "Group" ? `Group: ${threadId}` : `User: ${msgs[0].senderName}`;
|
||||
doc.fontSize(16).text(threadName, { underline: true });
|
||||
doc.moveDown(0.5);
|
||||
|
||||
msgs.forEach(msg => {
|
||||
const time = new Date(msg.timestamp).toLocaleTimeString();
|
||||
doc.fontSize(10).text(`[${time}] ${msg.senderName}:`, { continued: true });
|
||||
doc.fontSize(11).text(` ${msg.content}`);
|
||||
});
|
||||
doc.moveDown();
|
||||
}
|
||||
|
||||
doc.end();
|
||||
|
||||
return new Promise<string>((resolve) => {
|
||||
stream.on("finish", () => resolve(filePath));
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendEmail(filePath: string, recipient: string) {
|
||||
const smtpHost = getSettings("smtp_host") || "smtp.gmail.com";
|
||||
const smtpPort = parseInt(getSettings("smtp_port") || "465");
|
||||
const smtpUser = getSettings("smtp_user");
|
||||
const smtpPass = getSettings("smtp_pass");
|
||||
|
||||
if (!smtpUser || !smtpPass) {
|
||||
throw new Error("SMTP credentials not configured");
|
||||
}
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: smtpHost,
|
||||
port: smtpPort,
|
||||
secure: smtpPort === 465,
|
||||
auth: {
|
||||
user: smtpUser,
|
||||
pass: smtpPass,
|
||||
},
|
||||
});
|
||||
|
||||
const info = await transporter.sendMail({
|
||||
from: `"Zalo Monitor" <${smtpUser}>`,
|
||||
to: recipient,
|
||||
subject: `Zalo Daily Report - ${new Date().toDateString()}`,
|
||||
text: "Please find the attached daily report for your Zalo messages.",
|
||||
attachments: [
|
||||
{
|
||||
filename: path.basename(filePath),
|
||||
path: filePath,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
console.log("Email sent: %s", info.messageId);
|
||||
return info;
|
||||
}
|
||||
|
||||
export async function processDailyReport() {
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
const recipient = getSettings("report_email");
|
||||
if (!recipient) {
|
||||
console.error("Report email not configured");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const filePath = await generateDailyReport(yesterday);
|
||||
if (filePath) {
|
||||
await sendEmail(filePath, recipient);
|
||||
// Optional: delete file after sending
|
||||
// fs.unlinkSync(filePath);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to process daily report:", error);
|
||||
}
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
import { Zalo, ThreadType, LoginQRCallbackEventType } from "zca-js";
|
||||
import db, { saveMessage, saveCredentials, getCredentials } from "./db";
|
||||
|
||||
let zaloInstance: Zalo | null = null;
|
||||
let apiInstance: any = null;
|
||||
let isInitializing = false;
|
||||
|
||||
function getZaloInstance() {
|
||||
if (!zaloInstance) {
|
||||
zaloInstance = new Zalo();
|
||||
}
|
||||
return zaloInstance;
|
||||
}
|
||||
|
||||
export async function getZaloApi() {
|
||||
if (apiInstance) return apiInstance;
|
||||
|
||||
const creds = getCredentials();
|
||||
if (creds) {
|
||||
try {
|
||||
console.log("Attempting to login with saved credentials...");
|
||||
const zalo = getZaloInstance();
|
||||
const api = await zalo.login(creds);
|
||||
apiInstance = api;
|
||||
setupListener(api);
|
||||
return api;
|
||||
} catch (error) {
|
||||
console.error("Failed to login with saved credentials:", error);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function loginWithQR(onQrGenerated: (qrData: string) => void) {
|
||||
if (isInitializing) {
|
||||
console.log("Already initializing Zalo login...");
|
||||
return;
|
||||
}
|
||||
|
||||
isInitializing = true;
|
||||
console.log("Initializing Zalo QR login...");
|
||||
const zalo = getZaloInstance();
|
||||
|
||||
try {
|
||||
console.log("Calling zalo.loginQR...");
|
||||
const api = await zalo.loginQR({}, (qrData: any) => {
|
||||
console.log("Zalo login event type:", qrData.type);
|
||||
if (qrData.type === LoginQRCallbackEventType.QRCodeGenerated) {
|
||||
console.log("QR Code generated successfully");
|
||||
let image = qrData.data.image;
|
||||
if (!image.startsWith("data:image")) {
|
||||
image = `data:image/png;base64,${image}`;
|
||||
}
|
||||
onQrGenerated(image);
|
||||
}
|
||||
});
|
||||
|
||||
console.log("Zalo login scan completed successfully");
|
||||
const context = api.getContext();
|
||||
saveCredentials(
|
||||
context.cookie.toJSON()?.cookies,
|
||||
context.imei,
|
||||
context.userAgent
|
||||
);
|
||||
|
||||
apiInstance = api;
|
||||
setupListener(api);
|
||||
return api;
|
||||
} catch (error) {
|
||||
console.error("Zalo loginQR process failed:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
isInitializing = false;
|
||||
}
|
||||
}
|
||||
|
||||
function setupListener(api: any) {
|
||||
api.listener.on("message", (message: any) => {
|
||||
let contentStr = "[Unknown Content]";
|
||||
try {
|
||||
contentStr = typeof message.data.content === "string"
|
||||
? message.data.content
|
||||
: JSON.stringify(message.data.content);
|
||||
} catch (e) {
|
||||
contentStr = "[Complex Object Content]";
|
||||
}
|
||||
|
||||
console.log(`Received message from ${message.data.senderName || message.data.uidFrom}:`, contentStr);
|
||||
saveMessage(message);
|
||||
});
|
||||
|
||||
api.listener.onConnected(() => {
|
||||
console.log("Zalo listener connected");
|
||||
});
|
||||
|
||||
api.listener.onClosed(() => {
|
||||
console.log("Zalo listener closed");
|
||||
apiInstance = null;
|
||||
});
|
||||
|
||||
api.listener.onError((error: any) => {
|
||||
console.error("Zalo listener error:", error);
|
||||
});
|
||||
|
||||
api.listener.start();
|
||||
}
|
||||
|
||||
export function isConnected() {
|
||||
return !!apiInstance;
|
||||
}
|
||||
Reference in New Issue
Block a user