Build Telegram holiday bot
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
.git
|
||||
.agents
|
||||
node_modules
|
||||
data
|
||||
*.log
|
||||
.env
|
||||
@@ -0,0 +1,2 @@
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
ADMIN_PASSWORD=change-me
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
data/
|
||||
.env
|
||||
*.log
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY package.json ./
|
||||
COPY src ./src
|
||||
COPY public ./public
|
||||
|
||||
RUN mkdir -p /data
|
||||
|
||||
EXPOSE 8080
|
||||
VOLUME ["/data"]
|
||||
|
||||
CMD ["npm", "start"]
|
||||
@@ -0,0 +1,32 @@
|
||||
# TagDesX
|
||||
|
||||
Telegram bot and admin UI for daily messages from the Kuriose Feiertage calendar.
|
||||
|
||||
## Configuration
|
||||
|
||||
Copy `.env.example` to `.env` on the deployment host and set:
|
||||
|
||||
- `TELEGRAM_BOT_TOKEN`: token from BotFather
|
||||
- `ADMIN_PASSWORD`: password for the admin UI
|
||||
|
||||
The service listens on port `8080` in the container and is published as `8088` by `docker-compose.yml`.
|
||||
|
||||
## Commands
|
||||
|
||||
```sh
|
||||
npm run check
|
||||
npm start
|
||||
```
|
||||
|
||||
## Bot
|
||||
|
||||
- `/start` subscribes the chat to the daily 09:00 Europe/Berlin message.
|
||||
- `/stop` unsubscribes.
|
||||
- `/today` sends today's holidays immediately.
|
||||
- Any other message returns a short help text.
|
||||
|
||||
## Admin UI
|
||||
|
||||
Open `/` and log in with `ADMIN_PASSWORD`.
|
||||
|
||||
The UI can refresh the scraped calendar data, preview today's entries, list subscribers, send a test message to all active subscribers, and trigger the daily message manually.
|
||||
@@ -0,0 +1,18 @@
|
||||
services:
|
||||
tagdesx:
|
||||
build: .
|
||||
container_name: tagdesx
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
PORT: "8080"
|
||||
DATA_DIR: "/data"
|
||||
TZ: "Europe/Berlin"
|
||||
ADMIN_PASSWORD: "${ADMIN_PASSWORD:?set ADMIN_PASSWORD}"
|
||||
TELEGRAM_BOT_TOKEN: "${TELEGRAM_BOT_TOKEN:-}"
|
||||
volumes:
|
||||
- tagdesx-data:/data
|
||||
ports:
|
||||
- "8088:8080"
|
||||
|
||||
volumes:
|
||||
tagdesx-data:
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "tagdesx",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node src/server.js",
|
||||
"check": "node --check src/server.js && node --check src/lib/store.js && node --check src/lib/scraper.js && node --check src/lib/telegram.js && node --check src/lib/scheduler.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
let token = sessionStorage.getItem("tagdesx-admin-token") ?? "";
|
||||
|
||||
const login = document.querySelector("#login");
|
||||
const app = document.querySelector("#app");
|
||||
const loginForm = document.querySelector("#login-form");
|
||||
const loginError = document.querySelector("#login-error");
|
||||
const status = document.querySelector("#status");
|
||||
|
||||
loginForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const password = document.querySelector("#password").value;
|
||||
loginError.textContent = "";
|
||||
|
||||
const response = await fetch("/api/login", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ password })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
loginError.textContent = "Login fehlgeschlagen.";
|
||||
return;
|
||||
}
|
||||
|
||||
token = password;
|
||||
sessionStorage.setItem("tagdesx-admin-token", token);
|
||||
showApp();
|
||||
await loadState();
|
||||
});
|
||||
|
||||
document.querySelector("#refresh").addEventListener("click", async () => {
|
||||
await action("Daten werden importiert...", () => api("/api/import", { method: "POST" }));
|
||||
await loadState();
|
||||
});
|
||||
|
||||
document.querySelector("#send-test").addEventListener("click", async () => {
|
||||
await action("Testversand laeuft...", () => api("/api/send-test", { method: "POST" }));
|
||||
await loadState();
|
||||
});
|
||||
|
||||
if (token) {
|
||||
showApp();
|
||||
loadState().catch(() => {
|
||||
sessionStorage.removeItem("tagdesx-admin-token");
|
||||
token = "";
|
||||
showLogin();
|
||||
});
|
||||
}
|
||||
|
||||
function showApp() {
|
||||
login.classList.add("hidden");
|
||||
app.classList.remove("hidden");
|
||||
}
|
||||
|
||||
function showLogin() {
|
||||
login.classList.remove("hidden");
|
||||
app.classList.add("hidden");
|
||||
}
|
||||
|
||||
async function action(message, fn) {
|
||||
const buttons = [...document.querySelectorAll("button")];
|
||||
buttons.forEach((button) => { button.disabled = true; });
|
||||
status.textContent = message;
|
||||
try {
|
||||
await fn();
|
||||
status.textContent = "Fertig.";
|
||||
} catch (error) {
|
||||
status.textContent = error.message;
|
||||
} finally {
|
||||
buttons.forEach((button) => { button.disabled = false; });
|
||||
}
|
||||
}
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const response = await fetch(path, {
|
||||
...options,
|
||||
headers: {
|
||||
...(options.headers ?? {}),
|
||||
authorization: `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(payload.error ?? "Request failed");
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function loadState() {
|
||||
const state = await api("/api/state");
|
||||
|
||||
document.querySelector("#holiday-count").textContent = String(state.holidayCount);
|
||||
document.querySelector("#active-count").textContent = String(state.activeSubscriberCount);
|
||||
document.querySelector("#subscriber-count").textContent = `${state.subscriberCount} gesamt`;
|
||||
document.querySelector("#today-label").textContent = new Intl.DateTimeFormat("de-DE", {
|
||||
weekday: "long",
|
||||
day: "2-digit",
|
||||
month: "long"
|
||||
}).format(new Date());
|
||||
|
||||
const importText = state.imports.error
|
||||
? "Fehler"
|
||||
: state.imports.lastRunAt
|
||||
? new Date(state.imports.lastRunAt).toLocaleString("de-DE")
|
||||
: "Noch nie";
|
||||
document.querySelector("#import-status").textContent = importText;
|
||||
|
||||
renderToday(state.today);
|
||||
renderSubscribers(state.subscribers);
|
||||
renderDeliveries(state.deliveries);
|
||||
}
|
||||
|
||||
function renderToday(items) {
|
||||
const list = document.querySelector("#today-list");
|
||||
list.innerHTML = "";
|
||||
if (items.length === 0) {
|
||||
list.append(empty("Keine Eintraege fuer heute."));
|
||||
return;
|
||||
}
|
||||
for (const item of items) {
|
||||
const li = document.createElement("li");
|
||||
const link = document.createElement("a");
|
||||
link.href = item.url;
|
||||
link.target = "_blank";
|
||||
link.rel = "noreferrer";
|
||||
link.textContent = item.title;
|
||||
li.append(link);
|
||||
list.append(li);
|
||||
}
|
||||
}
|
||||
|
||||
function renderSubscribers(items) {
|
||||
const list = document.querySelector("#subscribers");
|
||||
list.innerHTML = "";
|
||||
if (items.length === 0) {
|
||||
list.append(empty("Noch keine Abonnenten."));
|
||||
return;
|
||||
}
|
||||
for (const item of items) {
|
||||
const li = document.createElement("li");
|
||||
const name = document.createElement("span");
|
||||
name.textContent = item.username ? `@${item.username}` : [item.firstName, item.lastName].filter(Boolean).join(" ") || item.chatId;
|
||||
const meta = document.createElement("span");
|
||||
meta.className = "meta";
|
||||
meta.textContent = item.active ? "aktiv" : "inaktiv";
|
||||
li.append(name, meta);
|
||||
list.append(li);
|
||||
}
|
||||
}
|
||||
|
||||
function renderDeliveries(items) {
|
||||
const list = document.querySelector("#deliveries");
|
||||
list.innerHTML = "";
|
||||
if (items.length === 0) {
|
||||
list.append(empty("Noch keine Sendungen."));
|
||||
return;
|
||||
}
|
||||
for (const item of items) {
|
||||
const li = document.createElement("li");
|
||||
const name = document.createElement("span");
|
||||
name.textContent = `${item.reason}: ${item.sent} gesendet, ${item.failed} Fehler`;
|
||||
const meta = document.createElement("span");
|
||||
meta.className = "meta";
|
||||
meta.textContent = new Date(item.createdAt).toLocaleString("de-DE");
|
||||
li.append(name, meta);
|
||||
list.append(li);
|
||||
}
|
||||
}
|
||||
|
||||
function empty(text) {
|
||||
const li = document.createElement("li");
|
||||
li.className = "meta";
|
||||
li.textContent = text;
|
||||
return li;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>TagDesX Admin</title>
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<section id="login" class="login">
|
||||
<div>
|
||||
<p class="eyebrow">TagDesX</p>
|
||||
<h1>Admin</h1>
|
||||
</div>
|
||||
<form id="login-form">
|
||||
<label>
|
||||
Passwort
|
||||
<input id="password" type="password" autocomplete="current-password" required>
|
||||
</label>
|
||||
<button type="submit">Einloggen</button>
|
||||
<p id="login-error" class="error"></p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section id="app" class="app hidden">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<p class="eyebrow">TagDesX</p>
|
||||
<h1>Telegram Tagesbot</h1>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button id="refresh">Daten neu laden</button>
|
||||
<button id="send-test">Testversand</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="metrics">
|
||||
<article>
|
||||
<span>Feiertage</span>
|
||||
<strong id="holiday-count">0</strong>
|
||||
</article>
|
||||
<article>
|
||||
<span>Aktive Chats</span>
|
||||
<strong id="active-count">0</strong>
|
||||
</article>
|
||||
<article>
|
||||
<span>Import</span>
|
||||
<strong id="import-status">-</strong>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="content-grid">
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>Heute</h2>
|
||||
<span id="today-label"></span>
|
||||
</div>
|
||||
<ul id="today-list" class="item-list"></ul>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>Abonnenten</h2>
|
||||
<span id="subscriber-count">0 gesamt</span>
|
||||
</div>
|
||||
<ul id="subscribers" class="item-list compact"></ul>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>Letzte Sendungen</h2>
|
||||
<span id="status"></span>
|
||||
</div>
|
||||
<ul id="deliveries" class="item-list compact"></ul>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
<script src="/app.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,226 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f4f2ee;
|
||||
--ink: #171717;
|
||||
--muted: #6a6258;
|
||||
--line: #d9d1c6;
|
||||
--panel: #fffdf8;
|
||||
--accent: #0f766e;
|
||||
--accent-ink: #ffffff;
|
||||
--danger: #b42318;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font: 15px/1.5 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
min-height: 40px;
|
||||
padding: 0 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: .55;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.shell {
|
||||
width: min(1120px, calc(100% - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 32px 0;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.login {
|
||||
min-height: calc(100vh - 64px);
|
||||
display: grid;
|
||||
align-content: center;
|
||||
gap: 24px;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.login h1,
|
||||
.topbar h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(30px, 5vw, 54px);
|
||||
line-height: 1;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 8px;
|
||||
color: var(--accent);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
form {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
color: var(--muted);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
padding: 0 12px;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.error {
|
||||
min-height: 22px;
|
||||
color: var(--danger);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.app {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding-bottom: 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.metrics,
|
||||
.content-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.content-grid {
|
||||
grid-template-columns: minmax(0, 1.3fr) minmax(280px, .7fr);
|
||||
}
|
||||
|
||||
.metrics article,
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.metrics article {
|
||||
padding: 16px;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.metrics span,
|
||||
.panel-head span,
|
||||
.meta {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.metrics strong {
|
||||
font-size: 28px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: baseline;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.panel h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.item-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.item-list li {
|
||||
border-top: 1px solid var(--line);
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.item-list li:first-child {
|
||||
border-top: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.item-list a {
|
||||
color: var(--accent);
|
||||
font-weight: 750;
|
||||
text-decoration: none;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.compact li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.topbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.metrics,
|
||||
.content-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.actions button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
const formatter = new Intl.DateTimeFormat("de-DE", {
|
||||
weekday: "long",
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
timeZone: "Europe/Berlin"
|
||||
});
|
||||
|
||||
export function berlinDateParts(date = new Date()) {
|
||||
const parts = new Intl.DateTimeFormat("de-DE", {
|
||||
timeZone: "Europe/Berlin",
|
||||
month: "numeric",
|
||||
day: "numeric"
|
||||
}).formatToParts(date);
|
||||
|
||||
return {
|
||||
month: Number(parts.find((part) => part.type === "month")?.value),
|
||||
day: Number(parts.find((part) => part.type === "day")?.value)
|
||||
};
|
||||
}
|
||||
|
||||
export function dailyMessage(holidays, date = new Date()) {
|
||||
const label = formatter.format(date);
|
||||
if (holidays.length === 0) {
|
||||
return `Heute (${label}) habe ich keinen kuriosen Feiertag im Kalender gefunden.`;
|
||||
}
|
||||
|
||||
const lines = holidays.slice(0, 12).map((holiday) => `- ${holiday.title}\n ${holiday.url}`);
|
||||
const suffix = holidays.length > 12 ? `\n\nUnd ${holidays.length - 12} weitere Einträge in der Admin-UI.` : "";
|
||||
return `Kuriose Feiertage am ${label}:\n\n${lines.join("\n")}${suffix}`;
|
||||
}
|
||||
|
||||
export function helpMessage() {
|
||||
return [
|
||||
"Ich schicke dir jeden Morgen um 9:00 Uhr die kuriosen Feiertage des Tages.",
|
||||
"",
|
||||
"/start - tägliche Nachricht abonnieren",
|
||||
"/today - heutigen Eintrag sofort senden",
|
||||
"/stop - Abo beenden"
|
||||
].join("\n");
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
export class DailyScheduler {
|
||||
constructor({ hour, minute, timezone, task, logger = console }) {
|
||||
this.hour = hour;
|
||||
this.minute = minute;
|
||||
this.timezone = timezone;
|
||||
this.task = task;
|
||||
this.logger = logger;
|
||||
this.lastKey = null;
|
||||
this.timer = null;
|
||||
}
|
||||
|
||||
start() {
|
||||
this.timer = setInterval(() => this.tick(), 30_000);
|
||||
this.tick();
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
}
|
||||
|
||||
tick() {
|
||||
const parts = Object.fromEntries(
|
||||
new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: this.timezone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false
|
||||
}).formatToParts(new Date()).map((part) => [part.type, part.value])
|
||||
);
|
||||
|
||||
const key = `${parts.year}-${parts.month}-${parts.day}`;
|
||||
if (Number(parts.hour) !== this.hour || Number(parts.minute) !== this.minute || this.lastKey === key) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastKey = key;
|
||||
this.task().catch((error) => {
|
||||
this.logger.error("daily task failed", error);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
const MONTHS = [
|
||||
["januar", 1],
|
||||
["februar", 2],
|
||||
["maerz", 3],
|
||||
["april", 4],
|
||||
["mai", 5],
|
||||
["juni", 6],
|
||||
["juli", 7],
|
||||
["august", 8],
|
||||
["september", 9],
|
||||
["oktober", 10],
|
||||
["november", 11],
|
||||
["dezember", 12]
|
||||
];
|
||||
|
||||
export const CALENDAR_SOURCE = "https://www.kuriose-feiertage.de/kalender/";
|
||||
|
||||
const htmlEntities = {
|
||||
amp: "&",
|
||||
quot: "\"",
|
||||
apos: "'",
|
||||
nbsp: " ",
|
||||
lt: "<",
|
||||
gt: ">"
|
||||
};
|
||||
|
||||
function decodeHtml(value) {
|
||||
return value
|
||||
.replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code)))
|
||||
.replace(/&#x([0-9a-f]+);/gi, (_, code) => String.fromCodePoint(Number.parseInt(code, 16)))
|
||||
.replace(/&([a-z]+);/gi, (_, name) => htmlEntities[name] ?? `&${name};`);
|
||||
}
|
||||
|
||||
function stripTags(value) {
|
||||
return decodeHtml(value.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim());
|
||||
}
|
||||
|
||||
function absoluteUrl(url) {
|
||||
return new URL(url, CALENDAR_SOURCE).toString();
|
||||
}
|
||||
|
||||
function parseMonth(html, month) {
|
||||
const entries = [];
|
||||
const dayBlocks = html.split(/<h2[^>]*>\s*(\d{1,2})\.\s*[^<]*<\/h2>/i);
|
||||
|
||||
for (let i = 1; i < dayBlocks.length; i += 2) {
|
||||
const day = Number(dayBlocks[i]);
|
||||
const block = dayBlocks[i + 1] ?? "";
|
||||
const untilNextHeading = block.split(/<h[23][^>]*>/i)[0];
|
||||
const links = untilNextHeading.matchAll(/<a\s+[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi);
|
||||
|
||||
for (const link of links) {
|
||||
const title = stripTags(link[2]);
|
||||
if (!title || title.startsWith("Grounding Page")) continue;
|
||||
entries.push({
|
||||
id: `${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}-${slugify(title)}`,
|
||||
month,
|
||||
day,
|
||||
title,
|
||||
url: absoluteUrl(decodeHtml(link[1]))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function slugify(value) {
|
||||
return value
|
||||
.normalize("NFKD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 80);
|
||||
}
|
||||
|
||||
export async function scrapeCalendar() {
|
||||
const holidays = [];
|
||||
|
||||
for (const [slug, month] of MONTHS) {
|
||||
const url = `${CALENDAR_SOURCE}${slug}/`;
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
"user-agent": "TagDesX/1.0 (+https://git.steinert.cc/Sven/TagDesX)"
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Import failed for ${url}: HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
holidays.push(...parseMonth(html, month));
|
||||
}
|
||||
|
||||
return holidays.sort((a, b) => a.month - b.month || a.day - b.day || a.title.localeCompare(b.title, "de"));
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const initialState = {
|
||||
subscribers: [],
|
||||
holidays: [],
|
||||
imports: {
|
||||
lastRunAt: null,
|
||||
source: "https://www.kuriose-feiertage.de/kalender/",
|
||||
count: 0,
|
||||
error: null
|
||||
},
|
||||
deliveries: []
|
||||
};
|
||||
|
||||
export class Store {
|
||||
constructor(dataDir) {
|
||||
this.dataDir = dataDir;
|
||||
this.file = path.join(dataDir, "tagdesx.json");
|
||||
this.state = structuredClone(initialState);
|
||||
}
|
||||
|
||||
async load() {
|
||||
await mkdir(this.dataDir, { recursive: true });
|
||||
if (!existsSync(this.file)) {
|
||||
await this.save();
|
||||
return this.state;
|
||||
}
|
||||
|
||||
const raw = await readFile(this.file, "utf8");
|
||||
const parsed = JSON.parse(raw);
|
||||
this.state = {
|
||||
...structuredClone(initialState),
|
||||
...parsed,
|
||||
imports: { ...initialState.imports, ...(parsed.imports ?? {}) },
|
||||
subscribers: parsed.subscribers ?? [],
|
||||
holidays: parsed.holidays ?? [],
|
||||
deliveries: parsed.deliveries ?? []
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
|
||||
async save() {
|
||||
await mkdir(this.dataDir, { recursive: true });
|
||||
const tmp = `${this.file}.tmp`;
|
||||
await writeFile(tmp, `${JSON.stringify(this.state, null, 2)}\n`, "utf8");
|
||||
await rename(tmp, this.file);
|
||||
}
|
||||
|
||||
snapshot() {
|
||||
return structuredClone(this.state);
|
||||
}
|
||||
|
||||
async setHolidays(holidays, source) {
|
||||
this.state.holidays = holidays;
|
||||
this.state.imports = {
|
||||
lastRunAt: new Date().toISOString(),
|
||||
source,
|
||||
count: holidays.length,
|
||||
error: null
|
||||
};
|
||||
await this.save();
|
||||
}
|
||||
|
||||
async setImportError(error) {
|
||||
this.state.imports = {
|
||||
...this.state.imports,
|
||||
lastRunAt: new Date().toISOString(),
|
||||
error: String(error?.message ?? error)
|
||||
};
|
||||
await this.save();
|
||||
}
|
||||
|
||||
getSubscriber(chatId) {
|
||||
return this.state.subscribers.find((subscriber) => subscriber.chatId === String(chatId));
|
||||
}
|
||||
|
||||
async upsertSubscriber(chat) {
|
||||
const chatId = String(chat.id);
|
||||
const now = new Date().toISOString();
|
||||
const existing = this.getSubscriber(chatId);
|
||||
const next = {
|
||||
chatId,
|
||||
type: chat.type ?? "private",
|
||||
username: chat.username ?? "",
|
||||
firstName: chat.first_name ?? "",
|
||||
lastName: chat.last_name ?? "",
|
||||
active: true,
|
||||
createdAt: existing?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
lastMessageAt: now
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
Object.assign(existing, next);
|
||||
} else {
|
||||
this.state.subscribers.push(next);
|
||||
}
|
||||
|
||||
await this.save();
|
||||
return next;
|
||||
}
|
||||
|
||||
async deactivateSubscriber(chatId) {
|
||||
const subscriber = this.getSubscriber(chatId);
|
||||
if (subscriber) {
|
||||
subscriber.active = false;
|
||||
subscriber.updatedAt = new Date().toISOString();
|
||||
await this.save();
|
||||
}
|
||||
return subscriber;
|
||||
}
|
||||
|
||||
activeSubscribers() {
|
||||
return this.state.subscribers.filter((subscriber) => subscriber.active);
|
||||
}
|
||||
|
||||
holidaysFor(month, day) {
|
||||
return this.state.holidays.filter((holiday) => holiday.month === month && holiday.day === day);
|
||||
}
|
||||
|
||||
async recordDelivery(delivery) {
|
||||
this.state.deliveries.unshift({
|
||||
...delivery,
|
||||
createdAt: new Date().toISOString()
|
||||
});
|
||||
this.state.deliveries = this.state.deliveries.slice(0, 200);
|
||||
await this.save();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { helpMessage } from "./messages.js";
|
||||
|
||||
export class TelegramBot {
|
||||
constructor({ token, store, logger = console }) {
|
||||
this.token = token;
|
||||
this.store = store;
|
||||
this.logger = logger;
|
||||
this.offset = 0;
|
||||
this.running = false;
|
||||
this.baseUrl = token ? `https://api.telegram.org/bot${token}` : null;
|
||||
}
|
||||
|
||||
enabled() {
|
||||
return Boolean(this.token);
|
||||
}
|
||||
|
||||
async request(method, body) {
|
||||
if (!this.enabled()) throw new Error("TELEGRAM_BOT_TOKEN is not configured");
|
||||
const response = await fetch(`${this.baseUrl}/${method}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok || payload.ok === false) {
|
||||
throw new Error(payload.description ?? `Telegram ${method} failed with HTTP ${response.status}`);
|
||||
}
|
||||
return payload.result;
|
||||
}
|
||||
|
||||
async sendMessage(chatId, text) {
|
||||
return this.request("sendMessage", {
|
||||
chat_id: chatId,
|
||||
text,
|
||||
disable_web_page_preview: true
|
||||
});
|
||||
}
|
||||
|
||||
start({ getTodayMessage }) {
|
||||
if (!this.enabled() || this.running) return;
|
||||
this.running = true;
|
||||
this.poll(getTodayMessage).catch((error) => {
|
||||
this.logger.error("telegram polling stopped", error);
|
||||
this.running = false;
|
||||
});
|
||||
}
|
||||
|
||||
async poll(getTodayMessage) {
|
||||
while (this.running) {
|
||||
try {
|
||||
const updates = await this.request("getUpdates", {
|
||||
offset: this.offset,
|
||||
timeout: 25,
|
||||
allowed_updates: ["message"]
|
||||
});
|
||||
|
||||
for (const update of updates) {
|
||||
this.offset = Math.max(this.offset, update.update_id + 1);
|
||||
await this.handleUpdate(update, getTodayMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error("telegram poll error", error.message);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async handleUpdate(update, getTodayMessage) {
|
||||
const message = update.message;
|
||||
if (!message?.chat) return;
|
||||
|
||||
const text = String(message.text ?? "").trim().toLowerCase();
|
||||
if (text.startsWith("/start")) {
|
||||
await this.store.upsertSubscriber(message.chat);
|
||||
await this.sendMessage(message.chat.id, `${helpMessage()}\n\nAbo ist aktiv.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (text.startsWith("/stop")) {
|
||||
await this.store.deactivateSubscriber(message.chat.id);
|
||||
await this.sendMessage(message.chat.id, "Abo beendet. Mit /start kannst du es wieder aktivieren.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (text.startsWith("/today") || text.startsWith("/heute")) {
|
||||
await this.store.upsertSubscriber(message.chat);
|
||||
await this.sendMessage(message.chat.id, getTodayMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
await this.sendMessage(message.chat.id, helpMessage());
|
||||
}
|
||||
}
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
import http from "node:http";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { Store } from "./lib/store.js";
|
||||
import { scrapeCalendar, CALENDAR_SOURCE } from "./lib/scraper.js";
|
||||
import { TelegramBot } from "./lib/telegram.js";
|
||||
import { DailyScheduler } from "./lib/scheduler.js";
|
||||
import { berlinDateParts, dailyMessage } from "./lib/messages.js";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = path.resolve(__dirname, "..");
|
||||
const publicDir = path.join(rootDir, "public");
|
||||
|
||||
const config = {
|
||||
port: Number(process.env.PORT ?? 8080),
|
||||
dataDir: process.env.DATA_DIR ?? path.join(rootDir, "data"),
|
||||
adminPassword: process.env.ADMIN_PASSWORD ?? "",
|
||||
telegramToken: process.env.TELEGRAM_BOT_TOKEN ?? "",
|
||||
timezone: process.env.TZ ?? "Europe/Berlin"
|
||||
};
|
||||
|
||||
if (!config.adminPassword) {
|
||||
console.warn("ADMIN_PASSWORD is not configured; admin login will be disabled.");
|
||||
}
|
||||
|
||||
const store = new Store(config.dataDir);
|
||||
await store.load();
|
||||
|
||||
const bot = new TelegramBot({
|
||||
token: config.telegramToken,
|
||||
store
|
||||
});
|
||||
|
||||
function todayHolidays(date = new Date()) {
|
||||
const { month, day } = berlinDateParts(date);
|
||||
return store.holidaysFor(month, day);
|
||||
}
|
||||
|
||||
function todayMessage(date = new Date()) {
|
||||
return dailyMessage(todayHolidays(date), date);
|
||||
}
|
||||
|
||||
async function runImport() {
|
||||
try {
|
||||
const holidays = await scrapeCalendar();
|
||||
await store.setHolidays(holidays, CALENDAR_SOURCE);
|
||||
return store.snapshot().imports;
|
||||
} catch (error) {
|
||||
await store.setImportError(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function sendDaily(reason = "scheduled") {
|
||||
const subscribers = store.activeSubscribers();
|
||||
const text = todayMessage();
|
||||
let sent = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const subscriber of subscribers) {
|
||||
try {
|
||||
await bot.sendMessage(subscriber.chatId, text);
|
||||
sent += 1;
|
||||
} catch (error) {
|
||||
failed += 1;
|
||||
console.error(`failed to send to ${subscriber.chatId}`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
await store.recordDelivery({
|
||||
reason,
|
||||
sent,
|
||||
failed,
|
||||
subscriberCount: subscribers.length,
|
||||
holidayCount: todayHolidays().length
|
||||
});
|
||||
|
||||
return { sent, failed, subscriberCount: subscribers.length };
|
||||
}
|
||||
|
||||
if (store.snapshot().holidays.length === 0) {
|
||||
runImport().catch((error) => console.error("initial calendar import failed", error.message));
|
||||
}
|
||||
|
||||
bot.start({ getTodayMessage: todayMessage });
|
||||
|
||||
new DailyScheduler({
|
||||
hour: 9,
|
||||
minute: 0,
|
||||
timezone: config.timezone,
|
||||
task: () => sendDaily("scheduled"),
|
||||
logger: console
|
||||
}).start();
|
||||
|
||||
const server = http.createServer(async (request, response) => {
|
||||
try {
|
||||
await route(request, response);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
sendJson(response, 500, { error: "Internal server error" });
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(config.port, () => {
|
||||
console.log(`TagDesX listening on :${config.port}`);
|
||||
});
|
||||
|
||||
async function route(request, response) {
|
||||
const url = new URL(request.url, `http://${request.headers.host}`);
|
||||
|
||||
if (url.pathname === "/health") {
|
||||
sendJson(response, 200, { ok: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/login" && request.method === "POST") {
|
||||
const body = await readJson(request);
|
||||
if (config.adminPassword && body.password === config.adminPassword) {
|
||||
sendJson(response, 200, { ok: true });
|
||||
return;
|
||||
}
|
||||
sendJson(response, 401, { error: "Invalid password" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith("/api/")) {
|
||||
if (!isAuthorized(request)) {
|
||||
sendJson(response, 401, { error: "Unauthorized" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/state" && request.method === "GET") {
|
||||
const snapshot = store.snapshot();
|
||||
sendJson(response, 200, {
|
||||
imports: snapshot.imports,
|
||||
subscriberCount: snapshot.subscribers.length,
|
||||
activeSubscriberCount: snapshot.subscribers.filter((subscriber) => subscriber.active).length,
|
||||
holidayCount: snapshot.holidays.length,
|
||||
today: todayHolidays(),
|
||||
deliveries: snapshot.deliveries.slice(0, 20),
|
||||
subscribers: snapshot.subscribers
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/import" && request.method === "POST") {
|
||||
const imports = await runImport();
|
||||
sendJson(response, 200, { imports, holidayCount: store.snapshot().holidays.length });
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/send-test" && request.method === "POST") {
|
||||
const result = await sendDaily("manual-test");
|
||||
sendJson(response, 200, result);
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/holidays" && request.method === "GET") {
|
||||
const month = Number(url.searchParams.get("month"));
|
||||
const day = Number(url.searchParams.get("day"));
|
||||
const holidays = Number.isFinite(month) && Number.isFinite(day)
|
||||
? store.holidaysFor(month, day)
|
||||
: store.snapshot().holidays;
|
||||
sendJson(response, 200, { holidays });
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(response, 404, { error: "Not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
await serveStatic(url.pathname, response);
|
||||
}
|
||||
|
||||
function isAuthorized(request) {
|
||||
const header = request.headers.authorization ?? "";
|
||||
const expected = `Bearer ${config.adminPassword}`;
|
||||
return Boolean(config.adminPassword) && header === expected;
|
||||
}
|
||||
|
||||
async function readJson(request) {
|
||||
const chunks = [];
|
||||
for await (const chunk of request) chunks.push(chunk);
|
||||
const raw = Buffer.concat(chunks).toString("utf8");
|
||||
return raw ? JSON.parse(raw) : {};
|
||||
}
|
||||
|
||||
function sendJson(response, status, payload) {
|
||||
response.writeHead(status, {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"cache-control": "no-store"
|
||||
});
|
||||
response.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
async function serveStatic(pathname, response) {
|
||||
const safePath = pathname === "/" ? "index.html" : pathname.replace(/^\/+/, "");
|
||||
const fullPath = path.normalize(path.join(publicDir, safePath));
|
||||
if (!fullPath.startsWith(publicDir)) {
|
||||
response.writeHead(403);
|
||||
response.end("Forbidden");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await readFile(fullPath);
|
||||
response.writeHead(200, {
|
||||
"content-type": contentType(fullPath),
|
||||
"cache-control": "no-store"
|
||||
});
|
||||
response.end(content);
|
||||
} catch {
|
||||
response.writeHead(404);
|
||||
response.end("Not found");
|
||||
}
|
||||
}
|
||||
|
||||
function contentType(file) {
|
||||
if (file.endsWith(".css")) return "text/css; charset=utf-8";
|
||||
if (file.endsWith(".js")) return "text/javascript; charset=utf-8";
|
||||
if (file.endsWith(".html")) return "text/html; charset=utf-8";
|
||||
return "application/octet-stream";
|
||||
}
|
||||
Reference in New Issue
Block a user