Build Telegram holiday bot

This commit is contained in:
2026-07-21 23:05:16 +02:00
parent 69db16824f
commit 2b94da8b3c
16 changed files with 1202 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
.git
.agents
node_modules
data
*.log
.env
+2
View File
@@ -0,0 +1,2 @@
TELEGRAM_BOT_TOKEN=
ADMIN_PASSWORD=change-me
+4
View File
@@ -0,0 +1,4 @@
node_modules/
data/
.env
*.log
+15
View File
@@ -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"]
+32
View File
@@ -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.
+18
View File
@@ -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:
+13
View File
@@ -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
View File
@@ -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;
}
+82
View File
@@ -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>
+226
View File
@@ -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;
}
}
+40
View File
@@ -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");
}
+44
View File
@@ -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);
});
}
}
+98
View File
@@ -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"));
}
+131
View File
@@ -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();
}
}
+93
View File
@@ -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
View File
@@ -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";
}