Setup-Seite: Kanal-Auswahl, Feed-Regeln und Status im Webinterface
- Settings-Tabelle in SQLite; Env-Variablen nur noch Fallback, Änderungen greifen sofort - /settings (Admin): Devlog-/Commit-Kanal als Dropdown aus allen sichtbaren Textkanälen, je mit Test-senden-Button - Commit-Feed-Regeln: an/aus, Branch-Filter, ignorierte Repos (archiviert wird immer, gefiltert wird nur das Posten; ignorierte Repos komplett übersprungen) - Status-Panel: Bot-Tag, Uptime, Devlog-/Commit-Zahlen, DB-Größe - API: GET/PUT /api/settings, POST /api/settings/test/:target (alles Admin-only) - COMMIT_CHANNEL_ID/DEVLOG_CHANNEL_ID in config optional Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -124,6 +124,24 @@ Bilder werden lokal gespeichert (`data/devlog_images/`), weil Discord-CDN-Links
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Setup-Seite im Webinterface (Admin)
|
||||||
|
|
||||||
|
Unter **/settings** (Nav: „Setup", nur als Admin sichtbar) lassen sich zur Laufzeit
|
||||||
|
ändern — gespeichert in SQLite, **Env-Variablen sind nur noch der Fallback**,
|
||||||
|
Änderungen greifen sofort ohne Redeploy:
|
||||||
|
|
||||||
|
- **Devlog-Kanal** und **Commit-Kanal** als Dropdown (alle Textkanäle, die der
|
||||||
|
Bot sieht) — mit **Test senden**-Button pro Kanal
|
||||||
|
- **Commit-Feed an/aus** (aus = Commits werden weiter archiviert, nur nicht gepostet)
|
||||||
|
- **Branch-Filter** (kommagetrennt, leer = alle) — gefilterte Branches werden
|
||||||
|
archiviert, aber nicht gepostet
|
||||||
|
- **Ignorierte Repos** (kommagetrennt, z. B. `D4rkst3r/ecobot`) — komplett übersprungen
|
||||||
|
- **Status-Panel:** Bot-Account, Uptime, Anzahl Devlogs/Commits, DB-Größe
|
||||||
|
|
||||||
|
`COMMIT_CHANNEL_ID` und `DEVLOG_CHANNEL_ID` sind damit optional geworden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Setup: Webinterface
|
## Setup: Webinterface
|
||||||
|
|
||||||
**Zugriffsmodell:** Devlog-Archiv ist öffentlich (wie der Discord-Kanal),
|
**Zugriffsmodell:** Devlog-Archiv ist öffentlich (wie der Discord-Kanal),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Routes, Route, NavLink, Navigate } from 'react-router-dom';
|
|||||||
import { apiGet } from './api.js';
|
import { apiGet } from './api.js';
|
||||||
import Devlogs from './pages/Devlogs.jsx';
|
import Devlogs from './pages/Devlogs.jsx';
|
||||||
import Commits from './pages/Commits.jsx';
|
import Commits from './pages/Commits.jsx';
|
||||||
|
import Settings from './pages/Settings.jsx';
|
||||||
|
|
||||||
function DiscordMark() {
|
function DiscordMark() {
|
||||||
return (
|
return (
|
||||||
@@ -69,6 +70,7 @@ export default function App() {
|
|||||||
<nav className="nav-links">
|
<nav className="nav-links">
|
||||||
<NavLink to="/devlogs">Devlog</NavLink>
|
<NavLink to="/devlogs">Devlog</NavLink>
|
||||||
{me.admin && <NavLink to="/commits">Commits</NavLink>}
|
{me.admin && <NavLink to="/commits">Commits</NavLink>}
|
||||||
|
{me.admin && <NavLink to="/settings">Setup</NavLink>}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="auth">
|
<div className="auth">
|
||||||
@@ -91,6 +93,7 @@ export default function App() {
|
|||||||
<Route path="/" element={<Navigate to="/devlogs" replace />} />
|
<Route path="/" element={<Navigate to="/devlogs" replace />} />
|
||||||
<Route path="/devlogs" element={<Devlogs me={me} />} />
|
<Route path="/devlogs" element={<Devlogs me={me} />} />
|
||||||
<Route path="/commits" element={<Commits me={me} />} />
|
<Route path="/commits" element={<Commits me={me} />} />
|
||||||
|
<Route path="/settings" element={<Settings me={me} />} />
|
||||||
<Route path="*" element={<Navigate to="/devlogs" replace />} />
|
<Route path="*" element={<Navigate to="/devlogs" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -11,3 +11,15 @@ async function api(path, options = {}) {
|
|||||||
|
|
||||||
export const apiGet = (path) => api(path);
|
export const apiGet = (path) => api(path);
|
||||||
export const apiDelete = (path) => api(path, { method: 'DELETE' });
|
export const apiDelete = (path) => api(path, { method: 'DELETE' });
|
||||||
|
export const apiPut = (path, body) =>
|
||||||
|
api(path, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
export const apiPost = (path, body = {}) =>
|
||||||
|
api(path, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { apiGet, apiPut, apiPost } from '../api.js';
|
||||||
|
|
||||||
|
/** Sekunden → "2d 4h 13m" */
|
||||||
|
function fmtUptime(s) {
|
||||||
|
const d = Math.floor(s / 86400);
|
||||||
|
const h = Math.floor((s % 86400) / 3600);
|
||||||
|
const m = Math.floor((s % 3600) / 60);
|
||||||
|
return [d && `${d}d`, (d || h) && `${h}h`, `${m}m`].filter(Boolean).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtBytes(b) {
|
||||||
|
if (b > 1024 * 1024) return `${(b / 1024 / 1024).toFixed(1)} MB`;
|
||||||
|
return `${(b / 1024).toFixed(0)} KB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Settings({ me }) {
|
||||||
|
const [data, setData] = useState(null);
|
||||||
|
const [form, setForm] = useState(null);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [feedback, setFeedback] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!me.admin) return;
|
||||||
|
apiGet('/api/settings')
|
||||||
|
.then((d) => {
|
||||||
|
setData(d);
|
||||||
|
setForm(d.settings);
|
||||||
|
})
|
||||||
|
.catch((e) => setError(e.status));
|
||||||
|
}, [me.admin]);
|
||||||
|
|
||||||
|
function flash(msg) {
|
||||||
|
setFeedback(msg);
|
||||||
|
setTimeout(() => setFeedback(null), 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
try {
|
||||||
|
const res = await apiPut('/api/settings', form);
|
||||||
|
setForm(res.settings);
|
||||||
|
flash('✓ Gespeichert');
|
||||||
|
} catch {
|
||||||
|
flash('✗ Speichern fehlgeschlagen');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendTest(target) {
|
||||||
|
try {
|
||||||
|
await apiPost(`/api/settings/test/${target}`);
|
||||||
|
flash('✓ Test-Nachricht gesendet');
|
||||||
|
} catch {
|
||||||
|
flash('✗ Test fehlgeschlagen — Kanal/Rechte prüfen');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = (
|
||||||
|
<section className="page-header">
|
||||||
|
<div className="page-bg-text">SETUP</div>
|
||||||
|
<div className="page-header-grid" aria-hidden="true" />
|
||||||
|
<div className="page-header-content">
|
||||||
|
<div className="page-tag">// Admin</div>
|
||||||
|
<h1 className="page-title">Setup</h1>
|
||||||
|
<p className="page-subtitle">Kanäle, Feed-Regeln und Status — Änderungen greifen sofort.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (me.loading) return <>{header}<section className="content"><p className="notice">Lade …</p></section></>;
|
||||||
|
if (!me.user || !me.admin) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{header}
|
||||||
|
<section className="content">
|
||||||
|
<div className="notice center">
|
||||||
|
<p>🔒 Nur für den Admin.</p>
|
||||||
|
{!me.user && <a className="btn" href="/auth/login">Login mit Discord</a>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (error) return <>{header}<section className="content"><p className="notice">Fehler beim Laden ({error}).</p></section></>;
|
||||||
|
if (!data || !form) return <>{header}<section className="content"><p className="notice">Lade …</p></section></>;
|
||||||
|
|
||||||
|
const channelOptions = data.channels.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
#{c.name} — {c.guild}
|
||||||
|
</option>
|
||||||
|
));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{header}
|
||||||
|
<section className="content">
|
||||||
|
{/* Status */}
|
||||||
|
<div className="settings-section">
|
||||||
|
<h2 className="settings-title">// Status</h2>
|
||||||
|
<div className="stats">
|
||||||
|
<div className="stat"><span className="stat-value">{data.status.botTag ?? '—'}</span><span className="stat-label">Bot</span></div>
|
||||||
|
<div className="stat"><span className="stat-value">{fmtUptime(data.status.uptimeSeconds)}</span><span className="stat-label">Uptime</span></div>
|
||||||
|
<div className="stat"><span className="stat-value">{data.status.devlogs}</span><span className="stat-label">Devlogs</span></div>
|
||||||
|
<div className="stat"><span className="stat-value">{data.status.commits}</span><span className="stat-label">Commits</span></div>
|
||||||
|
<div className="stat"><span className="stat-value">{fmtBytes(data.status.dbSizeBytes)}</span><span className="stat-label">Datenbank</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Kanäle */}
|
||||||
|
<div className="settings-section">
|
||||||
|
<h2 className="settings-title">// Kanäle</h2>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>Devlog-Kanal <span className="field-hint">öffentlich — hier postet der Bot die Devlogs</span></label>
|
||||||
|
<div className="field-row">
|
||||||
|
<select
|
||||||
|
value={form.devlog_channel_id ?? ''}
|
||||||
|
onChange={(e) => setForm({ ...form, devlog_channel_id: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="" disabled>— Kanal wählen —</option>
|
||||||
|
{channelOptions}
|
||||||
|
</select>
|
||||||
|
<button className="btn" onClick={() => sendTest('devlog')}>Test senden</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>Commit-Kanal <span className="field-hint">privat — hier landen die Push-Embeds</span></label>
|
||||||
|
<div className="field-row">
|
||||||
|
<select
|
||||||
|
value={form.commit_channel_id ?? ''}
|
||||||
|
onChange={(e) => setForm({ ...form, commit_channel_id: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="" disabled>— Kanal wählen —</option>
|
||||||
|
{channelOptions}
|
||||||
|
</select>
|
||||||
|
<button className="btn" onClick={() => sendTest('commit')}>Test senden</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Commit-Feed */}
|
||||||
|
<div className="settings-section">
|
||||||
|
<h2 className="settings-title">// Commit-Feed</h2>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label className="toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.commit_feed_enabled}
|
||||||
|
onChange={(e) => setForm({ ...form, commit_feed_enabled: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<span>Push-Embeds posten</span>
|
||||||
|
<span className="field-hint">aus = Commits werden weiter archiviert, nur nicht gepostet</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>Branch-Filter <span className="field-hint">kommagetrennt, leer = alle Branches posten</span></label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="z. B. main, release"
|
||||||
|
value={form.commit_branch_filter}
|
||||||
|
onChange={(e) => setForm({ ...form, commit_branch_filter: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>Ignorierte Repos <span className="field-hint">kommagetrennt, volle Namen — werden komplett übersprungen</span></label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="z. B. D4rkst3r/ecobot"
|
||||||
|
value={form.ignored_repos}
|
||||||
|
onChange={(e) => setForm({ ...form, ignored_repos: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-actions">
|
||||||
|
<button className="btn btn-save" onClick={save}>Speichern</button>
|
||||||
|
{feedback && <span className="feedback">{feedback}</span>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -354,6 +354,86 @@ a.sha:hover { border-color: var(--neon); background: rgba(245, 197, 24, .07); }
|
|||||||
font-size: .68rem; color: var(--muted2); white-space: nowrap;
|
font-size: .68rem; color: var(--muted2); white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── SETTINGS ──────────────────────────────────────── */
|
||||||
|
.settings-section {
|
||||||
|
background: var(--bg2);
|
||||||
|
border: 1px solid rgba(255, 255, 255, .06);
|
||||||
|
padding: 1.4rem 1.6rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.settings-title {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: .72rem; letter-spacing: .3em; text-transform: uppercase;
|
||||||
|
color: var(--neon2);
|
||||||
|
margin-bottom: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats { display: flex; flex-wrap: wrap; gap: 2.5rem; }
|
||||||
|
.stat { display: flex; flex-direction: column; gap: .1rem; }
|
||||||
|
.stat-value {
|
||||||
|
font-family: var(--display);
|
||||||
|
font-size: 1.5rem; letter-spacing: .05em; color: var(--text);
|
||||||
|
}
|
||||||
|
.stat-label {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: .6rem; letter-spacing: .25em; text-transform: uppercase;
|
||||||
|
color: var(--muted2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field { margin-bottom: 1.1rem; }
|
||||||
|
.field:last-child { margin-bottom: 0; }
|
||||||
|
.field > label {
|
||||||
|
display: block;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: .68rem; letter-spacing: .15em; text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: .4rem;
|
||||||
|
}
|
||||||
|
.field-hint {
|
||||||
|
text-transform: none; letter-spacing: .03em;
|
||||||
|
color: var(--muted2); margin-left: .5rem;
|
||||||
|
}
|
||||||
|
.field-row { display: flex; gap: .6rem; }
|
||||||
|
.field-row select { flex: 1; }
|
||||||
|
|
||||||
|
.field select, .field input[type="text"] {
|
||||||
|
width: 100%;
|
||||||
|
background: #0d0d0d;
|
||||||
|
border: 1px solid rgba(255, 255, 255, .12);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--body);
|
||||||
|
font-size: .95rem;
|
||||||
|
padding: .5rem .7rem;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color .2s;
|
||||||
|
}
|
||||||
|
.field select:focus, .field input[type="text"]:focus { border-color: var(--neon); }
|
||||||
|
.field select option { background: #0d0d0d; }
|
||||||
|
|
||||||
|
.toggle { display: flex; align-items: center; gap: .6rem; cursor: pointer; }
|
||||||
|
.toggle input {
|
||||||
|
appearance: none;
|
||||||
|
width: 34px; height: 18px; flex-shrink: 0;
|
||||||
|
background: #222; border: 1px solid rgba(255, 255, 255, .15);
|
||||||
|
position: relative; cursor: pointer;
|
||||||
|
transition: background .2s, border-color .2s;
|
||||||
|
}
|
||||||
|
.toggle input::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute; top: 2px; left: 2px;
|
||||||
|
width: 12px; height: 12px;
|
||||||
|
background: var(--muted);
|
||||||
|
transition: transform .2s, background .2s;
|
||||||
|
}
|
||||||
|
.toggle input:checked { background: rgba(245, 197, 24, .2); border-color: var(--neon); }
|
||||||
|
.toggle input:checked::after { transform: translateX(16px); background: var(--neon); }
|
||||||
|
.toggle > span:first-of-type { color: var(--text); font-size: .95rem; }
|
||||||
|
|
||||||
|
.settings-actions { display: flex; align-items: center; gap: 1rem; }
|
||||||
|
.btn-save { background: var(--neon); color: #0a0a0a; border-color: var(--neon); font-weight: 600; }
|
||||||
|
.btn-save:hover:not(:disabled) { background: var(--text); border-color: var(--text); }
|
||||||
|
.feedback { font-family: var(--mono); font-size: .75rem; letter-spacing: .1em; color: var(--success); }
|
||||||
|
|
||||||
/* ── PAGINATION ────────────────────────────────────── */
|
/* ── PAGINATION ────────────────────────────────────── */
|
||||||
.pager {
|
.pager {
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
|||||||
+3
-2
@@ -1,6 +1,7 @@
|
|||||||
// Discord-Client: Commands laden, registrieren und Interactions verarbeiten
|
// Discord-Client: Commands laden, registrieren und Interactions verarbeiten
|
||||||
import { Client, Collection, Events, GatewayIntentBits, Partials, REST, Routes } from 'discord.js';
|
import { Client, Collection, Events, GatewayIntentBits, Partials, REST, Routes } from 'discord.js';
|
||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
|
import { devlogChannelId } from '../runtime-settings.js';
|
||||||
import { archiveDevlogMessage, removeDevlog } from './devlog-archive.js';
|
import { archiveDevlogMessage, removeDevlog } from './devlog-archive.js';
|
||||||
import * as ping from './commands/ping.js';
|
import * as ping from './commands/ping.js';
|
||||||
import * as devlogBackfill from './commands/devlog-backfill.js';
|
import * as devlogBackfill from './commands/devlog-backfill.js';
|
||||||
@@ -34,7 +35,7 @@ export async function startBot() {
|
|||||||
|
|
||||||
// Live-Archivierung: neue Devlogs (Webhook-Posts im Devlog-Kanal) sofort sichern
|
// Live-Archivierung: neue Devlogs (Webhook-Posts im Devlog-Kanal) sofort sichern
|
||||||
client.on(Events.MessageCreate, async (message) => {
|
client.on(Events.MessageCreate, async (message) => {
|
||||||
if (message.channelId !== config.devlogChannelId) return;
|
if (message.channelId !== devlogChannelId()) return;
|
||||||
try {
|
try {
|
||||||
if (await archiveDevlogMessage(message)) {
|
if (await archiveDevlogMessage(message)) {
|
||||||
console.log(`[devlog] Neues Devlog archiviert (${message.id})`);
|
console.log(`[devlog] Neues Devlog archiviert (${message.id})`);
|
||||||
@@ -46,7 +47,7 @@ export async function startBot() {
|
|||||||
|
|
||||||
// In Discord gelöscht = im Archiv gelöscht (inkl. lokaler Bilder)
|
// In Discord gelöscht = im Archiv gelöscht (inkl. lokaler Bilder)
|
||||||
client.on(Events.MessageDelete, async (message) => {
|
client.on(Events.MessageDelete, async (message) => {
|
||||||
if (message.channelId !== config.devlogChannelId) return;
|
if (message.channelId !== devlogChannelId()) return;
|
||||||
try {
|
try {
|
||||||
if (await removeDevlog(message.id)) {
|
if (await removeDevlog(message.id)) {
|
||||||
console.log(`[devlog] Archiv-Eintrag entfernt (Discord-Nachricht ${message.id} gelöscht)`);
|
console.log(`[devlog] Archiv-Eintrag entfernt (Discord-Nachricht ${message.id} gelöscht)`);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// /devlog-backfill — komplette Kanal-Historie scannen und alte Devlogs nacharchivieren (Admin only)
|
// /devlog-backfill — komplette Kanal-Historie scannen und alte Devlogs nacharchivieren (Admin only)
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js';
|
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js';
|
||||||
import { config } from '../../config.js';
|
import { devlogChannelId } from '../../runtime-settings.js';
|
||||||
import { archiveDevlogMessage } from '../devlog-archive.js';
|
import { archiveDevlogMessage } from '../devlog-archive.js';
|
||||||
|
|
||||||
export const data = new SlashCommandBuilder()
|
export const data = new SlashCommandBuilder()
|
||||||
@@ -11,7 +11,12 @@ export const data = new SlashCommandBuilder()
|
|||||||
export async function execute(interaction) {
|
export async function execute(interaction) {
|
||||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||||
|
|
||||||
const channel = await interaction.client.channels.fetch(config.devlogChannelId);
|
const targetChannel = devlogChannelId();
|
||||||
|
if (!targetChannel) {
|
||||||
|
await interaction.editReply('❌ Kein Devlog-Kanal konfiguriert (Settings-Seite im Webinterface).');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const channel = await interaction.client.channels.fetch(targetChannel);
|
||||||
if (!channel?.isTextBased()) {
|
if (!channel?.isTextBased()) {
|
||||||
await interaction.editReply('❌ Devlog-Kanal nicht gefunden oder kein Textkanal.');
|
await interaction.editReply('❌ Devlog-Kanal nicht gefunden oder kein Textkanal.');
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Baut aus einem Gitea-Push ein hübsches Embed und postet es in den Commit-Kanal
|
// Baut aus einem Gitea-Push ein hübsches Embed und postet es in den Commit-Kanal
|
||||||
import { EmbedBuilder } from 'discord.js';
|
import { EmbedBuilder } from 'discord.js';
|
||||||
import { config } from '../config.js';
|
import { commitChannelId } from '../runtime-settings.js';
|
||||||
|
|
||||||
const GITEA_GREEN = 0x609926;
|
const GITEA_GREEN = 0x609926;
|
||||||
const MAX_COMMITS_SHOWN = 10;
|
const MAX_COMMITS_SHOWN = 10;
|
||||||
@@ -19,9 +19,13 @@ function firstLine(message, maxLen = 72) {
|
|||||||
* commits: Array<{sha: string, message: string, url: string, author_name: string}> }} push
|
* commits: Array<{sha: string, message: string, url: string, author_name: string}> }} push
|
||||||
*/
|
*/
|
||||||
export async function postPushEmbed(client, push) {
|
export async function postPushEmbed(client, push) {
|
||||||
const channel = await client.channels.fetch(config.commitChannelId);
|
const channelId = commitChannelId();
|
||||||
|
if (!channelId) {
|
||||||
|
throw new Error('Kein Commit-Kanal konfiguriert (Settings-Seite oder COMMIT_CHANNEL_ID)');
|
||||||
|
}
|
||||||
|
const channel = await client.channels.fetch(channelId);
|
||||||
if (!channel?.isTextBased()) {
|
if (!channel?.isTextBased()) {
|
||||||
throw new Error(`Commit-Kanal ${config.commitChannelId} nicht gefunden oder kein Textkanal`);
|
throw new Error(`Commit-Kanal ${channelId} nicht gefunden oder kein Textkanal`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const count = push.commits.length;
|
const count = push.commits.length;
|
||||||
|
|||||||
+5
-4
@@ -19,15 +19,16 @@ export const config = {
|
|||||||
discordGuildId: process.env.DISCORD_GUILD_ID || null,
|
discordGuildId: process.env.DISCORD_GUILD_ID || null,
|
||||||
|
|
||||||
// Devlog-Archiv (Feature 3)
|
// Devlog-Archiv (Feature 3)
|
||||||
// Kanal, in den der Bot die Devlogs postet (und den er mitliest)
|
// Kanal-Defaults — können über die Settings-Seite im Webinterface
|
||||||
devlogChannelId: required('DEVLOG_CHANNEL_ID'),
|
// überschrieben werden (DB gewinnt, Env ist Fallback)
|
||||||
|
devlogChannelId: process.env.DEVLOG_CHANNEL_ID || null,
|
||||||
// Secret im Pfad des Devlog-Endpoints: /webhooks/devlog/<secret>
|
// Secret im Pfad des Devlog-Endpoints: /webhooks/devlog/<secret>
|
||||||
// (steckt in der URL in tools/.devlog_webhook im EcoGame-Repo)
|
// (steckt in der URL in tools/.devlog_webhook im EcoGame-Repo)
|
||||||
devlogPostSecret: required('DEVLOG_POST_SECRET'),
|
devlogPostSecret: required('DEVLOG_POST_SECRET'),
|
||||||
|
|
||||||
// Commit-Feed (Feature 2)
|
// Commit-Feed (Feature 2)
|
||||||
// Kanal, in den Push-Embeds gepostet werden
|
// Kanal, in den Push-Embeds gepostet werden (Default, siehe oben)
|
||||||
commitChannelId: required('COMMIT_CHANNEL_ID'),
|
commitChannelId: process.env.COMMIT_CHANNEL_ID || null,
|
||||||
// Shared Secret — muss identisch im Gitea-Webhook eingetragen sein
|
// Shared Secret — muss identisch im Gitea-Webhook eingetragen sein
|
||||||
giteaWebhookSecret: required('GITEA_WEBHOOK_SECRET'),
|
giteaWebhookSecret: required('GITEA_WEBHOOK_SECRET'),
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// SQLite-Anbindung (better-sqlite3, synchron & schnell) — Schema wird beim Start angelegt
|
// SQLite-Anbindung (better-sqlite3, synchron & schnell) — Schema wird beim Start angelegt
|
||||||
import Database from 'better-sqlite3';
|
import Database from 'better-sqlite3';
|
||||||
import { mkdirSync } from 'node:fs';
|
import { mkdirSync, statSync } from 'node:fs';
|
||||||
import { dirname, resolve } from 'node:path';
|
import { dirname, resolve } from 'node:path';
|
||||||
import { config } from './config.js';
|
import { config } from './config.js';
|
||||||
|
|
||||||
@@ -41,6 +41,22 @@ if (!devlogCols.some((c) => c.name === 'images')) {
|
|||||||
db.exec(`ALTER TABLE devlogs ADD COLUMN images TEXT NOT NULL DEFAULT '[]'`);
|
db.exec(`ALTER TABLE devlogs ADD COLUMN images TEXT NOT NULL DEFAULT '[]'`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Laufzeit-Einstellungen (Settings-Seite im Webinterface) — überschreiben Env-Defaults
|
||||||
|
db.exec('CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)');
|
||||||
|
const getSettingStmt = db.prepare('SELECT value FROM settings WHERE key = ?');
|
||||||
|
const setSettingStmt = db.prepare(`
|
||||||
|
INSERT INTO settings (key, value) VALUES (?, ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
||||||
|
`);
|
||||||
|
|
||||||
|
export function getSetting(key) {
|
||||||
|
return getSettingStmt.get(key)?.value ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSetting(key, value) {
|
||||||
|
setSettingStmt.run(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
const insertCommit = db.prepare(`
|
const insertCommit = db.prepare(`
|
||||||
INSERT OR IGNORE INTO commits (sha, repo, branch, message, author_name, author_user, url, committed_at)
|
INSERT OR IGNORE INTO commits (sha, repo, branch, message, author_name, author_user, url, committed_at)
|
||||||
VALUES (@sha, @repo, @branch, @message, @author_name, @author_user, @url, @committed_at)
|
VALUES (@sha, @repo, @branch, @message, @author_name, @author_user, @url, @committed_at)
|
||||||
@@ -103,3 +119,12 @@ const countCommitsStmt = db.prepare('SELECT COUNT(*) AS n FROM commits');
|
|||||||
export function listCommits(limit, offset) {
|
export function listCommits(limit, offset) {
|
||||||
return { items: selectCommits.all(limit, offset), total: countCommitsStmt.get().n };
|
return { items: selectCommits.all(limit, offset), total: countCommitsStmt.get().n };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Statistiken fürs Status-Panel der Settings-Seite */
|
||||||
|
export function archiveStats() {
|
||||||
|
return {
|
||||||
|
devlogs: countDevlogsStmt.get().n,
|
||||||
|
commits: countCommitsStmt.get().n,
|
||||||
|
dbSizeBytes: statSync(dbFile).size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// Effektive Laufzeit-Konfiguration: DB-Setting (Webinterface) vor Env-Variable.
|
||||||
|
// Wird bei jedem Zugriff frisch aufgelöst — Änderungen greifen ohne Neustart.
|
||||||
|
import { getSetting } from './db.js';
|
||||||
|
import { config } from './config.js';
|
||||||
|
|
||||||
|
export function commitChannelId() {
|
||||||
|
return getSetting('commit_channel_id') || config.commitChannelId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function devlogChannelId() {
|
||||||
|
return getSetting('devlog_channel_id') || config.devlogChannelId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Commit-Feed global an/aus (Default: an). Aus = weiter archivieren, nur nicht posten. */
|
||||||
|
export function commitFeedEnabled() {
|
||||||
|
return getSetting('commit_feed_enabled') !== '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kommagetrennte Liste in Array übersetzen ('' → leer) */
|
||||||
|
function csv(value) {
|
||||||
|
return (value ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Branch-Filter: leer = alle Branches werden gepostet */
|
||||||
|
export function branchAllowed(branch) {
|
||||||
|
const allowed = csv(getSetting('commit_branch_filter'));
|
||||||
|
return allowed.length === 0 || allowed.includes(branch);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ignorierte Repos (z. B. "D4rkst3r/ecobot") — werden komplett übersprungen */
|
||||||
|
export function repoIgnored(repo) {
|
||||||
|
return csv(getSetting('ignored_repos')).some(
|
||||||
|
(r) => r.toLowerCase() === repo.toLowerCase()
|
||||||
|
);
|
||||||
|
}
|
||||||
+113
-9
@@ -1,6 +1,8 @@
|
|||||||
// REST-API fürs Webinterface: Devlogs öffentlich, Commits nur für den Admin
|
// REST-API fürs Webinterface: Devlogs öffentlich, Commits + Settings nur für den Admin
|
||||||
import { listDevlogs, listCommits } from '../db.js';
|
import { EmbedBuilder } from 'discord.js';
|
||||||
|
import { listDevlogs, listCommits, archiveStats, getSetting, setSetting } from '../db.js';
|
||||||
import { removeDevlog } from '../bot/devlog-archive.js';
|
import { removeDevlog } from '../bot/devlog-archive.js';
|
||||||
|
import { commitChannelId, devlogChannelId } from '../runtime-settings.js';
|
||||||
import { getSessionUser, isAdmin } from './auth.js';
|
import { getSessionUser, isAdmin } from './auth.js';
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
@@ -11,7 +13,15 @@ function paging(request) {
|
|||||||
return { limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE, page };
|
return { limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE, page };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerApiRoutes(app) {
|
/** Admin-Guard: null = okay, sonst wurde bereits eine Fehler-Antwort gesendet */
|
||||||
|
function requireAdmin(request, reply) {
|
||||||
|
const user = getSessionUser(request);
|
||||||
|
if (!user) return reply.code(401).send({ error: 'login required' });
|
||||||
|
if (!isAdmin(user)) return reply.code(403).send({ error: 'admin only' });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerApiRoutes(app, client) {
|
||||||
// Wer bin ich? (fürs Frontend: Login-Status + Admin-Flag)
|
// Wer bin ich? (fürs Frontend: Login-Status + Admin-Flag)
|
||||||
app.get('/api/me', async (request) => {
|
app.get('/api/me', async (request) => {
|
||||||
const user = getSessionUser(request);
|
const user = getSessionUser(request);
|
||||||
@@ -32,9 +42,7 @@ export function registerApiRoutes(app) {
|
|||||||
|
|
||||||
// Devlog aus dem Archiv löschen — nur Admin (z. B. alte Test-Posts)
|
// Devlog aus dem Archiv löschen — nur Admin (z. B. alte Test-Posts)
|
||||||
app.delete('/api/devlogs/:id', async (request, reply) => {
|
app.delete('/api/devlogs/:id', async (request, reply) => {
|
||||||
const user = getSessionUser(request);
|
if (requireAdmin(request, reply)) return;
|
||||||
if (!user) return reply.code(401).send({ error: 'login required' });
|
|
||||||
if (!isAdmin(user)) return reply.code(403).send({ error: 'admin only' });
|
|
||||||
|
|
||||||
const deleted = await removeDevlog(request.params.id);
|
const deleted = await removeDevlog(request.params.id);
|
||||||
request.log.info(`Devlog ${request.params.id} per Web-UI gelöscht: ${deleted}`);
|
request.log.info(`Devlog ${request.params.id} per Web-UI gelöscht: ${deleted}`);
|
||||||
@@ -43,12 +51,108 @@ export function registerApiRoutes(app) {
|
|||||||
|
|
||||||
// Commit-Feed — nur für den Admin (spiegelt den privaten #-gitea-Kanal)
|
// Commit-Feed — nur für den Admin (spiegelt den privaten #-gitea-Kanal)
|
||||||
app.get('/api/commits', async (request, reply) => {
|
app.get('/api/commits', async (request, reply) => {
|
||||||
const user = getSessionUser(request);
|
if (requireAdmin(request, reply)) return;
|
||||||
if (!user) return reply.code(401).send({ error: 'login required' });
|
|
||||||
if (!isAdmin(user)) return reply.code(403).send({ error: 'admin only' });
|
|
||||||
|
|
||||||
const { limit, offset, page } = paging(request);
|
const { limit, offset, page } = paging(request);
|
||||||
const { items, total } = listCommits(limit, offset);
|
const { items, total } = listCommits(limit, offset);
|
||||||
return { items, total, page, pageSize: PAGE_SIZE };
|
return { items, total, page, pageSize: PAGE_SIZE };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Settings (Admin) ---
|
||||||
|
|
||||||
|
/** Alle Textkanäle, die der Bot sehen kann (für die Dropdowns) */
|
||||||
|
function listChannels() {
|
||||||
|
const channels = [];
|
||||||
|
for (const guild of client.guilds.cache.values()) {
|
||||||
|
for (const ch of guild.channels.cache.values()) {
|
||||||
|
if (ch.isTextBased?.() && ch.viewable !== false) {
|
||||||
|
channels.push({ id: ch.id, name: ch.name, guild: guild.name });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return channels.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentSettings() {
|
||||||
|
return {
|
||||||
|
commit_channel_id: commitChannelId(),
|
||||||
|
devlog_channel_id: devlogChannelId(),
|
||||||
|
commit_feed_enabled: getSetting('commit_feed_enabled') !== '0',
|
||||||
|
commit_branch_filter: getSetting('commit_branch_filter') ?? '',
|
||||||
|
ignored_repos: getSetting('ignored_repos') ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/api/settings', async (request, reply) => {
|
||||||
|
if (requireAdmin(request, reply)) return;
|
||||||
|
|
||||||
|
const stats = archiveStats();
|
||||||
|
return {
|
||||||
|
channels: listChannels(),
|
||||||
|
settings: currentSettings(),
|
||||||
|
status: {
|
||||||
|
botTag: client.user?.tag ?? null,
|
||||||
|
uptimeSeconds: Math.floor(process.uptime()),
|
||||||
|
guilds: client.guilds.cache.size,
|
||||||
|
...stats,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/settings', async (request, reply) => {
|
||||||
|
if (requireAdmin(request, reply)) return;
|
||||||
|
|
||||||
|
const body = request.body ?? {};
|
||||||
|
|
||||||
|
// Kanäle: müssen existierende Textkanäle sein
|
||||||
|
for (const key of ['commit_channel_id', 'devlog_channel_id']) {
|
||||||
|
if (body[key] !== undefined) {
|
||||||
|
const ch = client.channels.cache.get(String(body[key]));
|
||||||
|
if (!ch?.isTextBased?.()) {
|
||||||
|
return reply.code(400).send({ error: `${key}: Kanal nicht gefunden` });
|
||||||
|
}
|
||||||
|
setSetting(key, String(body[key]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (body.commit_feed_enabled !== undefined) {
|
||||||
|
setSetting('commit_feed_enabled', body.commit_feed_enabled ? '1' : '0');
|
||||||
|
}
|
||||||
|
for (const key of ['commit_branch_filter', 'ignored_repos']) {
|
||||||
|
if (body[key] !== undefined) {
|
||||||
|
setSetting(key, String(body[key]).trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
request.log.info('Settings per Web-UI aktualisiert');
|
||||||
|
return { ok: true, settings: currentSettings() };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test-Embed in den konfigurierten Kanal senden (prüft die Kanal-Wahl ohne Push/Devlog)
|
||||||
|
app.post('/api/settings/test/:target', async (request, reply) => {
|
||||||
|
if (requireAdmin(request, reply)) return;
|
||||||
|
|
||||||
|
const target = request.params.target;
|
||||||
|
const channelId = target === 'commit' ? commitChannelId() : target === 'devlog' ? devlogChannelId() : null;
|
||||||
|
if (!channelId) {
|
||||||
|
return reply.code(400).send({ error: 'Kein Kanal konfiguriert' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const channel = await client.channels.fetch(channelId);
|
||||||
|
await channel.send({
|
||||||
|
embeds: [
|
||||||
|
new EmbedBuilder()
|
||||||
|
.setColor(0xf5c518)
|
||||||
|
.setTitle('🔧 Test')
|
||||||
|
.setDescription(
|
||||||
|
`Test-Nachricht für den **${target === 'commit' ? 'Commit' : 'Devlog'}-Kanal** — von der Settings-Seite ausgelöst.`
|
||||||
|
)
|
||||||
|
.setFooter({ text: 'D4RKST3R // SETUP' }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return { ok: true };
|
||||||
|
} catch (error) {
|
||||||
|
request.log.error({ err: error }, 'Test-Nachricht fehlgeschlagen');
|
||||||
|
return reply.code(502).send({ error: 'Senden fehlgeschlagen — Rechte im Kanal prüfen' });
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { join } from 'node:path';
|
|||||||
import { AttachmentBuilder, EmbedBuilder } from 'discord.js';
|
import { AttachmentBuilder, EmbedBuilder } from 'discord.js';
|
||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
import { saveDevlog } from '../db.js';
|
import { saveDevlog } from '../db.js';
|
||||||
|
import { devlogChannelId } from '../runtime-settings.js';
|
||||||
import { imagesDir } from '../bot/devlog-archive.js';
|
import { imagesDir } from '../bot/devlog-archive.js';
|
||||||
|
|
||||||
const BRAND_YELLOW = 0xf5c518;
|
const BRAND_YELLOW = 0xf5c518;
|
||||||
@@ -64,7 +65,11 @@ export function registerDevlogEndpoint(app, client) {
|
|||||||
return reply.code(400).send({ error: 'empty devlog' });
|
return reply.code(400).send({ error: 'empty devlog' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const channel = await client.channels.fetch(config.devlogChannelId);
|
const targetChannel = devlogChannelId();
|
||||||
|
if (!targetChannel) {
|
||||||
|
return reply.code(500).send({ error: 'no devlog channel configured' });
|
||||||
|
}
|
||||||
|
const channel = await client.channels.fetch(targetChannel);
|
||||||
if (!channel?.isTextBased()) {
|
if (!channel?.isTextBased()) {
|
||||||
return reply.code(500).send({ error: 'devlog channel not found' });
|
return reply.code(500).send({ error: 'devlog channel not found' });
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-1
@@ -9,6 +9,7 @@ import { fileURLToPath } from 'node:url';
|
|||||||
import { dirname, join } from 'node:path';
|
import { dirname, join } from 'node:path';
|
||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
import { saveCommits } from '../db.js';
|
import { saveCommits } from '../db.js';
|
||||||
|
import { commitFeedEnabled, branchAllowed, repoIgnored } from '../runtime-settings.js';
|
||||||
import { postPushEmbed } from '../bot/commit-feed.js';
|
import { postPushEmbed } from '../bot/commit-feed.js';
|
||||||
import { registerAuthRoutes } from './auth.js';
|
import { registerAuthRoutes } from './auth.js';
|
||||||
import { registerApiRoutes } from './api.js';
|
import { registerApiRoutes } from './api.js';
|
||||||
@@ -59,7 +60,7 @@ export async function startWebServer(client) {
|
|||||||
await app.register(fastifyMultipart, { limits: { fileSize: 10 * 1024 * 1024, files: 8 } });
|
await app.register(fastifyMultipart, { limits: { fileSize: 10 * 1024 * 1024, files: 8 } });
|
||||||
|
|
||||||
registerAuthRoutes(app);
|
registerAuthRoutes(app);
|
||||||
registerApiRoutes(app);
|
registerApiRoutes(app, client);
|
||||||
registerDevlogEndpoint(app, client);
|
registerDevlogEndpoint(app, client);
|
||||||
|
|
||||||
// Healthcheck (für Portainer/NPM)
|
// Healthcheck (für Portainer/NPM)
|
||||||
@@ -118,6 +119,11 @@ export async function startWebServer(client) {
|
|||||||
const repo = payload.repository?.full_name ?? 'unbekannt';
|
const repo = payload.repository?.full_name ?? 'unbekannt';
|
||||||
const branch = (payload.ref ?? '').replace('refs/heads/', '');
|
const branch = (payload.ref ?? '').replace('refs/heads/', '');
|
||||||
|
|
||||||
|
// Ignorierte Repos komplett überspringen (Settings-Seite)
|
||||||
|
if (repoIgnored(repo)) {
|
||||||
|
return { ok: true, ignored: `repo ${repo}` };
|
||||||
|
}
|
||||||
|
|
||||||
const rows = commits.map((c) => ({
|
const rows = commits.map((c) => ({
|
||||||
sha: c.id,
|
sha: c.id,
|
||||||
repo,
|
repo,
|
||||||
@@ -131,6 +137,12 @@ export async function startWebServer(client) {
|
|||||||
const inserted = saveCommits(rows);
|
const inserted = saveCommits(rows);
|
||||||
request.log.info(`Push auf ${repo}@${branch}: ${rows.length} Commit(s), ${inserted} neu gespeichert`);
|
request.log.info(`Push auf ${repo}@${branch}: ${rows.length} Commit(s), ${inserted} neu gespeichert`);
|
||||||
|
|
||||||
|
// Posten nur wenn Feed aktiv und Branch erlaubt (archiviert wird immer)
|
||||||
|
if (!commitFeedEnabled() || !branchAllowed(branch)) {
|
||||||
|
request.log.info(`Embed übersprungen (Feed aus oder Branch ${branch} gefiltert)`);
|
||||||
|
return { ok: true, commits: rows.length, new: inserted, posted: false };
|
||||||
|
}
|
||||||
|
|
||||||
// Embed posten — Fehler hier sollen den Webhook nicht scheitern lassen (Gitea würde sonst retrien)
|
// Embed posten — Fehler hier sollen den Webhook nicht scheitern lassen (Gitea würde sonst retrien)
|
||||||
try {
|
try {
|
||||||
await postPushEmbed(client, {
|
await postPushEmbed(client, {
|
||||||
|
|||||||
Reference in New Issue
Block a user