Skip to content

Commit 0c6c89f

Browse files
committed
feat: tạo server
1 parent 99b5767 commit 0c6c89f

5 files changed

Lines changed: 341 additions & 3 deletions

File tree

.env

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# Copy file này thành ".env" (cùng thư mục với server.ts) rồi điền giá trị thật vào
2+
# Chạy: deno run --allow-net --unstable-kv --env-file server.ts
3+
4+
AUTH_TOKEN=secret123
5+
TELEGRAM_BOT_TOKEN = 8986035970:AAFw1V-wj_g_pV4h-IGULTjfwc0FdnEHyB4
6+
TELEGRAM_CHAT_ID = 6227530380
7+
PORT=8080
8+

Momo Lý Minh Nhật.jpg

51.1 KB
Loading

index.html

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
<button id="sound" onClick="playPause()">
2+
<img src="./Momo Lý Minh Nhật.jpg">
23
<audio src="https://ff83c814-a2d5-4105-9a45-be3bec448ed7.mdnplay.dev/shared-assets/audio/t-rex-roar.mp3"></audio>
34
Sound
45
</button>
@@ -20,9 +21,9 @@
2021
}
2122

2223
const list = ["a", "b", "c", "d"]
23-
document.getElementsByTagName("div")[0].innerHTML=list.at(list.length*Math.random())
24+
document.getElementById("text").innerHTML=list.at(list.length*Math.random())
2425
function change() {
25-
document.getElementsByTagName("div")[0].innerHTML=list.at(list.length*Math.random())
26+
document.getElementById("text").innerHTML=list.at(list.length*Math.random())
2627
}
2728
</script>
2829

@@ -33,6 +34,6 @@
3334
display: flex;
3435
justify-content: center; /* Centers horizontally */
3536
align-items: center; /* Centers vertically */
36-
zoom: 2
37+
/* zoom: 2 */
3738
}
3839
</style>

notifications.log.jsonl

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{"receivedAt":"2026-07-02T06:51:28.194Z","app":"com.zing.zalo","title":"Tin nhan moi","text":"Xin chao","time":"12:00"}
2+
{"receivedAt":"2026-07-02T06:53:14.608Z","app":"com.zing.zalo","title":"Tin nhan moi","text":"Xin chao","time":"12:00"}
3+
{"receivedAt":"2026-07-02T08:14:05.875Z","app":"com.zing.zalo","title":"Tin nhan moi","text":"Xin chao","time":"12:00"}
4+
{"receivedAt":"2026-07-02T08:14:21.316Z","app":"com.zing.zalo","title":"Tin nhan moi","text":"Xin chao","time":"12:00"}

server.ts

