Willkommens-Karten, Geburtstage, Devlog-Permalinks mit OG-Vorschau, Auto-Publish
- Willkommens-Karten: gerendertes PNG (SVG → sharp) mit Avatar im Neon-Ring, Willkommens-Schriftzug und Member-Nummer; Fallback aufs bisherige Embed wenn das Rendering scheitert; Dockerfile installiert fonts-dejavu-core für die Text-Darstellung - Geburtstags-System: /geburtstag setzen|entfernen (birthdays-Tabelle), tägliche Runde ab 09:00 Europe/Berlin (Doppel-Post-Schutz über last_birthday_run), Gratulations-Embed + Tages-Rolle (wird am nächsten Morgen wieder abgeräumt); Kanal + Rolle im Community-Tab - Devlog-Permalinks: /devlogs/:id als eigene Seite (GET /api/devlogs/:id), Link-Symbol an jeder Karte, Link-kopieren-Button; der Server injiziert Open-Graph-Tags ins SPA-HTML — Devlog-Links zeigen Titel, Anriss und Bild, alle anderen Seiten bekommen Default-Tags (auch die Startseite) - Auto-Publish: maybeCrosspost() veröffentlicht Devlog-, Release-, Composer- und geplante Posts automatisch in Ankündigungs-Kanälen - brandEmbed: leere Avatar-URL crasht nicht mehr die Footer-Validierung Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,10 @@ RUN npm run build
|
||||
# Stage 2: Runtime (slim statt alpine: better-sqlite3 liefert glibc-Prebuilds)
|
||||
FROM node:22-slim
|
||||
|
||||
# Fonts für die Willkommens-Karten (sharp/librsvg braucht installierte Schriften)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends fonts-dejavu-core \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Erst nur Manifest kopieren → Docker-Layer-Cache für npm ci
|
||||
|
||||
@@ -42,7 +42,10 @@ SQLite (better-sqlite3, FTS5), Docker Multi-Stage — deploybar als Portainer-St
|
||||
| 📣 **Twitch/YouTube** | 🔴-Live- und ▶️-Neues-Video-Announcements (YouTube ohne Key via RSS, Twitch mit App-Credentials) |
|
||||
| 🔊 **Temp-Voice** | „Join to Create": Hub-Kanal betreten → eigener Voice-Kanal mit Control-Panel (Umbenennen, Sperren, Limit, Löschen — nur für den Besitzer), löscht sich wenn leer |
|
||||
| 📊 **Server-Stats** | Nachrichten/Joins/Leaves pro Tag → Aktivitäts-Chart auf der Level-Seite |
|
||||
| 👋 **Willkommens-Embed** | Begrüßung neuer Member im Brand-Look (braucht Server-Members-Intent) |
|
||||
| 👋 **Willkommens-Karten** | Begrüßung neuer Member als gerendertes Bild (Avatar mit Neon-Ring, Member-Nummer, Brand-Look); Fallback aufs Text-Embed |
|
||||
| 🎂 **Geburtstage** | `/geburtstag` zum Eintragen; morgens ab 09:00 Gratulation im Kanal + Tages-Rolle (Community-Tab) |
|
||||
| 📢 **Auto-Publish** | Devlog-/Release-/Composer-Posts in Ankündigungs-Kanälen werden automatisch veröffentlicht (Follower bekommen sie) |
|
||||
| 🔗 **Link-Vorschau** | Jedes Devlog hat einen Permalink (`/devlogs/:id`); geteilte Links zeigen überall gebrandete Open-Graph-Vorschau mit Bild |
|
||||
| 📋 **Mod-Log** | Gelöschte/bearbeitete Nachrichten in einen privaten Log-Kanal |
|
||||
| 🎮 **Server-Monitor** | DiscordGSM-Stil: eigener Setup-Tab, pro Server ein Live-Embed (🟢/🔴, Spieler-Balken mit %, Map, Spieler-Liste, klickbarer Connect-Link, Ping); **300+ Spiele via gamedig** (Minecraft, Rust, CS2, Valheim, ARK …) + FiveM + HTTP-Check; Down/Up-Alerts (🚨 nach 2 Fehlversuchen, ✅ mit Downtime bei Recovery) in eigenen Alert-Kanal; Spielerzahl in der Presence |
|
||||
| 👥 **Team-Rechte** | Team-Mitglieder bekommen gezielten Web-Zugriff (Composer, Bewerbungen, Rollen, Server, …) — Brand, System, API-Keys bleiben Owner-only; **Audit-Log** im Team-Tab zeigt dem Owner die letzten Aktionen |
|
||||
@@ -55,7 +58,7 @@ SQLite (better-sqlite3, FTS5), Docker Multi-Stage — deploybar als Portainer-St
|
||||
### Webinterface (`bot.d4rkst3r.de`)
|
||||
| Seite | Zugriff | Inhalt |
|
||||
|---|---|---|
|
||||
| `/devlogs` | öffentlich | Devlog-Archiv: Timeline, Bildergalerien, Projekt-Chips, **Volltextsuche** |
|
||||
| `/devlogs` | öffentlich | Devlog-Archiv: Timeline, Bildergalerien, Projekt-Chips, **Volltextsuche**; `/devlogs/:id` als teilbarer Permalink |
|
||||
| `/roadmap` | öffentlich | Meilensteine aus Gitea mit Fortschrittsbalken |
|
||||
| `/galerie` | öffentlich | Community-Screenshots aus dem Discord |
|
||||
| `/level` | öffentlich | XP-Bestenliste + Server-Aktivitäts-Chart |
|
||||
|
||||
@@ -12,6 +12,7 @@ import Settings from './pages/Settings.jsx';
|
||||
import Profil from './pages/Profil.jsx';
|
||||
import Server from './pages/Server.jsx';
|
||||
import Home from './pages/Home.jsx';
|
||||
import DevlogDetail from './pages/DevlogDetail.jsx';
|
||||
|
||||
const COMMUNITY_LINKS = [
|
||||
['/galerie', 'Galerie'],
|
||||
@@ -187,6 +188,7 @@ export default function App() {
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/devlogs" element={<Devlogs me={me} />} />
|
||||
<Route path="/devlogs/:id" element={<DevlogDetail />} />
|
||||
<Route path="/roadmap" element={<Roadmap />} />
|
||||
<Route path="/server" element={<Server />} />
|
||||
<Route path="/galerie" element={<Galerie />} />
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// Permalink-Seite für ein einzelnes Devlog (/devlogs/:id) — teilbar mit
|
||||
// Open-Graph-Vorschau (injiziert der Server ins HTML).
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { apiGet } from '../api.js';
|
||||
import { Markdown } from '../markdown.jsx';
|
||||
import Lightbox from '../components/Lightbox.jsx';
|
||||
import { SkeletonCards } from '../components/Skeleton.jsx';
|
||||
import { splitContent } from './Devlogs.jsx';
|
||||
|
||||
const dateFmt = new Intl.DateTimeFormat('de-DE', {
|
||||
weekday: 'long', day: '2-digit', month: 'long', year: 'numeric',
|
||||
});
|
||||
|
||||
export default function DevlogDetail() {
|
||||
const { id } = useParams();
|
||||
const [item, setItem] = useState(null);
|
||||
const [error, setError] = useState(false);
|
||||
const [lightbox, setLightbox] = useState(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setItem(null);
|
||||
setError(false);
|
||||
apiGet(`/api/devlogs/${id}`).then((d) => setItem(d.item)).catch(() => setError(true));
|
||||
window.scrollTo(0, 0);
|
||||
}, [id]);
|
||||
|
||||
async function copyLink() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(window.location.href);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch { /* Browser ohne Clipboard-Rechte */ }
|
||||
}
|
||||
|
||||
const parts = item ? splitContent(item.content) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="page-header">
|
||||
<div className="page-bg-text">DEVLOG</div>
|
||||
<div className="page-header-grid" aria-hidden="true" />
|
||||
<div className="page-header-content">
|
||||
<div className="page-tag">// Devlog</div>
|
||||
<h1 className="page-title">{item ? dateFmt.format(new Date(item.posted_at)) : 'Devlog'}</h1>
|
||||
{parts?.project && <p className="page-subtitle">Projekt: {parts.project}</p>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="content">
|
||||
{error && (
|
||||
<p className="notice">
|
||||
Devlog nicht gefunden — <Link to="/devlogs" style={{ textDecoration: 'underline' }}>zurück zum Archiv</Link>.
|
||||
</p>
|
||||
)}
|
||||
{!error && !item && <SkeletonCards n={1} />}
|
||||
|
||||
{item && (
|
||||
<>
|
||||
<article className="card">
|
||||
<div className="card-body">
|
||||
<Markdown text={parts.body} />
|
||||
</div>
|
||||
{item.images?.length > 0 && (
|
||||
<div className={`card-images n${Math.min(item.images.length, 4)}`}>
|
||||
{item.images.map((src, i) => (
|
||||
<a key={src} href={src} onClick={(e) => { e.preventDefault(); setLightbox(i); }}>
|
||||
<img src={src} alt="Devlog-Screenshot" loading="lazy" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{parts.footer && (
|
||||
<div className="card-footer">
|
||||
<Markdown text={parts.footer} />
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
|
||||
<div className="field-row" style={{ marginTop: '1.2rem' }}>
|
||||
<Link className="btn" to="/devlogs">← Alle Devlogs</Link>
|
||||
<button className="btn" onClick={copyLink}>{copied ? '✓ Link kopiert' : '🔗 Link kopieren'}</button>
|
||||
</div>
|
||||
|
||||
{lightbox !== null && (
|
||||
<Lightbox images={item.images} start={lightbox} onClose={() => setLightbox(null)} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -24,7 +24,7 @@ function relative(iso) {
|
||||
* - Embed-Titel („Devlog — Projekt") → Projekt-Chip statt Fließtext
|
||||
* - Abschließende Kursiv-Zeile („*X Commits heute …*") → abgesetzte Fußzeile
|
||||
*/
|
||||
function splitContent(raw) {
|
||||
export function splitContent(raw) {
|
||||
const blocks = raw.split(/\n{2,}/);
|
||||
let project = null;
|
||||
let footer = null;
|
||||
@@ -135,6 +135,7 @@ export default function Devlogs({ me }) {
|
||||
</span>
|
||||
{project && <span className="card-project">{project}</span>}
|
||||
<span className="card-rel">{relative(d.posted_at)}</span>
|
||||
<a className="card-permalink" href={`/devlogs/${d.message_id}`} title="Direktlink zu diesem Devlog">🔗</a>
|
||||
{me?.admin && (
|
||||
<button
|
||||
className="card-delete"
|
||||
|
||||
@@ -733,6 +733,24 @@ export default function Settings({ me }) {
|
||||
|
||||
const tabCommunity = (
|
||||
<>
|
||||
<div className="settings-section">
|
||||
<h2 className="settings-title">// Geburtstage</h2>
|
||||
<div className="field">
|
||||
<label>Geburtstags-Kanal <span className="field-hint">Member tragen sich mit /geburtstag ein; Gratulation morgens ab 09:00 — leer = aus</span></label>
|
||||
<select value={form.birthday_channel_id ?? ''} onChange={(e) => setForm({ ...form, birthday_channel_id: e.target.value })}>
|
||||
<option value="">— deaktiviert —</option>
|
||||
{channelOptions}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Geburtstags-Rolle <span className="field-hint">gibt's für den Tag, wird am nächsten Morgen wieder entfernt — leer = keine</span></label>
|
||||
<select value={form.birthday_role_id ?? ''} onChange={(e) => setForm({ ...form, birthday_role_id: e.target.value })}>
|
||||
<option value="">— keine Rolle —</option>
|
||||
{roleOptions}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<h2 className="settings-title">// Playtester</h2>
|
||||
<div className="field">
|
||||
|
||||
@@ -1466,3 +1466,10 @@ a.sha:hover { border-color: var(--neon); background: rgba(245, 197, 24, .07); }
|
||||
font-family: var(--mono); font-size: .7rem; letter-spacing: .2em;
|
||||
text-transform: uppercase; color: var(--neon);
|
||||
}
|
||||
|
||||
/* Permalink-Icon an der Devlog-Karte */
|
||||
.card-permalink {
|
||||
font-size: .8rem; opacity: .35; transition: opacity .15s;
|
||||
text-decoration: none;
|
||||
}
|
||||
.card-permalink:hover { opacity: 1; }
|
||||
|
||||
Generated
+609
-1
@@ -16,7 +16,8 @@
|
||||
"discord.js": "^14.16.3",
|
||||
"dotenv": "^16.4.7",
|
||||
"fastify": "^5.10.0",
|
||||
"gamedig": "^5.3.3"
|
||||
"gamedig": "^5.3.3",
|
||||
"sharp": "^0.35.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
@@ -152,6 +153,16 @@
|
||||
"url": "https://github.com/discordjs/discord.js?sponsor"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
|
||||
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/accept-negotiator": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz",
|
||||
@@ -405,6 +416,554 @@
|
||||
"glob": "^13.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/colour": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-arm64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz",
|
||||
"integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-x64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz",
|
||||
"integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-freebsd-wasm32": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz",
|
||||
"integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"dependencies": {
|
||||
"@img/sharp-wasm32": "0.35.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz",
|
||||
"integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz",
|
||||
"integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz",
|
||||
"integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz",
|
||||
"integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz",
|
||||
"integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz",
|
||||
"integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz",
|
||||
"integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz",
|
||||
"integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz",
|
||||
"integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz",
|
||||
"integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz",
|
||||
"integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz",
|
||||
"integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-ppc64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz",
|
||||
"integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-riscv64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz",
|
||||
"integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-s390x": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz",
|
||||
"integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-x64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz",
|
||||
"integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-x64": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz",
|
||||
"integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz",
|
||||
"integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-wasm32": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz",
|
||||
"integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==",
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/runtime": "^1.11.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-webcontainers-wasm32": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz",
|
||||
"integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@img/sharp-wasm32": "0.35.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-arm64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz",
|
||||
"integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-ia32": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz",
|
||||
"integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-x64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz",
|
||||
"integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@lukeed/ms": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz",
|
||||
@@ -1993,6 +2552,55 @@
|
||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/sharp": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
|
||||
"integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@img/colour": "^1.1.0",
|
||||
"detect-libc": "^2.1.2",
|
||||
"semver": "^7.8.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-darwin-arm64": "0.35.3",
|
||||
"@img/sharp-darwin-x64": "0.35.3",
|
||||
"@img/sharp-freebsd-wasm32": "0.35.3",
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.2",
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.2",
|
||||
"@img/sharp-libvips-linux-arm": "1.3.2",
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.2",
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.2",
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.2",
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.2",
|
||||
"@img/sharp-libvips-linux-x64": "1.3.2",
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2",
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.2",
|
||||
"@img/sharp-linux-arm": "0.35.3",
|
||||
"@img/sharp-linux-arm64": "0.35.3",
|
||||
"@img/sharp-linux-ppc64": "0.35.3",
|
||||
"@img/sharp-linux-riscv64": "0.35.3",
|
||||
"@img/sharp-linux-s390x": "0.35.3",
|
||||
"@img/sharp-linux-x64": "0.35.3",
|
||||
"@img/sharp-linuxmusl-arm64": "0.35.3",
|
||||
"@img/sharp-linuxmusl-x64": "0.35.3",
|
||||
"@img/sharp-webcontainers-wasm32": "0.35.3",
|
||||
"@img/sharp-win32-arm64": "0.35.3",
|
||||
"@img/sharp-win32-ia32": "0.35.3",
|
||||
"@img/sharp-win32-x64": "0.35.3"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/simple-concat": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
|
||||
|
||||
+2
-1
@@ -21,6 +21,7 @@
|
||||
"discord.js": "^14.16.3",
|
||||
"dotenv": "^16.4.7",
|
||||
"fastify": "^5.10.0",
|
||||
"gamedig": "^5.3.3"
|
||||
"gamedig": "^5.3.3",
|
||||
"sharp": "^0.35.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// Geburtstags-Runde: einmal täglich ab 09:00 (Europe/Berlin) gratulieren
|
||||
// und die Tages-Rolle umhängen (gestern Geburtstag → Rolle wieder weg).
|
||||
import { getSetting, setSetting, birthdaysToday } from '../db.js';
|
||||
import { birthdayChannelId, birthdayRoleId, discordGuildId, brandColor2 } from '../runtime-settings.js';
|
||||
import { brandEmbed } from '../embeds.js';
|
||||
|
||||
const CHECK_INTERVAL_MS = 15 * 60 * 1000;
|
||||
|
||||
/** Aktuelles Datum/Stunde in Europe/Berlin */
|
||||
function berlinNow() {
|
||||
const parts = new Intl.DateTimeFormat('de-DE', {
|
||||
timeZone: 'Europe/Berlin', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', hour12: false,
|
||||
}).formatToParts(new Date());
|
||||
const get = (type) => Number(parts.find((p) => p.type === type)?.value);
|
||||
return { day: get('day'), month: get('month'), hour: get('hour'), dateKey: `${get('year')}-${get('month')}-${get('day')}` };
|
||||
}
|
||||
|
||||
export async function birthdayTick(client) {
|
||||
const { day, month, hour, dateKey } = berlinNow();
|
||||
if (hour < 9) return; // erst ab 09:00
|
||||
if (getSetting('last_birthday_run') === dateKey) return; // heute schon gelaufen
|
||||
setSetting('last_birthday_run', dateKey);
|
||||
|
||||
const guildId = discordGuildId() ?? [...client.guilds.cache.keys()][0];
|
||||
const guild = guildId ? await client.guilds.fetch(guildId).catch(() => null) : null;
|
||||
const kids = birthdaysToday(day, month);
|
||||
|
||||
// Tages-Rolle: erst bei allen abräumen, dann den heutigen geben
|
||||
const roleId = birthdayRoleId();
|
||||
if (guild && roleId) {
|
||||
const role = await guild.roles.fetch(roleId).catch(() => null);
|
||||
if (role) {
|
||||
for (const member of role.members.values()) {
|
||||
await member.roles.remove(roleId).catch(() => {});
|
||||
}
|
||||
for (const b of kids) {
|
||||
const member = await guild.members.fetch(b.user_id).catch(() => null);
|
||||
await member?.roles.add(roleId).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (kids.length === 0) return;
|
||||
const channelId = birthdayChannelId();
|
||||
if (!channelId) return;
|
||||
const channel = await client.channels.fetch(channelId).catch(() => null);
|
||||
if (!channel?.isTextBased()) return;
|
||||
|
||||
const mentions = kids.map((b) => `<@${b.user_id}>`).join(' ');
|
||||
await channel.send({
|
||||
content: mentions,
|
||||
embeds: [
|
||||
brandEmbed(client, 'GEBURTSTAG')
|
||||
.setColor(brandColor2())
|
||||
.setTitle(kids.length === 1 ? '🎂 Alles Gute zum Geburtstag!' : '🎂 Heute wird gleich mehrfach gefeiert!')
|
||||
.setDescription(
|
||||
`${mentions} ${kids.length === 1 ? 'hat' : 'haben'} heute Geburtstag — ` +
|
||||
'lasst mal ordentlich 🎉 da!'
|
||||
),
|
||||
],
|
||||
allowedMentions: { users: kids.map((b) => b.user_id) },
|
||||
}).catch(() => {});
|
||||
console.log(`[birthday] ${kids.length} Gratulation(en) für den ${day}.${month}.`);
|
||||
}
|
||||
|
||||
export function startBirthdays(client) {
|
||||
setInterval(() => birthdayTick(client).catch((e) => console.error('[birthday]', e)), CHECK_INTERVAL_MS);
|
||||
birthdayTick(client).catch(() => {});
|
||||
}
|
||||
+2
-1
@@ -33,11 +33,12 @@ import * as purge from './commands/purge.js';
|
||||
import * as rank from './commands/rank.js';
|
||||
import * as tag from './commands/tag.js';
|
||||
import * as remind from './commands/remind.js';
|
||||
import * as geburtstag from './commands/geburtstag.js';
|
||||
|
||||
// Alle Commands hier eintragen — jedes Modul exportiert { data, execute }
|
||||
const commandModules = [
|
||||
ping, devlogBackfill, bug, playtesterSetup, galerieBackfill,
|
||||
wunsch, giveaway, ticketSetup, warn, warns, timeout, purge, rank, tag, remind,
|
||||
wunsch, giveaway, ticketSetup, warn, warns, timeout, purge, rank, tag, remind, geburtstag,
|
||||
];
|
||||
|
||||
export async function startBot() {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// /geburtstag — eintragen/entfernen; der Bot gratuliert morgens im Geburtstags-Kanal
|
||||
import { SlashCommandBuilder, MessageFlags } from 'discord.js';
|
||||
import { setBirthday, deleteBirthday } from '../../db.js';
|
||||
import { birthdayChannelId } from '../../runtime-settings.js';
|
||||
|
||||
const DAYS_IN_MONTH = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||||
|
||||
export const data = new SlashCommandBuilder()
|
||||
.setName('geburtstag')
|
||||
.setDescription('Geburtstag eintragen — der Bot gratuliert dir am großen Tag 🎂')
|
||||
.addSubcommand((s) =>
|
||||
s.setName('setzen').setDescription('Deinen Geburtstag eintragen (ohne Jahr)')
|
||||
.addIntegerOption((o) => o.setName('tag').setDescription('Tag (1–31)').setRequired(true).setMinValue(1).setMaxValue(31))
|
||||
.addIntegerOption((o) => o.setName('monat').setDescription('Monat (1–12)').setRequired(true).setMinValue(1).setMaxValue(12))
|
||||
)
|
||||
.addSubcommand((s) =>
|
||||
s.setName('entfernen').setDescription('Deinen Geburtstag wieder austragen')
|
||||
);
|
||||
|
||||
export async function execute(interaction) {
|
||||
if (interaction.options.getSubcommand() === 'entfernen') {
|
||||
const deleted = deleteBirthday(interaction.user.id);
|
||||
await interaction.reply({
|
||||
content: deleted ? '🗑️ Geburtstag ausgetragen.' : '❕ Es war kein Geburtstag eingetragen.',
|
||||
flags: MessageFlags.Ephemeral,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const day = interaction.options.getInteger('tag');
|
||||
const month = interaction.options.getInteger('monat');
|
||||
if (day > DAYS_IN_MONTH[month - 1]) {
|
||||
await interaction.reply({ content: `❌ Der ${day}.${month}. existiert nicht.`, flags: MessageFlags.Ephemeral });
|
||||
return;
|
||||
}
|
||||
setBirthday(interaction.user.id, interaction.user.username, day, month);
|
||||
await interaction.reply({
|
||||
content:
|
||||
`🎂 Gemerkt: **${day}.${month}.** — ich gratuliere dir dann morgens` +
|
||||
(birthdayChannelId() ? '!' : '. (Hinweis an die Admins: noch kein Geburtstags-Kanal gesetzt.)'),
|
||||
flags: MessageFlags.Ephemeral,
|
||||
});
|
||||
}
|
||||
+18
-3
@@ -1,5 +1,5 @@
|
||||
// Moderation & Kontakt: Modmail (DM ↔ Staff-Thread), Willkommens-Embed, Mod-Log
|
||||
import { ChannelType, EmbedBuilder, Events } from 'discord.js';
|
||||
import { AttachmentBuilder, ChannelType, EmbedBuilder, Events } from 'discord.js';
|
||||
import { saveModmail, modmailByUser, modmailByThread, saveStickyRoles, stickyRolesOf } from '../db.js';
|
||||
import { modmailChannelId, welcomeChannelId, modlogChannelId, publicUrl, autoroleId, stickyRolesEnabled, brandColor, brandColor2, brandFooter } from '../runtime-settings.js';
|
||||
|
||||
@@ -86,7 +86,6 @@ async function handleMemberAdd(member) {
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(brandColor())
|
||||
.setTitle(`👋 Willkommen, ${member.displayName}!`)
|
||||
.setThumbnail(member.user.displayAvatarURL({ size: 128 }))
|
||||
.setDescription(
|
||||
`Schön, dass du da bist — du bist Mitglied **#${member.guild.memberCount}**.\n\n` +
|
||||
`📔 Devlogs & Roadmap: ${publicUrl()}\n` +
|
||||
@@ -96,7 +95,23 @@ async function handleMemberAdd(member) {
|
||||
.setFooter({ text: brandFooter('COMMUNITY'), iconURL: member.client?.user?.displayAvatarURL?.({ size: 64 }) })
|
||||
.setTimestamp();
|
||||
|
||||
await channel.send({ content: `<@${member.id}>`, embeds: [embed] });
|
||||
// Gerenderte Willkommens-Karte — wenn das Rendering klemmt, gibt's das Embed pur
|
||||
let files = [];
|
||||
try {
|
||||
const { buildWelcomeCard } = await import('./welcome-card.js');
|
||||
const png = await buildWelcomeCard({
|
||||
username: member.displayName,
|
||||
avatarUrl: member.user.displayAvatarURL({ extension: 'png', size: 128 }),
|
||||
memberNumber: member.guild.memberCount,
|
||||
});
|
||||
files = [new AttachmentBuilder(png, { name: 'welcome.png' })];
|
||||
embed.setImage('attachment://welcome.png');
|
||||
} catch (error) {
|
||||
embed.setThumbnail(member.user.displayAvatarURL({ size: 128 }));
|
||||
console.error('[welcome] Karte fehlgeschlagen, nutze Embed:', error.message);
|
||||
}
|
||||
|
||||
await channel.send({ content: `<@${member.id}>`, embeds: [embed], files });
|
||||
}
|
||||
|
||||
/* ── Auto- & Sticky-Roles ──────────────────────────── */
|
||||
|
||||
@@ -44,6 +44,8 @@ export async function postReleaseEmbed(client, release) {
|
||||
);
|
||||
}
|
||||
|
||||
await channel.send({ embeds: [embed], components: [buttons] });
|
||||
const message = await channel.send({ embeds: [embed], components: [buttons] });
|
||||
const { maybeCrosspost } = await import('../embeds.js');
|
||||
await maybeCrosspost(message);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// Willkommens-Karte: gerendertes PNG (SVG → sharp) im D4RKST3R-Look —
|
||||
// Avatar mit Neon-Ring, großer Willkommens-Schriftzug, Member-Nummer.
|
||||
import sharp from 'sharp';
|
||||
import { brandName } from '../runtime-settings.js';
|
||||
|
||||
const W = 900;
|
||||
const H = 300;
|
||||
|
||||
/** XML-Sonderzeichen im Usernamen entschärfen */
|
||||
function esc(s) {
|
||||
return String(s).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>')
|
||||
.replaceAll('"', '"').replaceAll("'", ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Karte rendern.
|
||||
* @returns {Promise<Buffer>} PNG-Buffer
|
||||
*/
|
||||
export async function buildWelcomeCard({ username, avatarUrl, memberNumber }) {
|
||||
// Avatar holen und als Data-URI einbetten (128px reicht für den 150px-Kreis)
|
||||
let avatarData = '';
|
||||
try {
|
||||
const res = await fetch(avatarUrl, { signal: AbortSignal.timeout(5000) });
|
||||
if (res.ok) {
|
||||
avatarData = `data:image/png;base64,${Buffer.from(await res.arrayBuffer()).toString('base64')}`;
|
||||
}
|
||||
} catch { /* ohne Avatar rendern */ }
|
||||
|
||||
const name = esc(username.length > 22 ? `${username.slice(0, 21)}…` : username);
|
||||
const svg = `<svg width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<defs>
|
||||
<linearGradient id="line" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0" stop-color="#f5c518" stop-opacity="0"/>
|
||||
<stop offset=".3" stop-color="#f5c518"/>
|
||||
<stop offset=".7" stop-color="#ff4d00"/>
|
||||
<stop offset="1" stop-color="#ff4d00" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="ring" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#f5c518"/>
|
||||
<stop offset="1" stop-color="#ff4d00"/>
|
||||
</linearGradient>
|
||||
<pattern id="grid" width="45" height="45" patternUnits="userSpaceOnUse">
|
||||
<path d="M 45 0 L 0 0 0 45" fill="none" stroke="#f5c518" stroke-opacity=".06" stroke-width="1"/>
|
||||
</pattern>
|
||||
<clipPath id="avatar"><circle cx="150" cy="150" r="75"/></clipPath>
|
||||
</defs>
|
||||
|
||||
<rect width="${W}" height="${H}" fill="#0a0a0a"/>
|
||||
<rect width="${W}" height="${H}" fill="url(#grid)"/>
|
||||
<text x="${W - 18}" y="${H - 24}" text-anchor="end" font-family="DejaVu Sans, sans-serif" font-weight="bold"
|
||||
font-size="120" fill="#f5c518" fill-opacity=".05">${esc(brandName())}</text>
|
||||
<rect x="0" y="0" width="${W}" height="3" fill="url(#line)"/>
|
||||
<rect x="0" y="${H - 3}" width="${W}" height="3" fill="url(#line)"/>
|
||||
|
||||
<circle cx="150" cy="150" r="80" fill="none" stroke="url(#ring)" stroke-width="4"/>
|
||||
${avatarData
|
||||
? `<image href="${avatarData}" x="75" y="75" width="150" height="150" clip-path="url(#avatar)" preserveAspectRatio="xMidYMid slice"/>`
|
||||
: `<circle cx="150" cy="150" r="75" fill="#161616"/>
|
||||
<text x="150" y="172" text-anchor="middle" font-family="DejaVu Sans, sans-serif" font-size="64" fill="#8a8378">?</text>`}
|
||||
|
||||
<text x="270" y="118" font-family="DejaVu Sans, sans-serif" font-weight="bold" font-size="26"
|
||||
letter-spacing="10" fill="#8a8378">WILLKOMMEN</text>
|
||||
<text x="270" y="182" font-family="DejaVu Sans, sans-serif" font-weight="bold" font-size="52"
|
||||
fill="#e8e0d0">${name}</text>
|
||||
<text x="270" y="228" font-family="DejaVu Sans, sans-serif" font-size="24"
|
||||
fill="#f5c518">Member #${Number(memberNumber) || '?'}</text>
|
||||
</svg>`;
|
||||
|
||||
return sharp(Buffer.from(svg)).png().toBuffer();
|
||||
}
|
||||
@@ -474,6 +474,25 @@ export const webAdminScopes = (userId) =>
|
||||
export const listWebAdmins = () => listWebAdminsStmt.all();
|
||||
export const deleteWebAdmin = (userId) => deleteWebAdminStmt.run(userId).changes > 0;
|
||||
|
||||
// Geburtstage: /geburtstag → morgendliche Gratulation + Tages-Rolle
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS birthdays (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
username TEXT,
|
||||
day INTEGER NOT NULL,
|
||||
month INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
const upsertBirthday = db.prepare(`
|
||||
INSERT INTO birthdays (user_id, username, day, month) VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET username = excluded.username, day = excluded.day, month = excluded.month
|
||||
`);
|
||||
const deleteBirthdayStmt = db.prepare('DELETE FROM birthdays WHERE user_id = ?');
|
||||
const birthdaysTodayStmt = db.prepare('SELECT * FROM birthdays WHERE day = ? AND month = ?');
|
||||
export const setBirthday = (userId, username, day, month) => upsertBirthday.run(userId, username, day, month);
|
||||
export const deleteBirthday = (userId) => deleteBirthdayStmt.run(userId).changes > 0;
|
||||
export const birthdaysToday = (day, month) => birthdaysTodayStmt.all(day, month);
|
||||
|
||||
// Audit-Log: wer (Owner/Team) hat wann was im Webinterface geändert
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
@@ -873,6 +892,11 @@ export function listDevlogs(limit, offset) {
|
||||
return { items: selectDevlogs.all(limit, offset), total: countDevlogsStmt.get().n };
|
||||
}
|
||||
|
||||
const getDevlogStmt = db.prepare(
|
||||
'SELECT message_id, content, author_name, posted_at, images FROM devlogs WHERE message_id = ?'
|
||||
);
|
||||
export const getDevlog = (messageId) => getDevlogStmt.get(messageId) ?? null;
|
||||
|
||||
const searchDevlogsStmt = db.prepare(`
|
||||
SELECT d.message_id, d.content, d.author_name, d.posted_at, d.images
|
||||
FROM devlogs_fts f
|
||||
|
||||
+10
-2
@@ -1,8 +1,15 @@
|
||||
// Zentrale Embed-Factory: einheitlicher Brand-Look für alle Bot-Embeds.
|
||||
// Footer mit Bot-Avatar, Brand-Farbe und Timestamp kommen automatisch.
|
||||
import { EmbedBuilder } from 'discord.js';
|
||||
import { ChannelType, EmbedBuilder } from 'discord.js';
|
||||
import { brandColor, brandColor2, brandFooter } from './runtime-settings.js';
|
||||
|
||||
/** Auto-Publish: Posts in Ankündigungs-Kanälen crossposten, damit Follower sie bekommen */
|
||||
export async function maybeCrosspost(message) {
|
||||
if (message?.channel?.type === ChannelType.GuildAnnouncement && typeof message.crosspost === 'function') {
|
||||
await message.crosspost().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gebrandetes Embed erzeugen.
|
||||
* @param {import('discord.js').Client|null} client — für das Footer-Icon (Bot-Avatar)
|
||||
@@ -11,9 +18,10 @@ import { brandColor, brandColor2, brandFooter } from './runtime-settings.js';
|
||||
*/
|
||||
export function brandEmbed(client, tag, opts = {}) {
|
||||
const embed = new EmbedBuilder().setColor(opts.secondary ? brandColor2() : brandColor());
|
||||
const icon = client?.user?.displayAvatarURL?.({ size: 64 });
|
||||
embed.setFooter({
|
||||
text: brandFooter(tag) + (opts.footerSuffix ? ` • ${opts.footerSuffix}` : ''),
|
||||
iconURL: client?.user?.displayAvatarURL?.({ size: 64 }) ?? undefined,
|
||||
iconURL: icon || undefined, // leerer String würde die discord.js-Validierung werfen
|
||||
});
|
||||
if (opts.timestamp !== false) embed.setTimestamp();
|
||||
return embed;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { startServerMonitor } from './bot/server-monitor.js';
|
||||
import { startGiveaways } from './bot/giveaways.js';
|
||||
import { startScheduledPosts } from './web/scheduled-posts.js';
|
||||
import { startSocialNotify } from './bot/social-notify.js';
|
||||
import { startBirthdays } from './bot/birthdays.js';
|
||||
|
||||
process.on('unhandledRejection', (error) => {
|
||||
console.error('[main] Unhandled Rejection:', error);
|
||||
@@ -23,6 +24,7 @@ try {
|
||||
startGiveaways(client);
|
||||
startScheduledPosts(client);
|
||||
startSocialNotify(client);
|
||||
startBirthdays(client);
|
||||
} catch (error) {
|
||||
console.error('[main] Start fehlgeschlagen:', error);
|
||||
process.exit(1);
|
||||
|
||||
@@ -171,6 +171,16 @@ export function discordInviteUrl() {
|
||||
return getSetting('discord_invite_url') || null;
|
||||
}
|
||||
|
||||
/** Geburtstags-Kanal (morgendliche Gratulation) — leer = Feature aus */
|
||||
export function birthdayChannelId() {
|
||||
return getSetting('birthday_channel_id') || null;
|
||||
}
|
||||
|
||||
/** Geburtstags-Rolle für den Tag — leer = keine Rolle */
|
||||
export function birthdayRoleId() {
|
||||
return getSetting('birthday_role_id') || null;
|
||||
}
|
||||
|
||||
/** Kanal für Feature-Wünsche (/wunsch) — leer = Feature aus */
|
||||
export function votingChannelId() {
|
||||
return getSetting('voting_channel_id') || null;
|
||||
|
||||
+19
-2
@@ -1,7 +1,7 @@
|
||||
// REST-API fürs Webinterface: Devlogs öffentlich, Commits + Settings nur für den Admin
|
||||
import { EmbedBuilder } from 'discord.js';
|
||||
import {
|
||||
listDevlogs, searchDevlogs, listCommits, listReleases, archiveStats,
|
||||
listDevlogs, searchDevlogs, getDevlog, listCommits, listReleases, archiveStats,
|
||||
getSetting, setSetting, createApiKey, listApiKeys, deleteApiKey,
|
||||
listGallery, listPlaytesters, topWishes, commitHeatmap,
|
||||
createRoleMenu, updateRoleMenu, getRoleMenu, listRoleMenus, deleteRoleMenu,
|
||||
@@ -89,6 +89,18 @@ export function registerApiRoutes(app, client) {
|
||||
return { items: mapped, total, page, pageSize: PAGE_SIZE };
|
||||
});
|
||||
|
||||
// Einzelnes Devlog (Permalink-Seite /devlogs/:id)
|
||||
app.get('/api/devlogs/:id', async (request, reply) => {
|
||||
const item = getDevlog(String(request.params.id));
|
||||
if (!item) return reply.code(404).send({ error: 'Devlog nicht gefunden' });
|
||||
return {
|
||||
item: {
|
||||
...item,
|
||||
images: JSON.parse(item.images || '[]').map((f) => `/devlog-assets/${f}`),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Changelog — öffentlich (Releases aller Repos)
|
||||
app.get('/api/releases', async (request) => {
|
||||
const { limit, offset, page } = paging(request);
|
||||
@@ -339,6 +351,8 @@ ${rssItems}
|
||||
server_alert_channel_id: getSetting('server_alert_channel_id') ?? '',
|
||||
member_gate_enabled: getSetting('member_gate_enabled') !== '0',
|
||||
discord_invite_url: getSetting('discord_invite_url') ?? '',
|
||||
birthday_channel_id: getSetting('birthday_channel_id') ?? '',
|
||||
birthday_role_id: getSetting('birthday_role_id') ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -385,6 +399,7 @@ ${rssItems}
|
||||
'screenshot_channel_id', 'modmail_channel_id', 'welcome_channel_id',
|
||||
'modlog_channel_id', 'status_channel_id', 'voting_channel_id', 'ticket_channel_id',
|
||||
'events_announce_channel_id', 'social_announce_channel_id', 'server_alert_channel_id',
|
||||
'birthday_channel_id',
|
||||
];
|
||||
for (const key of ['commit_channel_id', 'devlog_channel_id', ...OPTIONAL_CHANNELS]) {
|
||||
if (body[key] === undefined) continue;
|
||||
@@ -400,7 +415,7 @@ ${rssItems}
|
||||
setSetting(key, value);
|
||||
}
|
||||
// Rollen: müssen existieren ('' = Feature aus)
|
||||
for (const key of ['devlog_ping_role_id', 'playtester_role_id', 'autorole_id']) {
|
||||
for (const key of ['devlog_ping_role_id', 'playtester_role_id', 'autorole_id', 'birthday_role_id']) {
|
||||
if (body[key] === undefined) continue;
|
||||
const value = String(body[key]);
|
||||
if (value !== '') {
|
||||
@@ -1005,6 +1020,8 @@ ${rssItems}
|
||||
}
|
||||
|
||||
const message = await channel.send(payload);
|
||||
const { maybeCrosspost } = await import('../embeds.js');
|
||||
await maybeCrosspost(message);
|
||||
request.log.info(`Composer: Nachricht ${message.id} gesendet`);
|
||||
logAudit(getSessionUser(request), 'composer gesendet', `#${channel.name ?? channelId}`);
|
||||
return { ok: true, edited: false, message_id: message.id };
|
||||
|
||||
@@ -12,6 +12,7 @@ import { config } from '../config.js';
|
||||
import { saveDevlog } from '../db.js';
|
||||
import { devlogChannelId, devlogPingRoleId, devlogThreadsEnabled, publicUrl, brandColor, brandFooter } from '../runtime-settings.js';
|
||||
import { imagesDir } from '../bot/devlog-archive.js';
|
||||
import { maybeCrosspost } from '../embeds.js';
|
||||
|
||||
const MAX_IMAGES = 4;
|
||||
|
||||
@@ -153,6 +154,7 @@ export function registerDevlogEndpoint(app, client) {
|
||||
? { content: `<@&${pingRole}>`, allowedMentions: { roles: [pingRole] } }
|
||||
: {}),
|
||||
});
|
||||
await maybeCrosspost(message);
|
||||
|
||||
// Diskussions-Thread unterm Devlog (Fehler nicht fatal — z. B. fehlende Rechte)
|
||||
if (devlogThreadsEnabled() && typeof message.startThread === 'function') {
|
||||
|
||||
@@ -65,7 +65,9 @@ export async function scheduledPostsTick(client) {
|
||||
try {
|
||||
const channel = await client.channels.fetch(post.channel_id).catch(() => null);
|
||||
if (channel?.isTextBased()) {
|
||||
await channel.send(buildScheduledPayload(post));
|
||||
const message = await channel.send(buildScheduledPayload(post));
|
||||
const { maybeCrosspost } = await import('../embeds.js');
|
||||
await maybeCrosspost(message);
|
||||
console.log(`[scheduled] Post ${post.id} → #${channel.name ?? post.channel_id}`);
|
||||
} else {
|
||||
console.warn(`[scheduled] Post ${post.id}: Kanal ${post.channel_id} nicht erreichbar`);
|
||||
|
||||
+62
-6
@@ -4,12 +4,12 @@ import fastifyCookie from '@fastify/cookie';
|
||||
import fastifyMultipart from '@fastify/multipart';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
import crypto from 'node:crypto';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { config } from '../config.js';
|
||||
import { saveCommits, saveRelease, takeBugReport } from '../db.js';
|
||||
import { commitFeedEnabled, branchAllowed, repoIgnored } from '../runtime-settings.js';
|
||||
import { saveCommits, saveRelease, takeBugReport, getDevlog } from '../db.js';
|
||||
import { commitFeedEnabled, branchAllowed, repoIgnored, publicUrl, brandName } from '../runtime-settings.js';
|
||||
import { postPushEmbed } from '../bot/commit-feed.js';
|
||||
import { postReleaseEmbed } from '../bot/release-feed.js';
|
||||
import { registerAuthRoutes } from './auth.js';
|
||||
@@ -90,14 +90,70 @@ export async function startWebServer(client) {
|
||||
});
|
||||
|
||||
if (existsSync(frontendDist)) {
|
||||
// React-Frontend ausliefern; unbekannte GET-Pfade → index.html (SPA-Routing)
|
||||
await app.register(fastifyStatic, { root: frontendDist });
|
||||
// React-Frontend ausliefern; unbekannte GET-Pfade → index.html (SPA-Routing).
|
||||
// Dabei werden Open-Graph-Tags injiziert, damit geteilte Links (Discord,
|
||||
// WhatsApp, …) eine hübsche Vorschau zeigen — Devlog-Links sogar mit Bild.
|
||||
const indexHtml = readFileSync(join(frontendDist, 'index.html'), 'utf8');
|
||||
const esc = (s) => String(s).replaceAll('&', '&').replaceAll('<', '<').replaceAll('"', '"');
|
||||
|
||||
function ogTagsFor(url) {
|
||||
const base = publicUrl();
|
||||
let title = `${brandName()} // Community-Hub`;
|
||||
let description = 'Devlogs, Roadmap, Server-Status und alles aus der Community — direkt aus dem Discord.';
|
||||
let image = null;
|
||||
|
||||
const pageTitles = {
|
||||
'/devlogs': ['Devlog-Archiv', 'Entwicklungs-Updates, automatisch archiviert.'],
|
||||
'/roadmap': ['Roadmap', 'Meilensteine und Community-Wünsche zum Abstimmen.'],
|
||||
'/server': ['Server-Status', 'Alle Game-Server live — Spielerzahlen und Verlauf.'],
|
||||
'/galerie': ['Galerie', 'Screenshots aus der Community.'],
|
||||
'/level': ['Level', 'Die XP-Bestenliste des Discords.'],
|
||||
'/events': ['Events', 'Playtests, Streams und was sonst ansteht.'],
|
||||
'/changelog': ['Changelog', 'Alle Releases mit Notes.'],
|
||||
};
|
||||
const path = url.split('?')[0];
|
||||
|
||||
const devlogId = path.match(/^\/devlogs\/(\d{15,21})$/)?.[1];
|
||||
const devlog = devlogId ? getDevlog(devlogId) : null;
|
||||
if (devlog) {
|
||||
const prose = devlog.content.replace(/^[-*>]\s+/gm, '').replace(/[#*`_]/g, '').replace(/\s+/g, ' ').trim();
|
||||
title = `${brandName()} // Devlog — ${new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: 'long', year: 'numeric' }).format(new Date(devlog.posted_at))}`;
|
||||
description = prose.slice(0, 200) + (prose.length > 200 ? ' …' : '');
|
||||
const firstImage = JSON.parse(devlog.images || '[]')[0];
|
||||
if (firstImage) image = `${base}/devlog-assets/${firstImage}`;
|
||||
} else if (pageTitles[path]) {
|
||||
title = `${brandName()} // ${pageTitles[path][0]}`;
|
||||
description = pageTitles[path][1];
|
||||
}
|
||||
|
||||
return [
|
||||
`<meta property="og:site_name" content="${esc(brandName())}">`,
|
||||
`<meta property="og:title" content="${esc(title)}">`,
|
||||
`<meta property="og:description" content="${esc(description)}">`,
|
||||
`<meta property="og:url" content="${esc(base + path)}">`,
|
||||
`<meta property="og:type" content="website">`,
|
||||
`<meta name="theme-color" content="#f5c518">`,
|
||||
`<meta name="description" content="${esc(description)}">`,
|
||||
image ? `<meta property="og:image" content="${esc(image)}">` : '',
|
||||
image ? `<meta name="twitter:card" content="summary_large_image">` : '<meta name="twitter:card" content="summary">',
|
||||
].filter(Boolean).join('\n ');
|
||||
}
|
||||
|
||||
const sendInjected = (request, reply) => {
|
||||
const html = indexHtml.replace('</head>', ` ${ogTagsFor(request.url)}\n</head>`);
|
||||
return reply.type('text/html; charset=utf-8').send(html);
|
||||
};
|
||||
|
||||
// index: false → "/" wird nicht statisch bedient, sondern von der eigenen
|
||||
// Route darunter (sonst gäbe es keine OG-Tags auf der Startseite)
|
||||
await app.register(fastifyStatic, { root: frontendDist, index: false });
|
||||
app.get('/', sendInjected);
|
||||
app.setNotFoundHandler((request, reply) => {
|
||||
const isApiPath = ['/api', '/auth', '/webhooks'].some((p) =>
|
||||
request.url.startsWith(p)
|
||||
);
|
||||
if (request.method === 'GET' && !isApiPath) {
|
||||
return reply.sendFile('index.html');
|
||||
return sendInjected(request, reply);
|
||||
}
|
||||
return reply.code(404).send({ error: 'not found' });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user