Lines changed: 325 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,325 @@
1+
// server.ts
2+
// Server Deno nhận webhook thông báo (notification) từ Tasker
3+
// - Dashboard xem log CÔNG KHAI tại "/" (ai có link cũng xem được)
4+
// - Dùng Temporal API (Deno 2.7+, stable, không cần --unstable-temporal) thay cho Date
5+
// - Log console bằng LogTape, hiển thị giờ GMT+7 (Asia/Ho_Chi_Minh)
6+
// - Lưu lịch sử vào Deno KV
7+
// - Forward thông báo sang Telegram (plain text, tránh lỗi escape MarkdownV2)
8+
//
9+
// Yêu cầu: Deno 2.7 trở lên (deno --version để kiểm tra; deno upgrade nếu cần)
10+
//
11+
// Chạy local:
12+
// deno run --allow-net --unstable-kv --env-file server.ts
13+
//
14+
// Test thử (PowerShell):
15+
// Invoke-RestMethod -Uri "http://127.0.0.1:8080/api/notify" -Method Post `
16+
// -Headers @{ "Authorization" = "Bearer secret123" } -ContentType "application/json" `
17+
// -Body '{"app":"com.zing.zalo","title":"Tin nhan moi","text":"Xin chao","time":"12:00"}'
18+
//
19+
// LƯU Ý BẢO MẬT: Dashboard ("/") và "/api/history" ở bản này KHÔNG có mật khẩu,
20+
// ai có đường link cũng xem được toàn bộ log thông báo. Chỉ endpoint "/api/notify"
21+
// (nơi ghi dữ liệu) vẫn yêu cầu Authorization token.
22+
23+
import {
24+
configure,
25+
getConsoleSink,
26+
getLogger,
27+
type LogRecord,
28+
} from "jsr:@logtape/logtape";
29+
30+
// ====== CẤU HÌNH ======
31+
const AUTH_TOKEN = Deno.env.get("AUTH_TOKEN") ?? "secret123";
32+
const TELEGRAM_BOT_TOKEN = Deno.env.get("TELEGRAM_BOT_TOKEN") ?? "";
33+
const TELEGRAM_CHAT_ID = Deno.env.get("TELEGRAM_CHAT_ID") ?? "";
34+
const PORT = Number(Deno.env.get("PORT") ?? 8080);
35+
const TIMEZONE = "Asia/Ho_Chi_Minh"; // GMT+7
36+
37+
// ====== TEMPORAL: giờ hiện tại theo GMT+7 ======
38+
// Trả về Temporal.ZonedDateTime tại thời điểm hiện tại, đúng múi giờ VN
39+
function nowInVietnam(): Temporal.ZonedDateTime {
40+
return Temporal.Now.zonedDateTimeISO(TIMEZONE);
41+
}
42+
43+
// Format 1 Instant (mốc thời gian tuyệt đối) sang chuỗi hiển thị GMT+7 dễ đọc
44+
function formatGmt7(instant: Temporal.Instant): string {
45+
const zdt = instant.toZonedDateTimeISO(TIMEZONE);
46+
const pad = (n: number) => String(n).padStart(2, "0");
47+
return `${zdt.year}-${pad(zdt.month)}-${pad(zdt.day)} ` +
48+
`${pad(zdt.hour)}:${pad(zdt.minute)}:${pad(zdt.second)} (GMT+7)`;
49+
}
50+
51+
function textFormatter(record: LogRecord): string {
52+
// LogTape cấp timestamp dạng epoch millisecond -> chuyển sang Temporal.Instant
53+
const instant = Temporal.Instant.fromEpochMilliseconds(record.timestamp);
54+
const time = formatGmt7(instant);
55+
const level = record.level.toUpperCase().padEnd(5);
56+
const category = record.category.join(".");
57+
const message = record.message.join("");
58+
return `[${time}] ${level} ${category} - ${message}`;
59+
}
60+
61+
await configure({
62+
sinks: { console: getConsoleSink({ formatter: textFormatter }) },
63+
loggers: [
64+
{ category: ["app"], lowestLevel: "info", sinks: ["console"] },
65+
{ category: ["logtape", "meta"], lowestLevel: "warning", sinks: ["console"] },
66+
],
67+
});
68+
69+
const logger = getLogger(["app"]);
70+
71+
// ====== DENO KV ======
72+
const kv = await Deno.openKv();
73+
74+
interface NotificationPayload {
75+
app?: string;
76+
title?: string;
77+
text?: string;
78+
time?: string;
79+
[key: string]: unknown;
80+
}
81+
82+
interface StoredEntry {
83+
receivedAtGmt7: string;
84+
receivedAtIso: string; // Temporal.Instant.toString() - chuẩn ISO 8601 UTC
85+
app?: string;
86+
title?: string;
87+
text?: string;
88+
telegramStatus: "sent" | "failed" | "skipped";
89+
telegramError?: string;
90+
}
91+
92+
// ====== FORWARD SANG TELEGRAM (plain text, tránh lỗi escape MarkdownV2) ======
93+
async function sendToTelegram(
94+
payload: NotificationPayload,
95+
receivedAtGmt7: string,
96+
): Promise<{ status: "sent" | "failed" | "skipped"; error?: string }> {
97+
if (!TELEGRAM_BOT_TOKEN || !TELEGRAM_CHAT_ID) {
98+
return { status: "skipped", error: "Chưa cấu hình TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID" };
99+
}
100+
101+
const message =
102+
`📱 ${payload.app ?? "Không rõ app"}\n` +
103+
`${payload.title ?? ""}\n` +
104+
`${payload.text ?? ""}\n` +
105+
`🕒 ${receivedAtGmt7}`;
106+
107+
const url = `https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage`;
108+
109+
try {
110+
const res = await fetch(url, {
111+
method: "POST",
112+
headers: { "Content-Type": "application/json" },
113+
body: JSON.stringify({ chat_id: TELEGRAM_CHAT_ID, text: message }),
114+
});
115+
if (!res.ok) {
116+
const errText = await res.text();
117+
logger.error("Gửi Telegram thất bại: {status} {body}", { status: res.status, body: errText });
118+
return { status: "failed", error: `${res.status}: ${errText}` };
119+
}
120+
return { status: "sent" };
121+
} catch (err) {
122+
logger.error("Lỗi khi gọi Telegram API: {error}", { error: String(err) });
123+
return { status: "failed", error: String(err) };
124+
}
125+
}
126+
127+
// ====== HANDLERS ======
128+
async function handleNotify(req: Request): Promise<Response> {
129+
const reqtext = await req.text()
130+
console.log("test")
131+
console.log(reqtext)
132+
logger.info(reqtext)
133+
if (req.method !== "POST") {
134+
return new Response("Method Not Allowed", { status: 405 });
135+
}
136+
137+
const authHeader = req.headers.get("Authorization") ?? "";
138+
if (AUTH_TOKEN && authHeader !== `Bearer ${AUTH_TOKEN}`) {
139+
logger.warn("Từ chối request thiếu/sai token");
140+
return new Response(JSON.stringify({ error: "Unauthorized" }), {
141+
status: 401,
142+
headers: { "Content-Type": "application/json" },
143+
});
144+
}
145+
146+
let payload: NotificationPayload;
147+
try {
148+
payload = await req.json();
149+
} catch {
150+
return new Response(JSON.stringify({ error: "Invalid JSON body" }), {
151+
status: 400,
152+
headers: { "Content-Type": "application/json" },
153+
});
154+
}
155+
156+
const nowInstant = Temporal.Now.instant();
157+
const receivedAtGmt7 = formatGmt7(nowInstant);
158+
logger.info("app={app} title={title} text={text}", {
159+
app: payload.app ?? "?",
160+
title: payload.title ?? "",
161+
text: payload.text ?? "",
162+
});
163+
164+
const tgResult = await sendToTelegram(payload, receivedAtGmt7);
165+
166+
const entry: StoredEntry = {
167+
receivedAtGmt7,
168+
receivedAtIso: nowInstant.toString(),
169+
app: payload.app,
170+
title: payload.title,
171+
text: payload.text,
172+
telegramStatus: tgResult.status,
173+
telegramError: tgResult.error,
174+
};
175+
176+
// Key theo thời gian để Deno KV giữ đúng thứ tự khi list ngược (mới nhất trước)
177+
const key = ["notifications", nowInstant.toString(), crypto.randomUUID()];
178+
await kv.set(key, entry);
179+
180+
return new Response(JSON.stringify({ status: "ok", receivedAt: receivedAtGmt7, telegram: tgResult.status }), {
181+
status: 200,
182+
headers: { "Content-Type": "application/json" },
183+
});
184+
}
185+
186+
async function getRecentEntries(limit = 100): Promise<StoredEntry[]> {
187+
const items: StoredEntry[] = [];
188+
const entries = kv.list<StoredEntry>({ prefix: ["notifications"] }, { reverse: true, limit });
189+
for await (const entry of entries) {
190+
items.push(entry.value);
191+
}
192+
return items;
193+
}
194+
195+
async function handleHistory(_req: Request): Promise<Response> {
196+
const items = await getRecentEntries(100);
197+
return new Response(JSON.stringify(items, null, 2), {
198+
headers: { "Content-Type": "application/json" },
199+
});
200+
}
201+
202+
// Dashboard công khai — không yêu cầu key/đăng nhập
203+
function renderDashboard(): string {
204+
return `<!DOCTYPE html>
205+
<html lang="vi">
206+
<head>
207+
<meta charset="UTF-8" />
208+
<meta name="viewport" content="width=device-width, initial-scale=1" />
209+
<title>Tasker Webhook Log</title>
210+
<style>
211+
:root { color-scheme: dark; }
212+
* { box-sizing: border-box; }
213+
body {
214+
margin: 0; padding: 24px; background: #0f1115; color: #e6e6e6;
215+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
216+
}
217+
h1 { font-size: 20px; margin: 0 0 4px; }
218+
.sub { color: #9aa0a6; font-size: 13px; margin-bottom: 20px; }
219+
table { width: 100%; border-collapse: collapse; font-size: 13px; }
220+
th, td { text-align: left; padding: 10px 12px; border-bottom: 1px solid #2a2d34; vertical-align: top; }
221+
th { color: #9aa0a6; font-weight: 600; position: sticky; top: 0; background: #0f1115; }
222+
tr:hover { background: #171a20; }
223+
.badge { padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 600; }
224+
.sent { background: #103a20; color: #4ade80; }
225+
.failed { background: #3a1010; color: #f87171; }
226+
.skipped { background: #2a2a10; color: #facc15; }
227+
.app { color: #8ab4f8; font-family: monospace; font-size: 12px; }
228+
.empty { color: #9aa0a6; padding: 40px; text-align: center; }
229+
.time { white-space: nowrap; color: #9aa0a6; }
230+
#status { font-size: 12px; color: #6b7280; margin-bottom: 12px; }
231+
</style>
232+
</head>
233+
<body>
234+
<h1>📋 Tasker Webhook Log</h1>
235+
<div class="sub">Tự động cập nhật mỗi 5 giây — GMT+7 — trang này công khai</div>
236+
<div id="status">Đang tải...</div>
237+
<table>
238+
<thead>
239+
<tr>
240+
<th>Thời gian</th>
241+
<th>App</th>
242+
<th>Tiêu đề</th>
243+
<th>Nội dung</th>
244+
<th>Telegram</th>
245+
</tr>
246+
</thead>
247+
<tbody id="rows"></tbody>
248+
</table>
249+
250+
<script>
251+
function escapeHtml(s) {
252+
if (s === undefined || s === null) return "";
253+
return String(s).replace(/[&<>"']/g, (c) => ({
254+
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
255+
}[c]));
256+
}
257+
258+
function badge(status) {
259+
const map = { sent: "Đã gửi", failed: "Thất bại", skipped: "Bỏ qua" };
260+
return '<span class="badge ' + status + '">' + (map[status] || status) + '</span>';
261+
}
262+
263+
async function refresh() {
264+
try {
265+
const res = await fetch("/api/history");
266+
if (!res.ok) {
267+
document.getElementById("status").textContent = "Lỗi tải log: HTTP " + res.status;
268+
return;
269+
}
270+
const items = await res.json();
271+
const rows = document.getElementById("rows");
272+
if (!items.length) {
273+
rows.innerHTML = '<tr><td colspan="5" class="empty">Chưa có thông báo nào được nhận</td></tr>';
274+
} else {
275+
rows.innerHTML = items.map((it) => \`
276+
<tr>
277+
<td class="time">\${escapeHtml(it.receivedAtGmt7)}</td>
278+
<td class="app">\${escapeHtml(it.app)}</td>
279+
<td>\${escapeHtml(it.title)}</td>
280+
<td>\${escapeHtml(it.text)}</td>
281+
<td>\${badge(it.telegramStatus)}\${it.telegramError ? '<div style="color:#f87171;font-size:11px;margin-top:4px">' + escapeHtml(it.telegramError) + '</div>' : ""}</td>
282+
</tr>
283+
\`).join("");
284+
}
285+
document.getElementById("status").textContent =
286+
"Cập nhật lúc " + new Date().toLocaleTimeString("vi-VN") + " — " + items.length + " thông báo gần nhất";
287+
} catch (e) {
288+
document.getElementById("status").textContent = "Lỗi kết nối: " + e;
289+
}
290+
}
291+
292+
refresh();
293+
setInterval(refresh, 5000);
294+
</script>
295+
</body>
296+
</html>`;
297+
}
298+
299+
Deno.serve({ port: PORT }, async (req: Request) => {
300+
const url = new URL(req.url);
301+
302+
if (url.pathname === "/api/notify") {
303+
return await handleNotify(req);
304+
}
305+
306+
if (url.pathname === "/api/history" && req.method === "GET") {
307+
return await handleHistory(req);
308+
}
309+
310+
if (url.pathname === "/" && req.method === "GET") {
311+
return new Response(renderDashboard(), {
312+
headers: { "Content-Type": "text/html; charset=utf-8" },
313+
});
314+
}
315+
316+
if (url.pathname === "/health") {
317+
return new Response("Tasker webhook server đang chạy ✅", { status: 200 });
318+
}
319+
320+
return new Response("Not Found", { status: 404 });
321+
});
322+
323+
// logger.info("Telegram forward: {status}", {
324+
// status: TELEGRAM_BOT_TOKEN ? "BẬT ✅" : "TẮT (chưa cấu hình token)",
325+
// });

0 commit comments

Comments
 (0)