Compare commits

...

2 commits

Author SHA1 Message Date
root
cbe681b0c4 merge: remote STATE-Updates integriert, fuenfvoracht-Sprint behalten
Made-with: Cursor
2026-02-26 22:12:27 +07:00
root
a9ef7739be feat(fuenfvoracht): vollständiger Sprint — Zeitplanung, Logging, Briefing, Deploy
- CT 112 auf pve-hetzner: Bot + Dashboard produktiv
- Strukturiertes JSON-Logging (logger.py, /logs/fuenfvoracht.log)
- 15-Min-Zeitslots: UNIQUE(date, post_time), DB-Migration, Konflikterkennung
- Einplan-Flow: scheduled-Status, notify_at, automatische Bot-Benachrichtigung
- Board-API: Umplanen (reschedule) + Löschen per Article-ID
- Morgen-Briefing täglich 10:00 MEZ: Tagesplan + Ausblick 3 Tage
- Fehler-Alarm: detaillierte Meldung an alle Redakteure bei Posting-Fehler
- Reviewer-Verwaltung: DB-Tabelle, API add/remove, Willkommensnachricht
- Zweiter Redakteur (1329146910) parallel eingebunden
- Markenzeichen automatisch unter jeden Beitrag (Duplikat-Schutz)
- Tailwind CSS self-hosted im Docker-Image (kein CDN, schnelle Ladezeiten)
- TELEGRAM_CHANNEL_ID gesetzt (-1001285446620)
- Hilfe-Seite /hilfe: 6 Aufgabenbereiche mit Klickpfaden
- STATE.md aktualisiert und vollständig dokumentiert

Made-with: Cursor
2026-02-26 22:12:12 +07:00
24 changed files with 3843 additions and 252 deletions

View file

@ -6,9 +6,12 @@
| Aufgabe betrifft... | Lade diese Datei | | Aufgabe betrifft... | Lade diese Datei |
|-----------------------------------|------------------------------| |-----------------------------------|------------------------------|
| WordPress / RSS / Arakava News | arakava-news/STATE.md | | WordPress / RSS / Arakava News | arakava-news/STATE.md |
| Redakteur / WordPress KI-Autor | redakteur/STATE.md |
| Flugpreisscanner / Selenium | flugpreisscanner/STATE.md |
| Gold / Silber / Edelmetall-Bot | edelmetall/STATE.md | | Gold / Silber / Edelmetall-Bot | edelmetall/STATE.md |
| Smart Home / ioBroker / Grafana | smart-home/STATE.md | | Smart Home / ioBroker / Grafana | smart-home/STATE.md |
| ESP32 / Display / Heizung | esp32/PLAN.md | | ESP32 / Display / Heizung | esp32/PLAN.md |
| FünfVorAcht / Telegram KI-Poster | fuenfvoracht/STATE.md |
| Server / Container / Proxmox | infrastructure/STATE.md | | Server / Container / Proxmox | infrastructure/STATE.md |
| Telegram Bots allgemein | infrastructure/STATE.md | | Telegram Bots allgemein | infrastructure/STATE.md |
| Alle Projekte / Übersicht | MASTER_INDEX.md | | Alle Projekte / Übersicht | MASTER_INDEX.md |

View file

@ -1,122 +1,50 @@
# Homelab Master Index # Homelab Master Index
> Eine Seite — alles drauf. Für Details: gezielt die STATE.md des Projekts laden. > Einmalig lesen für Übersicht. Danach gezielt die STATE.md des betroffenen Projekts laden.
> Letztes Update: Februar 2026 > Gesamtvision: [VISION.md](VISION.md)
--- ## Projekte
## Server-Infrastruktur | Projekt | Repo / Pfad | STATE.md | Code |
|---|---|---|---|
| **Arakava News** (WordPress + RSS + KI) | Orbitalo/Wordpress-V3-MCP-Projekt | arakava-news/STATE.md | arakava-news/src/ |
| **Edelmetall Dashboard** (Gold/Silber) | — (in diesem Repo) | edelmetall/STATE.md | edelmetall/src/ |
| **Smart Home Muldenstein** (ioBroker, Grafana) | — (in diesem Repo) | smart-home/STATE.md | smart-home/scripts/ |
| **ESP32 Projekte** (Heizung, Sensor) | — (in diesem Repo) | esp32/PLAN.md | — |
| **FünfVorAcht** (Telegram KI-Poster) | — (in diesem Repo) | fuenfvoracht/STATE.md | fuenfvoracht/src/ |
| **Redakteur** (WordPress KI-Autor) | git.orbitalo.net/orbitalo/redakteur | redakteur/STATE.md | redakteur/src/ |
| **Flugpreisscanner** (FRA→PNH, Selenium, KI) | git.orbitalo.net/orbitalo/flugpreisscanner | flugpreisscanner/STATE.md | flugpreisscanner/src/ |
| **Infrastruktur** (alle Server + CTs) | — (in diesem Repo) | infrastructure/STATE.md | — |
| Server | Standort | IP / SSH | Passwort | Funktion | ## Server
|--------|----------|----------|----------|----------|
| **pve-hetzner** | Hetzner DE | `ssh root@100.88.230.59` | Astral-Proxmox!2026 | Hauptserver, alle Dienste |
| **pbs** | Hetzner DE | `ssh root@159.69.37.185` | astral66 | Traefik/Pangolin Eintrittspunkt für *.orbitalo.net |
| **pve1** | Kambodscha | `ssh root@192.168.0.197` | astral66 | Heimserver, homelab-brain Clone |
| **pve3** | Muldenstein DE | `100.109.101.12` | astral66 | Smart Home (ioBroker, Grafana) |
> **Wichtig:** DNS für `*.orbitalo.net` zeigt auf `159.69.37.185` (PBS). | Server | Standort | Tailscale IP | Funktion |
> Traefik-Config: `/opt/config/traefik/dynamic_config.yml` auf PBS. |---|---|---|---|
| pve-hetzner | Deutschland | 100.88.230.59 | Hauptserver (CT 100-110, 144, 999) |
| pve1 | Kambodscha | 192.168.0.197 (lokal) / 100.122.56.60 (TS) | Heimserver (CT 136, 888, 999-Mirror) |
| pve3 | Muldenstein, DE | 100.109.101.12 | Smart Home (CT 143, 134) |
--- ## Wichtigste Zugangsdaten
## Container-Übersicht (pve-hetzner) | System | Login |
|---|---|
| CT | Name | Funktion | Interne IP | Tailscale IP | | pve-hetzner SSH | root / Astral-Proxmox!2026 |
|-----|-------------------|-----------------------------------|----------------|-------------------| | pve1 SSH | root / astral66 |
| 100 | traefik | Traefik + Pangolin + Gerbil | 10.10.10.100 | — | | Alle lokalen CTs | root / astral66 |
| 101 | wordpress-v2 | WordPress (alt) | 10.10.10.101 | — | | WordPress Admin | admin / astral66 |
| 103 | seafile | Seafile Fileserver | 10.10.10.103 | 100.114.178.113 | | Seafile | admin@orbitalo.net / astral66 |
| 104 | n8n | n8n Workflow Automation | 10.10.10.104 | — | | n8n | wuttig@gmx.de / Astral66 |
| 106 | wordpress-news | WordPress Arakava News (v3) | 10.10.10.106 | — | | Dify | admin@orbitalo.net / astral66 |
| 109 | rss-manager | RSS Manager + Research Dashboard | 10.10.10.109 | 100.113.244.101 | | Grafana | admin / astral66 |
| 110 | portainer | Portainer Docker-Management | 10.10.10.110 | — |
| 111 | forgejo | Forgejo Git-Server | 10.10.10.111 | 100.89.246.60 |
| 144 | muldenstein-backup| Backup CT | 10.10.10.144 | — |
| 999 | cluster-docu | Cluster-Dokumentation | — | — |
---
## Öffentliche URLs
| URL | Dienst | CT |
|-----|--------|----|
| `https://arakava-news.orbitalo.net` | WordPress News (öffentlich) | 106 |
| `https://research.orbitalo.net` | Research Dashboard (privat, PW: astral66) | 109 |
| `https://git.orbitalo.net` | Forgejo Git-Server | 111 |
| `https://seafile.orbitalo.net` | Seafile | 103 |
| `https://pangolin.orbitalo.info` | Pangolin VPN Dashboard | PBS |
| `https://status.orbitalo.net` | Uptime Kuma | PBS |
| `https://traefik.orbitalo.net` | Traefik Dashboard | PBS |
---
## Projekte & Dokumentation
| Projekt | Zweck | Docs laden |
|---------|-------|------------|
| **Arakava News V3** | WordPress + RSS + KI-Recherche | `arakava-news/STATE.md` + `arakava-news/ROADMAP.md` |
| **Edelmetall Dashboard** | Gold/Silber Preis-Monitor | `edelmetall/STATE.md` |
| **Smart Home Muldenstein** | ioBroker, Grafana, Sensoren | `smart-home/STATE.md` |
| **ESP32 Projekte** | Heizung, Display, Sensoren | `esp32/PLAN.md` |
| **Infrastruktur** | Alle Server + CTs + Netzwerk | `infrastructure/STATE.md` |
### Arakava News V3 — Ausbaustufen (Kurzübersicht)
```
✅ Stufe 1: Research Dashboard live (research.orbitalo.net)
🔲 Stufe 2: Volltext-Suche & RAG (Artikel-Inhalte in DB)
🔲 Stufe 3: Aktive Recherche-Aufträge
🔲 Stufe 4: Persönlicher Assistent / "Moltbot V2"
🔲 Stufe 5: Public/Private Publishing-Workflow
```
Details: `arakava-news/ROADMAP.md`
---
## Zugangsdaten
| System | URL / Zugang | Login |
|--------|-------------|-------|
| pve-hetzner Web | `https://138.201.84.95:8006` | root / Astral-Proxmox!2026 |
| pve1 Web | `https://192.168.0.197:8006` | root / astral66 |
| WordPress Admin | `https://arakava-news.orbitalo.net/wp-admin` | admin / eJIyhW0p5PFacjvvKGufKeXS |
| Research Dashboard | `https://research.orbitalo.net` | astral66 |
| Forgejo | `https://git.orbitalo.net` | orbitalo / astral66 |
| Seafile | `https://seafile.orbitalo.net` | admin@orbitalo.net / astral66 |
| n8n | intern CT 104 | wuttig@gmx.de / Astral66 |
| Portainer | intern CT 110 | — |
| Grafana | pve3 | admin / astral66 |
| OpenRouter | `https://openrouter.ai` | (API Key in CT 109 .env) |
---
## Git-Repos
| Repo | Forgejo (primär) | GitHub (Spiegel) |
|------|-----------------|------------------|
| homelab-brain | `git.orbitalo.net/orbitalo/homelab-brain` | github.com/Orbitalo/homelab-brain |
| Wordpress-V3 | `git.orbitalo.net/orbitalo/Wordpress-V3-MCP-Projekt` | github.com/Orbitalo/Wordpress-V3-MCP-Projekt |
---
## Telegram Bots ## Telegram Bots
| Bot | Token | Chat-ID | Einsatz | | Bot | Token | Chat-ID | Projekt |
|-----|-------|---------|---------| |---|---|---|---|
| Arakava / Alerts | `8551565940:AAHIUpZND-tCNGv9yEoNPRyPt4GxEPYBJdE` | 674951792 | RSS Alerts, WP Kommentare | | @MutterbotAI_bot | (in infrastructure/STATE.md) | 674951792 | Moltbot allgemein |
| DifyRagBot | `8390483455:AAEUyRWkvESSGQBtvjzAIQ5UKqmpoMTQZ00` | 674951792 | Dify / Grafana | | @DifyRagBot | 8390483455:AAEUyRWkvESSGQBtvjzAIQ5UKqmpoMTQZ00 | 674951792 | Dify RAG / Grafana Alerts |
| Arakava Comments | 8551565940:AAHIUpZND-tCNGv9yEoNPRyPt4GxEPYBJdE | 674951792 | WordPress Kommentare |
--- | Edelmetall Bot | 8262992299:AAEf8YHPsz42ZdP85DV7JqC4822Ts75GqF4 | 674951792 | Gold/Silber Preise (CT 136) |
## Auto-Sync ## Auto-Sync
Die STATE.md Dateien werden täglich um 03:00 Uhr automatisch aktualisiert.
- `sync-state.sh` läuft alle **15 Minuten** auf pve-hetzner (`/opt/homelab-brain/scripts/`) Script: `scripts/sync-state.sh` läuft als Cron-Job auf pve-hetzner.
- Pusht zu Forgejo: `git.orbitalo.net/orbitalo/homelab-brain`
- pve1 pullt automatisch alle 15 Minuten von Forgejo
- Log: `/var/log/homelab-sync.log` auf pve-hetzner
---
## Cluster-Dokumentation (CT 999)
```bash
ssh root@192.168.0.197 # pve1 Kambodscha
pct exec 999 -- cat /root/.cursorrules
```

View file

@ -1,72 +1,130 @@
# Arakava News — Live State # STATE: Arakava News
> Auto-generiert: 2026-02-26 16:00 **Stand: 24.02.2026**
## Service Status ---
| Service | CT | Status |
|---|---|---|
| rss-manager | 109 | active |
| WordPress Docker | 101 | running |
## Letzte Feed-Aktivität (Top 5) ## Aktiver Zustand
Antispiegel: 2026-02-26 14:30:12
PAZ: 2026-02-26 14:30:09
Heise Online: 2026-02-26 14:13:12
Rubikon.news: 2026-02-26 14:13:05
Junge Freiheit: 2026-02-26 14:00:08
## Fehler (letzte 24h) ### Container-Landschaft (nach Bereinigung)
- Fehler gesamt: 0
0
- Letzter Fehler:
## OpenRouter Guthaben | CT | Dienst | Status | Tailscale |
$12.20 verbleibend |----|--------|--------|-----------|
| 101 | WordPress + MySQL (Docker) | ✅ Läuft | 100.91.212.19 |
| 109 | RSS Manager + Matomo | ✅ Läuft | 100.113.244.101 |
## URLs **Gelöscht (24.02.2026):** CT 100 (Traefik), CT 102 (Dify), CT 104 (n8n), CT 105, CT 106, CT 113 (Matomo alt)
- Blog: https://arakava-news-2.orbitalo.net
- Admin: https://arakava-news-2.orbitalo.net/wp-admin (admin / eJIyhW0p5PFacjvvKGufKeXS)
- RSS Manager: http://100.113.244.101:8080 (admin / astral66)
- Matomo: https://matomo.orbitalo.net (admin / astral66)
## Container ### URLs
| CT | Dienst | Tailscale |
|---|---|---|
| 101 | WordPress + MySQL (Docker) | 100.91.212.19 |
| 109 | RSS Manager + Matomo | 100.113.244.101 |
## Aktive Feeds (17) | Dienst | URL |
| ID | Name | Schedule | |--------|-----|
|---|---|---| | Blog | https://arakava-news-2.orbitalo.net |
| 1 | Dr. Bines Substack | 08/14/20 Uhr | | Admin | https://arakava-news-2.orbitalo.net/wp-admin |
| 3 | NachDenkSeiten | 07/13/19 Uhr | | RSS Manager | http://100.113.244.101:8080 |
| 4 | Tichys Einblick | 07:30/13:30/19:30 | | Matomo | https://matomo.orbitalo.net |
| 5 | Junge Freiheit | 08/14/20 Uhr |
| 6 | PAZ | 08:30/14:30/20:30 |
| 7 | Apollo News | 09/15/21 Uhr |
| 8 | Apolut | 09:30/15:30/21:30 |
| 9 | Achgut.com | 10/16/22 Uhr |
| 10 | Heise Security | alle 4h |
| 11 | Golem.de | alle 2h |
| 12 | Heise Online | alle 3h |
| 13 | Rubikon.news | alle 3h |
| 14 | Corona-Transition | alle 4h |
| 15 | Photon.info (KI-Analyse) | alle 6h |
| 16 | Antispiegel | 08:30/14:30/20:30 |
| 17 | Riehle News | 09:00 Uhr |
## Code (CT 109: /opt/rss-manager/) ---
poster.py, scheduler.py, app.py, db.py
## WP-Cron Konfiguration ## CT 101 — WordPress
- DISABLE_WP_CRON = true in wp-config.php (Race Conditions behoben)
- System-Cron: */5 * * * * curl -sk https://arakava-news-2.orbitalo.net/wp-cron.php?doing_wp_cron
- WordPress + Blocksy auf Deutsch (de_DE)
## Änderungshistorie **Stack:** Docker (wordpress-app + wordpress-mysql)
- 24.02.2026: Scheduler Lock gegen Doppelstarts **Compose:** /opt/wordpress/docker-compose.yml
- 24.02.2026: Telegram auf HTML-Modus (Sonderzeichen-Fix)
- 24.02.2026: Werbeartikel-Blacklist (Anzeige:, Sponsored, etc.) ### Plugins
- 24.02.2026: DISABLE_WP_CRON + System-Cron (Race Condition Fix)
- 24.02.2026: WordPress auf Deutsch (de_DE), Blocksy Theme DE | Plugin | Pfad | Funktion |
- 23.02.2026: Matomo von CT 113 → CT 109 migriert |--------|------|---------|
- 23.02.2026: CT 100/102/104/105/106/113 gelöscht | arakava-counter | /wp-content/plugins/arakava-counter/ | Besucherzähler, Bot-gefiltert |
| blocksy | /wp-content/plugins/blocksy-companion/ | Theme |
| matomo-tracking.php | /wp-content/mu-plugins/ | Async Matomo JS |
| breaking-news-ticker.php | /wp-content/mu-plugins/ | News Ticker Shortcode |
### Bot-Filter (arakava-counter.php)
- User-Agent < 10 Zeichen ignoriert
- Regex für ~20 Bot-Typen (bot, crawl, spider, curl, python, selenium, etc.)
- IP-Ausschluss: `103.101.*` (eigene dynamische IP)
- Kein Tracking für eingeloggte WordPress-Benutzer
### Matomo-Tracking
- **Eingeloggte User:** KEIN Tracking (via `is_user_logged_in()` Check)
- **Konfiguration:** Matomo auf CT 109:80, Site-ID 1
### Design-Anpassungen
- Header: Dunkel (`#0f172a`) via Blocksy Custom CSS
- Homepage: Breaking News Ticker ([breaking_news] Shortcode)
- Neue Kategorie "Eigenversorgung & Optimierung" mit mehreren Unterkategorien
---
## CT 109 — RSS Manager
**Stack:** Python/Flask + APScheduler
**Pfad:** /opt/rss-manager/
### Scheduler-Features (Stand 24.02.2026)
- **Startup-Recovery:** Übersprungene Feeds werden beim Start sofort nachgeholt
- **Feed-Lock:** `threading.Lock` pro Feed verhindert Doppelstarts
- Alle Locks über `_feed_locks_mutex` (Thread-sicher)
### Telegram-Integration
- **Bot:** Mutter (@MutterbotAI_bot)
- **Parse-Mode:** HTML (nicht Markdown — Sonderzeichen-Bug behoben 24.02.2026)
- Logging für erfolgreiche Sends und API-Fehler
### Keyword-Filter (Blacklist)
Aktiv für alle Feeds:
- `Anzeige:`
- `Sponsored`
- `Werbung`
- `PR:`
### Matomo (auf CT 109)
- Migriert von CT 113 → CT 109 (23.02.2026)
- Apache2 + PHP-FPM + MariaDB
- Cloudflare Tunnel: matomo.orbitalo.net
- Admin: admin / astral66
- Eigene Besuche ausgeschlossen (Matomo-Einstellung)
---
## Routing
**Cloudflare Tunnel auf CT 101:**
- arakava-news-2.orbitalo.net → localhost:80
**Cloudflare Tunnel auf CT 109:**
- matomo.orbitalo.net → localhost:80
**Kein Traefik, kein PBS-Gateway mehr.**
---
## CT 101 — WP-Cron Konfiguration
- `DISABLE_WP_CRON = true` in wp-config.php (Race Conditions bei hohem Traffic behoben)
- Echter System-Cron in CT 101: `*/5 * * * * curl -sk https://arakava-news-2.orbitalo.net/wp-cron.php?doing_wp_cron`
- WordPress auf Deutsch (de_DE) umgestellt, inkl. Blocksy Theme
## Gesundheitswerte (24.02.2026)
| Metrik | Wert |
|--------|------|
| Response Zeit | 0,18s |
| RAM WordPress | 257 MB / 2 GB |
| RAM MySQL | 443 MB / 2 GB |
| Disk | 3,8 GB / 20 GB (21%) |
---
## Bekannte Offene Punkte
- Keine bekannten kritischen Probleme
- Scheduler läuft stabil seit 24.02.2026 05:00 Uhr
- WP-Cron Race Condition behoben (24.02.2026)

View file

@ -1,68 +1,109 @@
# Edelmetall Dashboard — Live State # Edelmetall Dashboard — Live State
> Auto-generiert täglich 03:00. Manueller Abschnitt am Ende. > Zuletzt aktualisiert: 24.02.2026
## Services ## Services
| Service | Container | URL | Status | | Service | Container | URL | Status |
|---|---|---|---| |---|---|---|---|
| Dashboard Kambodscha | CT 135, pve1 | http://192.168.0.219:8501 | auto-aktualisiert | | **Dashboard V3** | CT 136, pve1 (Kambodscha) | https://blei.orbitalo.info | ✅ Aktiv |
| Dashboard Deutschland | CT 134, pve3 | https://blei.orbitalo.info | auto-aktualisiert | | Dashboard V3 Tailscale | CT 136, pve1 | http://100.72.230.87:8501 | ✅ Aktiv |
| **Telegram Bot** | CT 136, pve1 | — | ✅ Aktiv |
| CT 135 (V2) | pve1 | — | ⛔ Gestoppt 2026-02-23 |
## Zugang ## Zugang
```bash ```bash
# Dashboard Kambodscha starten/stoppen ssh root@100.122.56.60 # pve1 Kambodscha (Tailscale)
pct exec 135 -- pkill -f streamlit pct exec 136 -- bash
pct exec 135 -- bash -c "cd /root/edelmetall && source venv/bin/activate && nohup streamlit run dashboard/app.py --server.port 8501 --server.address 0.0.0.0 &"
# Dashboard Deutschland (pve3) systemctl status edelmetall-dashboard # Streamlit
ssh root@100.122.163.2 "pct exec 134 -- ..." systemctl status edelmetall-telegram # Telegram Bot
systemctl status cloudflared # Tunnel
# Logs
pct exec 136 -- tail -f /opt/edelmetall/logs/scraper.log
pct exec 136 -- journalctl -u edelmetall-telegram -f
# Scraper manuell
pct exec 136 -- bash -c "source /opt/edelmetall/venv/bin/activate && python3 /opt/edelmetall/scrape.py"
``` ```
## Code-Struktur (CT 135: /root/edelmetall/) ## Code-Struktur (CT 136: /opt/edelmetall/)
``` ```
dashboard/app.py — Haupt-Dashboard (Streamlit) core/
dashboard/validation.py — Preis-Validierung db.py DB-Verbindung + Schema
bot.py — Telegram Bot prices.py Spot + Händler, COALESCE(buy_price, sell_price)
spot_api.py — Spot-Preis API portfolio.py Portfolio-Berechnung (Krügerrand-Ankaufspreis)
venv/ — Python venv scrapers/
proaurum.py Selenium — CSS Modules [class*='product-root'] (gefixt 2026-02-24)
degussa.py requests Fallback
dashboard/
app.py, tab_*.py Streamlit Dashboard
bot.py Telegram Bot V3
scrape.py Haupt-Scraper (PA → Degussa Fallback bei 0 Ergebnissen)
fetch_spot.py Spot-Preis alle 30min
data/edelmetall.db SQLite
``` ```
## Dashboard-Tabs ## Cron-Jobs (CT 136)
| Tab | Funktion | ```
*/30 * * * * python3 /opt/edelmetall/fetch_spot.py
0 7 * * * python3 /opt/edelmetall/scrape.py
0 13 * * * python3 /opt/edelmetall/scrape.py
0 21 * * * python3 /opt/edelmetall/scrape.py
```
## Telegram Bot
| Info | Wert |
|---|---| |---|---|
| 📈 Preise | Gold/Silber Live-Preise | | Token | 8262992299:AAEf8YHPsz42ZdP85DV7JqC4822Ts75GqF4 |
| ⚖️ Ratio | Gold/Silber Ratio + Regime-Analyse | | Service | edelmetall-telegram.service |
| 📊 Strategie | **Allocation Signal Indikator** | | Befehle | /start /portfolio /preise /ratio /status |
| 📊 Spreads | Händler-Spreads |
| 💎 Portfolio | Persönliches Portfolio |
| 🌍 Makro Liquidität | Makro-Analyse |
| 🔧 Scraper | Scraper Status |
## Allocation Signal Logik ## Portfolio-Konfiguration
| Signal | Bedingung | Bedeutung | | Parameter | Wert |
|---|---|
| Gold | 33 oz Krügerrand |
| Silber | 500 oz Silbermünzen |
| Startdatum | 2025-12-28 |
| Einkauf Gold | 3.774 EUR/oz |
| Einkauf Silber | 66,80 EUR/oz |
## Datenbank (Stand 24.02.2026)
| Tabelle | Einträge | Zeitraum |
|---|---|---| |---|---|---|
| 🔴 GOLD | ratio > MA(120) AND slope > 0 | Gold bevorzugen | | spot_prices | 2.031 | 2025-12-18 bis heute |
| 🟢 REBALANCING | percentile >= 90 OR ratio > 85 | Silber relativ günstig | | gold_prices | 16.776 | 2025-12-18 bis heute |
| 🟡 NEUTRAL | sonst | Halten | | silver_prices | 14.033 | 2025-12-18 bis heute |
Hysterese: Signal wechselt erst nach 7 Tagen stabiler Bedingung. ## Scraper-Logik
## Portfolio (Kambodscha Investment) 1. **Pro Aurum** (Selenium) — primär
- Selektor: `[class*='product-root']` (CSS Modules, Stand 2026-02-24)
- Preise: `[class*='buySellSection-price']` — Index 0 = Kaufpreis, Index 1 = Ankaufspreis
- Fallback bei 0 Ergebnissen → Degussa (nicht nur bei Exception!)
2. **Degussa** (requests) — Fallback
3. Portfolio nutzt **Ankaufspreis** (buy_price) für Bewertung
4. COALESCE(buy_price, sell_price) in SQL falls Ankaufspreis fehlt
| Wohnung | Stock | Kaufpreis | Mieter | Miete | ## Fixes 24.02.2026
|---|---|---|---|---|
| D1603 | 16 | 29.745 EUR | Antonio Ramirez | 250 USD/M |
| G2010B | 20 | 34.000 USD | Cheng Qiu | 250 USD/M |
Netto-Einnahmen: 467 USD/Monat (nach Management Fee) | Problem | Ursache | Fix |
|---|---|---|
| Scraper: 0 Produkte | Pro Aurum CSS Modules → alter Selektor | `[class*='product-root']` |
| Fallback nie aktiv | Exception nur bei Fehler, nicht bei leer | `raise ValueError` bei 0 Ergebnissen |
| Bot: "Keine Preise" | SQLite `'localtime'` → +7h Versatz | `'localtime'` entfernt |
| Portfolio zu hoch | buy/sell Preise vertauscht (10% Fehler) | `buySellSection-price` Index-Reihenfolge |
| buy_price NULL | Neuer Selektor fand Ankauf nicht | `COALESCE(buy_price, sell_price)` |
## Offene Aufgaben ## Konnektivität CT 136
- [ ] Beide Dashboards in CT 112 v3 migrieren
- [ ] Bot-Alerts für Allocation Signal
## Notizen (manuell) | Typ | Wert |
<!-- Hier können manuelle Ergänzungen eingetragen werden --> |---|---|
| Cloudflare Tunnel | c94b28f3-f473-475a-87da-b87c1806ecd8 |
| Tailscale | 100.72.230.87 |

9
fuenfvoracht/.env Normal file
View file

@ -0,0 +1,9 @@
TELEGRAM_BOT_TOKEN=8799990587:AAEoQuohGdoJ2WudoOHs_j5Ns3iwft6OlFc
TELEGRAM_CHANNEL_ID=-1001285446620
ADMIN_CHAT_ID=674951792
REVIEW_CHAT_IDS=674951792,1329146910
OPENROUTER_API_KEY=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
AI_MODEL=openai/gpt-4o-mini
POST_TIME=19:55
TIMEZONE=Europe/Berlin
DB_PATH=/data/fuenfvoracht.db

4
fuenfvoracht/.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
__pycache__/
*.pyc
*.pyo
.env

189
fuenfvoracht/STATE.md Normal file
View file

@ -0,0 +1,189 @@
# STATE: FünfVorAcht
**Stand: 26.02.2026**
---
## Projektübersicht
**Zweck:** KI-gestützter Telegram-Kanal-Poster für die tägliche Reihe "Fünf vor Acht"
**Posting-Zeit:** 19:55 Uhr (Europe/Berlin) — pro Artikel individuell konfigurierbar
**Kanal:** Privater Kanal (`-1001285446620`)
**Status:** ✅ Produktiv seit 24.02.2026
---
## Aktiver Zustand
### Container-Landschaft
| CT | Dienst | Status | Tailscale |
|----|--------|--------|-----------|
| 112 | FünfVorAcht (Bot + Dashboard) | ✅ Läuft | 100.73.171.62 |
---
## Architektur
### Stack (CT 112 auf pve-hetzner)
- **Sprache:** Python 3.11
- **Bot-Framework:** python-telegram-bot
- **KI:** OpenRouter API (GPT-4o-mini, konfigurierbar)
- **Scheduler:** APScheduler (jede Minute für Posting, alle 5 Min für Notify-Check)
- **DB:** SQLite mit WAL-Modus
- **Dashboard:** Flask + Tailwind CSS (self-hosted, kein CDN)
- **Deployment:** Docker Compose (2 Container: bot + web)
- **Logging:** JSON-Lines nach `/logs/fuenfvoracht.log`
### Workflow
```
Redakteur gibt Quelle/Thema ein
KI generiert Artikel (OpenRouter)
Redigieren im Dashboard + Telegram-Vorschau
Einplanen: Datum + 15-Min-Zeitslot + Bot-Benachrichtigungszeit
Scheduler schickt Review an alle Redakteure (notify_at)
[✅ Freigeben] [✏️ Bearbeiten]
APScheduler postet automatisch zum Zeitslot
Bestätigung + Markenzeichen automatisch angehängt
```
---
## Pfade & Konfiguration
| Was | Pfad |
|-----|------|
| App-Verzeichnis | /opt/fuenfvoracht/ |
| Docker Compose | /opt/fuenfvoracht/docker-compose.yml |
| Datenbank | /opt/fuenfvoracht/data/fuenfvoracht.db |
| Logs (JSON) | /opt/fuenfvoracht/logs/fuenfvoracht.log |
| Prompts | In SQLite DB (Tabelle: prompts) |
| Tailwind CSS | Im Docker-Image gebaut (/app/static/tailwind.min.css) |
---
## Datenbank-Schema
### Tabellen
| Tabelle | Zweck |
|---------|-------|
| articles | Artikel-Queue mit `date + post_time` als Unique-Key |
| article_versions | Alle Versionen bei Neu-Generierung |
| post_history | Posting-Log mit Channel-Message-IDs |
| prompts | KI-Prompt-Bibliothek (editierbar) |
| sources_favorites | Gespeicherte Quellen-Favoriten |
| tags | Themen-Kategorien |
| article_tags | Artikel ↔ Tags (n:m) |
| channels | Kanal-Konfiguration (Zeit, Timezone) |
| locations | Aufenthaltsorte mit Reminder-Zeiten |
| settings | Schlüssel-Wert-Paare (z.B. user_location_id) |
| reviewers | Redakteure (chat_id, name, active) |
### Article-Status-Lifecycle
```
draft → scheduled → sent_to_bot → approved → posted
↘ rejected
↘ skipped
↘ pending_review
```
### Zeitslot-System
- `UNIQUE(date, post_time)` — Konflikte technisch ausgeschlossen
- `post_time` im 15-Minuten-Raster (06:00, 06:15, … 23:45)
- `notify_at` — UTC-Timestamp wann der Review-Bot benachrichtigt
- `scheduled_at` — wann der Artikel eingeplant wurde
---
## Telegram-Setup
| Was | Wert |
|-----|------|
| Review-Bot Token | 8799990587:AAEoQuohGdoJ2WudoOHs_j5Ns3iwft6OlFc |
| Review-Bot Name | @Diendemleben_bot |
| Kanal-ID | -1001285446620 |
| Redakteur 1 | Chat-ID 674951792 |
| Redakteur 2 | Chat-ID 1329146910 |
---
## Dashboard-Features
- **Studio:** Artikel generieren, redigieren, Telegram-Vorschau in Echtzeit
- **Einplan-Panel:** Datum + 15-Min-Zeitslot + Bot-Benachrichtigungszeit
- **Redaktionsplan:** Nächste 7 Tage, mehrere Slots pro Tag, Umplanen + Löschen direkt im Board
- **Monatskalender:** Status-Dots pro Tag
- **Prompt-Editor:** Bearbeiten + Test mit Telegram-Preview
- **History:** Alle Posts der letzten 30 Tage
- **Quellen-Favoriten:** Häufig genutzte Quellen
- **Redakteure-Verwaltung:** Hinzufügen/Entfernen per Chat-ID
- **Aufenthaltsort-Schalter:** Reminder-Zeiten automatisch auf MEZ umgerechnet
- **Anleitung (/hilfe):** 6 Aufgabenbereiche mit Klickpfaden
---
## Bot-Features
| Feature | Details |
|---------|---------|
| `/start` | Übersicht & Befehle (alle Redakteure) |
| `/heute` | Alle Slots des heutigen Tages |
| `/queue` | Nächste 3 Tage mit Slots |
| `/skip` | Hauptslot heute überspringen |
| Inline-Review | ✅ Freigeben / ✏️ Bearbeiten |
| Morgen-Briefing | 10:00 MEZ: Tagesplan + Ausblick 3 Tage |
| Nachmittags-Reminder | 18:00 MEZ: Warnung wenn nicht freigegeben |
| Fehler-Alarm | Sofort bei Posting-Fehler: Ursache + Dashboard-Link |
| Willkommensnachricht | Automatisch bei neuem Redakteur |
---
## Markenzeichen (automatisch)
Wird unter **jeden** Beitrag angehängt (Duplikat-Schutz aktiv):
```
Wir schützen die Zukunft unserer Kinder und das Leben❤
Pax et Lux Terranaut01 https://t.me/DieneDemLeben
Unterstützt die Menschen, die für Uns einstehen❗
```
---
## Logging
Strukturiertes JSON-Logging nach `/logs/fuenfvoracht.log`:
```json
{"ts": "2026-02-26T14:46:19Z", "level": "INFO", "event": "article_posted", "date": "2026-02-26", "post_time": "19:55", ...}
```
Events: `article_generated`, `article_saved`, `article_scheduled`, `article_sent_to_bot`, `article_approved`, `article_posted`, `article_skipped`, `posting_failed`, `reviewer_added`, `reviewer_removed`, `slot_conflict`, `bot_started`, `morning_briefing_sent`
---
## Routing
- Dashboard: `https://fuenfvoracht.orbitalo.net` (Cloudflare Tunnel)
- Lokal: `http://100.73.171.62:8080`
- Login: Holgerhh / ddlhh
- Kein öffentlicher Zugriff außer via Cloudflare Tunnel
---
## Offene Punkte / TODOs
- [ ] 15-Min-Einplan-Panel in Dashboard-UI integrieren (API vorhanden)
- [ ] Board: Umplanen/Löschen Buttons in index.html
- [ ] Redakteure-Verwaltung in settings.html
- [ ] Kanal-ID in Settings-UI editierbar
- [ ] Media-Einbettung im Editor (Video/Link Drag & Drop)
- [ ] Letzter-Post Zeitstempel im Dashboard anzeigen

View file

@ -0,0 +1,36 @@
services:
bot:
build:
context: ./src
dockerfile: Dockerfile.bot
container_name: fuenfvoracht-bot
restart: unless-stopped
env_file: .env
environment:
- DB_PATH=/data/fuenfvoracht.db
volumes:
- ./data:/data
- ./logs:/logs
networks:
- fuenfvoracht
web:
build:
context: ./src
dockerfile: Dockerfile.web
container_name: fuenfvoracht-web
restart: unless-stopped
env_file: .env
environment:
- DB_PATH=/data/fuenfvoracht.db
volumes:
- ./data:/data
- ./logs:/logs
ports:
- "8080:8080"
networks:
- fuenfvoracht
networks:
fuenfvoracht:
driver: bridge

View file

@ -0,0 +1,6 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements-bot.txt .
RUN pip install --no-cache-dir -r requirements-bot.txt
COPY database.py openrouter.py bot.py logger.py ./
CMD ["python", "bot.py"]

View file

@ -0,0 +1,11 @@
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
COPY requirements-web.txt .
RUN pip install --no-cache-dir -r requirements-web.txt
COPY database.py openrouter.py app.py logger.py ./
COPY templates/ templates/
RUN mkdir -p /app/static && \
curl -sL https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css \
-o /app/static/tailwind.min.css
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "4", "--timeout", "120", "app:app"]

531
fuenfvoracht/src/app.py Normal file
View file

@ -0,0 +1,531 @@
from flask import Flask, render_template, request, jsonify, redirect, url_for, Response
from functools import wraps
from datetime import datetime, date, timedelta
import os
import asyncio
import logging
import requests as req_lib
import database as db
import openrouter
import logger as flog
app = Flask(__name__)
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
TZ_NAME = os.environ.get('TIMEZONE', 'Europe/Berlin')
BOT_TOKEN = os.environ.get('TELEGRAM_BOT_TOKEN', '')
POST_TIME = os.environ.get('POST_TIME', '19:55')
AUTH_USER = os.environ.get('AUTH_USER', 'Holgerhh')
AUTH_PASS = os.environ.get('AUTH_PASS', 'ddlhh')
BRAND_MARKER = "Pax et Lux Terranaut01 https://t.me/DieneDemLeben"
BRAND_SIGNATURE = (
"Wir schützen die Zukunft unserer Kinder und das Leben❤\n\n"
"Pax et Lux Terranaut01 https://t.me/DieneDemLeben\n\n"
"Unterstützt die Menschen, die für Uns einstehen❗"
)
def check_auth(username, password):
return username == AUTH_USER and password == AUTH_PASS
def authenticate():
return Response(
'Zugang verweigert.', 401,
{'WWW-Authenticate': 'Basic realm="FünfVorAcht"'})
@app.before_request
def before_request_auth():
auth = request.authorization
if not auth or not check_auth(auth.username, auth.password):
return authenticate()
def today_str():
import pytz
return datetime.now(pytz.timezone(TZ_NAME)).strftime('%Y-%m-%d')
def today_display():
import pytz
return datetime.now(pytz.timezone(TZ_NAME)).strftime('%d. %B %Y')
def week_range():
today = date.today()
start = today - timedelta(days=today.weekday())
return [(start + timedelta(days=i)).strftime('%Y-%m-%d') for i in range(7)]
def planning_days(count=7):
import pytz
tz = pytz.timezone(TZ_NAME)
t = datetime.now(tz).date()
return [(t + timedelta(days=i)).strftime('%Y-%m-%d') for i in range(count)]
def with_branding(content: str) -> str:
text = (content or "").rstrip()
if BRAND_MARKER in text:
return text
return f"{text}\n\n{BRAND_SIGNATURE}" if text else BRAND_SIGNATURE
def send_telegram_message(chat_id, text, reply_markup=None):
url = f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage"
payload = {"chat_id": chat_id, "text": text, "parse_mode": "HTML"}
if reply_markup:
import json
payload["reply_markup"] = json.dumps(reply_markup)
try:
r = req_lib.post(url, json=payload, timeout=10)
return r.json()
except Exception as e:
logger.error("Telegram send fehlgeschlagen: %s", e)
return None
def notify_all_reviewers(text, reply_markup=None):
results = []
for chat_id in db.get_reviewer_chat_ids():
result = send_telegram_message(chat_id, text, reply_markup)
results.append({'chat_id': chat_id, 'ok': bool(result and result.get('ok'))})
return results
# ── Main Dashboard ────────────────────────────────────────────────────────────
@app.route('/')
def index():
today = today_str()
articles_today = db.get_articles_by_date(today)
article_today = articles_today[0] if articles_today else None
week_days = week_range()
week_articles_raw = db.get_week_articles(week_days[0], week_days[-1])
# Mehrere Artikel pro Tag: dict date → list
week_articles = {}
for a in week_articles_raw:
week_articles.setdefault(a['date'], []).append(a)
recent = db.get_recent_articles(10)
stats = db.get_monthly_stats()
channel = db.get_channel()
prompts = db.get_prompts()
tags = db.get_tags()
favorites = db.get_favorites()
locations = db.get_locations()
current_location = db.get_current_location()
reviewers = db.get_reviewers()
last_posted = db.get_last_posted()
plan_days = planning_days(7)
plan_raw = db.get_week_articles(plan_days[0], plan_days[-1])
plan_articles = {}
for a in plan_raw:
plan_articles.setdefault(a['date'], []).append(a)
month_start = date.today().replace(day=1).strftime('%Y-%m-%d')
month_end = (date.today().replace(day=28) + timedelta(days=4)).replace(day=1) - timedelta(days=1)
month_articles = {}
for a in db.get_week_articles(month_start, month_end.strftime('%Y-%m-%d')):
month_articles.setdefault(a['date'], []).append(a['status'])
return render_template('index.html',
today=today,
article_today=article_today,
articles_today=articles_today,
week_days=week_days,
week_articles=week_articles,
plan_days=plan_days,
plan_articles=plan_articles,
month_articles=month_articles,
recent=recent,
stats=stats,
channel=channel,
post_time=POST_TIME,
prompts=prompts,
tags=tags,
favorites=favorites,
locations=locations,
current_location=current_location,
reviewers=reviewers,
last_posted=last_posted)
# ── History ───────────────────────────────────────────────────────────────────
@app.route('/history')
def history():
articles = db.get_recent_articles(30)
return render_template('history.html', articles=articles)
# ── Prompts ───────────────────────────────────────────────────────────────────
@app.route('/prompts')
def prompts():
all_prompts = db.get_prompts()
return render_template('prompts.html', prompts=all_prompts)
@app.route('/prompts/save', methods=['POST'])
def save_prompt():
pid = request.form.get('id')
name = request.form.get('name', '').strip()
system_prompt = request.form.get('system_prompt', '').strip()
if pid:
db.save_prompt(int(pid), name, system_prompt)
else:
db.create_prompt(name, system_prompt)
return redirect(url_for('prompts'))
@app.route('/prompts/default/<int:pid>', methods=['POST'])
def set_default_prompt(pid):
db.set_default_prompt(pid)
return redirect(url_for('prompts'))
@app.route('/prompts/delete/<int:pid>', methods=['POST'])
def delete_prompt(pid):
db.delete_prompt(pid)
return redirect(url_for('prompts'))
@app.route('/prompts/test', methods=['POST'])
def test_prompt():
data = request.get_json()
system_prompt = data.get('system_prompt', '')
source = data.get('source', 'https://tagesschau.de')
tag = data.get('tag', 'Politik')
prompt_id = data.get('prompt_id')
import pytz
date_display = datetime.now(pytz.timezone(TZ_NAME)).strftime('%d. %B %Y')
try:
result = asyncio.run(openrouter.generate_article(source, system_prompt, date_display, tag))
if prompt_id:
db.save_prompt_test_result(int(prompt_id), result)
return jsonify({'success': True, 'result': result})
except Exception as e:
return jsonify({'success': False, 'error': str(e)})
# ── Hilfe ─────────────────────────────────────────────────────────────────────
@app.route('/hilfe')
def hilfe():
return render_template('hilfe.html')
# ── Settings ──────────────────────────────────────────────────────────────────
@app.route('/settings')
def settings():
channel = db.get_channel()
favorites = db.get_favorites()
tags = db.get_tags()
reviewers = db.get_reviewers(active_only=False)
return render_template('settings.html', channel=channel,
favorites=favorites, tags=tags,
reviewers=reviewers)
@app.route('/settings/channel', methods=['POST'])
def save_channel():
telegram_id = request.form.get('telegram_id', '').strip()
post_time = request.form.get('post_time', '19:55').strip()
db.update_channel(telegram_id, post_time)
return redirect(url_for('settings'))
@app.route('/settings/favorite/add', methods=['POST'])
def add_favorite():
label = request.form.get('label', '').strip()
url = request.form.get('url', '').strip()
if label and url:
db.add_favorite(label, url)
return redirect(url_for('settings'))
# ── Reviewer API ──────────────────────────────────────────────────────────────
@app.route('/api/reviewers', methods=['GET'])
def api_reviewers():
return jsonify(db.get_reviewers(active_only=False))
@app.route('/api/reviewers/add', methods=['POST'])
def api_add_reviewer():
data = request.get_json()
chat_id = data.get('chat_id')
name = data.get('name', '').strip()
if not chat_id or not name:
return jsonify({'success': False, 'error': 'chat_id und name erforderlich'})
try:
chat_id = int(chat_id)
except ValueError:
return jsonify({'success': False, 'error': 'Ungültige Chat-ID'})
added = db.add_reviewer(chat_id, name)
if not added:
return jsonify({'success': False, 'error': 'Chat-ID bereits vorhanden'})
# Willkommensnachricht
welcome = (
f"👋 <b>Willkommen bei FünfVorAcht!</b>\n\n"
f"Du wurdest als Redakteur hinzugefügt.\n"
f"Ab jetzt erhältst du Reviews, Reminder und Status-Meldungen.\n\n"
f"/start für eine Übersicht aller Befehle."
)
send_telegram_message(chat_id, welcome)
return jsonify({'success': True})
@app.route('/api/reviewers/remove', methods=['POST'])
def api_remove_reviewer():
data = request.get_json()
chat_id = data.get('chat_id')
if not chat_id:
return jsonify({'success': False, 'error': 'chat_id erforderlich'})
db.remove_reviewer(int(chat_id))
return jsonify({'success': True})
# ── API Endpoints ─────────────────────────────────────────────────────────────
@app.route('/api/generate', methods=['POST'])
def api_generate():
data = request.get_json()
source = data.get('source', '').strip()
tag = data.get('tag', 'allgemein')
prompt_id = data.get('prompt_id')
date_str = data.get('date', today_str())
post_time = data.get('post_time', POST_TIME)
if not source:
return jsonify({'success': False, 'error': 'Keine Quelle angegeben'})
prompt = None
if prompt_id:
all_prompts = db.get_prompts()
prompt = next((p for p in all_prompts if str(p['id']) == str(prompt_id)), None)
if not prompt:
prompt = db.get_default_prompt()
if not prompt:
return jsonify({'success': False, 'error': 'Kein Prompt konfiguriert'})
try:
content = asyncio.run(
openrouter.generate_article(source, prompt['system_prompt'], today_display(), tag)
)
existing = db.get_article_by_date(date_str, post_time)
if existing:
db.update_article_content(date_str, content, new_version=True, post_time=post_time)
conn = db.get_conn()
conn.execute(
"UPDATE articles SET source_input=?, tag=?, status='draft' WHERE date=? AND post_time=?",
(source, tag, date_str, post_time)
)
conn.commit()
conn.close()
else:
db.create_article(date_str, source, content, prompt['id'], tag, post_time)
flog.article_generated(date_str, source, 1, tag)
return jsonify({'success': True, 'content': content})
except Exception as e:
return jsonify({'success': False, 'error': str(e)})
@app.route('/api/article/<date_str>/save', methods=['POST'])
def api_save(date_str):
data = request.get_json()
content = data.get('content', '').strip()
post_time = data.get('post_time', POST_TIME)
if not content:
return jsonify({'success': False, 'error': 'Kein Inhalt'})
existing = db.get_article_by_date(date_str, post_time)
if existing:
db.update_article_content(date_str, content, post_time=post_time)
flog.article_saved(date_str, post_time)
return jsonify({'success': True})
@app.route('/api/article/<date_str>/schedule', methods=['POST'])
def api_schedule(date_str):
"""Artikel einplanen: post_time + notify_at setzen."""
data = request.get_json()
post_time = data.get('post_time', POST_TIME)
notify_mode = data.get('notify_mode', 'auto') # sofort | auto | custom
notify_at_custom = data.get('notify_at')
# Slot-Konflikt prüfen
existing = db.get_article_by_date(date_str, post_time)
if not existing:
return jsonify({'success': False, 'error': 'Kein Artikel für diesen Slot'})
# notify_at berechnen
import pytz
from datetime import datetime as dt
berlin = pytz.timezone('Europe/Berlin')
now_berlin = dt.now(berlin)
article_dt = berlin.localize(dt.strptime(f"{date_str} {post_time}", '%Y-%m-%d %H:%M'))
if notify_mode == 'sofort':
notify_at = dt.utcnow().isoformat()
elif notify_mode == 'custom' and notify_at_custom:
notify_at = notify_at_custom
else:
# Auto: wenn heute → sofort, sonst Vortag 17:00
if article_dt.date() == now_berlin.date():
notify_at = dt.utcnow().isoformat()
else:
day_before = (article_dt - timedelta(days=1)).replace(hour=17, minute=0, second=0)
notify_at = day_before.astimezone(pytz.utc).strftime('%Y-%m-%dT%H:%M:%S')
db.schedule_article(date_str, post_time, notify_at)
flog.article_scheduled(date_str, post_time, notify_at)
return jsonify({'success': True, 'notify_at': notify_at})
@app.route('/api/article/<int:article_id>/reschedule', methods=['POST'])
def api_reschedule(article_id):
data = request.get_json()
new_date = data.get('date')
new_time = data.get('post_time')
if not new_date or not new_time:
return jsonify({'success': False, 'error': 'date und post_time erforderlich'})
# Slot-Konflikt prüfen
if db.slot_is_taken(new_date, new_time, exclude_id=article_id):
taken = db.get_taken_slots(new_date)
flog.slot_conflict(new_date, new_time)
return jsonify({
'success': False,
'error': f'Slot {new_date} {new_time} ist bereits belegt.',
'taken_slots': taken
})
ok = db.reschedule_article(article_id, new_date, new_time)
if ok:
return jsonify({'success': True})
return jsonify({'success': False, 'error': 'Fehler beim Umplanen'})
@app.route('/api/article/<int:article_id>/delete', methods=['POST'])
def api_delete(article_id):
db.delete_article(article_id)
return jsonify({'success': True})
@app.route('/api/slots/<date_str>')
def api_slots(date_str):
"""Gibt belegte Slots für einen Tag zurück."""
taken = db.get_taken_slots(date_str)
return jsonify({'date': date_str, 'taken': taken})
@app.route('/api/article/<date_str>/send-to-bot', methods=['POST'])
def api_send_to_bot(date_str):
data = request.get_json() or {}
post_time = data.get('post_time', POST_TIME)
article = db.get_article_by_date(date_str, post_time)
if not article or not article.get('content_final'):
return jsonify({'success': False, 'error': 'Kein Artikel vorhanden'})
channel = db.get_channel()
pt = channel.get('post_time', post_time)
branded = with_branding(article['content_final'])
text = (
f"📋 <b>Review: {date_str} · {post_time} Uhr</b>\n"
f"Version {article['version']}\n"
f"──────────────────────\n\n"
f"{branded}\n\n"
f"──────────────────────\n"
f"Freigeben oder bearbeiten?"
)
keyboard = {
"inline_keyboard": [[
{"text": "✅ Freigeben", "callback_data": f"approve:{date_str}:{post_time}"},
{"text": "✏️ Bearbeiten", "callback_data": f"edit:{date_str}:{post_time}"}
]]
}
results = notify_all_reviewers(text, keyboard)
ok_count = sum(1 for r in results if r['ok'])
if ok_count > 0:
db.update_article_status(date_str, 'sent_to_bot', post_time=post_time)
flog.article_sent_to_bot(date_str, post_time, [r['chat_id'] for r in results if r['ok']])
return jsonify({'success': True})
return jsonify({'success': False, 'error': 'Kein Reviewer erreichbar'})
@app.route('/api/settings/post-time', methods=['POST'])
def api_save_post_time():
data = request.get_json()
post_time = data.get('post_time', '19:55')
channel = db.get_channel()
db.update_channel(channel.get('telegram_id', ''), post_time)
return jsonify({'success': True})
@app.route('/api/settings/location', methods=['POST'])
def api_set_location():
data = request.get_json()
location_id = data.get('location_id')
if not location_id:
return jsonify({'success': False, 'error': 'Keine Location-ID'})
db.set_location(location_id)
loc = db.get_current_location()
morning, afternoon = db.get_reminder_times_in_berlin(loc)
return jsonify({
'success': True,
'location': loc,
'reminders_berlin': {
'morning': f"{morning[0]:02d}:{morning[1]:02d}",
'afternoon': f"{afternoon[0]:02d}:{afternoon[1]:02d}"
}
})
@app.route('/api/balance')
def api_balance():
try:
balance = asyncio.run(openrouter.get_balance())
return jsonify(balance)
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/article/<date_str>')
def api_article(date_str):
post_time = request.args.get('post_time')
article = db.get_article_by_date(date_str, post_time)
if not article:
return jsonify({'error': 'Nicht gefunden'}), 404
return jsonify(article)
@app.route('/api/articles/<date_str>')
def api_articles_for_date(date_str):
"""Alle Artikel eines Tages (alle Slots)."""
articles = db.get_articles_by_date(date_str)
return jsonify(articles)
@app.route('/api/article/<date_str>/approve', methods=['POST'])
def api_approve(date_str):
data = request.get_json() or {}
post_time = data.get('post_time', POST_TIME)
db.update_article_status(date_str, 'approved', post_time=post_time)
flog.article_approved(date_str, post_time, 0)
return jsonify({'success': True})
@app.route('/api/article/<date_str>/skip', methods=['POST'])
def api_skip(date_str):
data = request.get_json() or {}
post_time = data.get('post_time', POST_TIME)
db.update_article_status(date_str, 'skipped', post_time=post_time)
flog.article_skipped(date_str, post_time)
return jsonify({'success': True})
if __name__ == '__main__':
db.init_db()
app.run(host='0.0.0.0', port=8080, debug=False)

402
fuenfvoracht/src/bot.py Normal file
View file

@ -0,0 +1,402 @@
#!/usr/bin/env python3
"""
FünfVorAcht Bot Review, Scheduling, Briefing, Fehler-Alarm
"""
import asyncio
import os
from datetime import datetime, timedelta
import pytz
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup, Bot
from telegram.ext import (Application, CallbackQueryHandler, CommandHandler,
MessageHandler, filters, ContextTypes)
from telegram.constants import ParseMode
from apscheduler.schedulers.asyncio import AsyncIOScheduler
import database as db
import logger as flog
BOT_TOKEN = os.environ['TELEGRAM_BOT_TOKEN']
CHANNEL_ID = os.environ.get('TELEGRAM_CHANNEL_ID', '')
TZ = pytz.timezone(os.environ.get('TIMEZONE', 'Europe/Berlin'))
POST_TIME = os.environ.get('POST_TIME', '19:55')
BRAND_MARKER = "Pax et Lux Terranaut01 https://t.me/DieneDemLeben"
BRAND_SIGNATURE = (
"Wir schützen die Zukunft unserer Kinder und das Leben❤\n\n"
"Pax et Lux Terranaut01 https://t.me/DieneDemLeben\n\n"
"Unterstützt die Menschen, die für Uns einstehen❗"
)
edit_pending = {}
def today_str():
return datetime.now(TZ).strftime('%Y-%m-%d')
def with_branding(content: str) -> str:
text = (content or "").rstrip()
if BRAND_MARKER in text:
return text
return f"{text}\n\n{BRAND_SIGNATURE}" if text else BRAND_SIGNATURE
def review_keyboard(date_str: str, post_time: str):
return InlineKeyboardMarkup([[
InlineKeyboardButton("✅ Freigeben", callback_data=f"approve:{date_str}:{post_time}"),
InlineKeyboardButton("✏️ Bearbeiten", callback_data=f"edit:{date_str}:{post_time}"),
]])
def is_reviewer(user_id: int) -> bool:
return user_id in db.get_reviewer_chat_ids()
async def notify_reviewers(bot: Bot, text: str, parse_mode=ParseMode.HTML,
reply_markup=None):
for chat_id in db.get_reviewer_chat_ids():
try:
await bot.send_message(chat_id, text,
parse_mode=parse_mode,
reply_markup=reply_markup)
except Exception as e:
flog.error('notify_reviewer_failed', chat_id=chat_id, reason=str(e))
# ── Commands ──────────────────────────────────────────────────────────────────
async def cmd_start(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
if not is_reviewer(update.effective_user.id):
return
await update.message.reply_text(
"🕗 <b>FünfVorAcht — Review Bot</b>\n\n"
"Artikel werden im Dashboard erstellt und eingeplant.\n"
"Hier kannst du sie freigeben oder letzte Änderungen vornehmen.\n\n"
"<b>Befehle:</b>\n"
"/heute — Alle Slots von heute\n"
"/queue — Nächste 3 Tage\n"
"/skip — Heutigen Hauptslot überspringen",
parse_mode=ParseMode.HTML
)
async def cmd_heute(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
if not is_reviewer(update.effective_user.id):
return
d = today_str()
articles = db.get_articles_by_date(d)
if not articles:
await update.message.reply_text(
f"📭 Noch keine Artikel für heute ({d}).\n"
"👉 Dashboard: http://100.73.171.62:8080"
)
return
status_map = {
'draft': '📝 Entwurf',
'scheduled': '🗓️ Eingeplant',
'sent_to_bot': '📱 Zum Review gesendet',
'approved': '✅ Freigegeben',
'posted': '📤 Gepostet ✓',
'skipped': '⏭️ Übersprungen',
'pending_review': '⏳ Wartet auf Freigabe',
}
lines = [f"📅 <b>{d}</b> — {len(articles)} Slot(s)\n"]
for art in articles:
lines.append(
f"<b>{art['post_time']} Uhr</b> · "
f"{status_map.get(art['status'], art['status'])} · "
f"v{art['version']}"
)
await update.message.reply_text('\n'.join(lines), parse_mode=ParseMode.HTML)
async def cmd_queue(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
if not is_reviewer(update.effective_user.id):
return
lines = ["📆 <b>Nächste 3 Tage:</b>\n"]
status_icons = {
'draft': '📝', 'scheduled': '🗓️', 'sent_to_bot': '📱',
'approved': '', 'posted': '📤', 'skipped': '⏭️',
}
for i in range(3):
d = (datetime.now(TZ) + timedelta(days=i)).strftime('%Y-%m-%d')
arts = db.get_articles_by_date(d)
if arts:
slots = ', '.join(
f"{a['post_time']} {status_icons.get(a['status'], '')}"
for a in arts
)
lines.append(f"<b>{d}</b>: {slots}")
else:
lines.append(f"❌ <b>{d}</b> — keine Artikel")
await update.message.reply_text('\n'.join(lines), parse_mode=ParseMode.HTML)
async def cmd_skip(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
if not is_reviewer(update.effective_user.id):
return
d = today_str()
channel = db.get_channel()
pt = channel.get('post_time', POST_TIME)
art = db.get_article_by_date(d, pt)
if not art:
db.create_article(d, "SKIP", "", None, "allgemein", pt)
db.update_article_status(d, 'skipped', post_time=pt)
flog.article_skipped(d, pt)
await update.message.reply_text(f"⏭️ {d} {pt} Uhr übersprungen.")
# ── Callbacks ─────────────────────────────────────────────────────────────────
async def handle_callback(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
query = update.callback_query
await query.answer()
parts = query.data.split(':', 2)
action = parts[0]
date_str = parts[1] if len(parts) > 1 else today_str()
post_time = parts[2] if len(parts) > 2 else POST_TIME
if action == "approve":
db.update_article_status(date_str, 'approved',
query.message.message_id,
query.message.chat_id,
post_time=post_time)
article = db.get_article_by_date(date_str, post_time)
flog.article_approved(date_str, post_time, query.message.chat_id)
await query.edit_message_text(
f"✅ <b>Freigegeben!</b>\n\n"
f"Wird automatisch um <b>{post_time} Uhr</b> gepostet.\n\n"
f"{article['content_final']}",
parse_mode=ParseMode.HTML
)
elif action == "edit":
article = db.get_article_by_date(date_str, post_time)
edit_pending[f"{date_str}:{post_time}"] = True
await query.edit_message_text(
f"✏️ <b>Bearbeiten</b> — {date_str} {post_time} Uhr\n\n"
f"Schick mir den neuen Text als nächste Nachricht.\n\n"
f"<i>Aktueller Text:</i>\n{article['content_final']}",
parse_mode=ParseMode.HTML
)
# ── Textnachrichten ───────────────────────────────────────────────────────────
async def handle_message(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
if not is_reviewer(update.effective_user.id):
return
# Suche ob ein Edit-Pending-Slot aktiv ist
pending_key = next((k for k in edit_pending), None)
if not pending_key:
await update.message.reply_text(
" Artikel werden im Dashboard erstellt.\n"
"http://100.73.171.62:8080"
)
return
date_str, post_time = pending_key.split(':', 1)
new_text = update.message.text.strip()
db.update_article_content(date_str, new_text, post_time=post_time)
del edit_pending[pending_key]
await update.message.reply_text(
f"✏️ <b>Aktualisiert</b> — {date_str} {post_time} Uhr\n\n{new_text}",
parse_mode=ParseMode.HTML,
reply_markup=review_keyboard(date_str, post_time)
)
db.update_article_status(date_str, 'sent_to_bot',
update.message.message_id,
update.effective_chat.id,
post_time=post_time)
# ── Scheduler Jobs ────────────────────────────────────────────────────────────
async def job_post_articles(bot: Bot):
"""Postet alle freigegebenen Artikel deren post_time jetzt fällig ist."""
now_berlin = datetime.now(TZ)
d = now_berlin.strftime('%Y-%m-%d')
current_slot = now_berlin.strftime('%H:%M')
articles = db.get_articles_by_date(d)
for article in articles:
if article['status'] != 'approved':
continue
if article['post_time'] != current_slot:
continue
if not CHANNEL_ID:
await notify_reviewers(bot, "⚠️ Kanal-ID nicht konfiguriert!")
flog.error('no_channel_id', date=d, post_time=current_slot)
return
try:
final_text = with_branding(article['content_final'])
msg = await bot.send_message(CHANNEL_ID, final_text, parse_mode=ParseMode.HTML)
db.update_article_status(d, 'posted', post_time=current_slot)
db.save_post_history(d, msg.message_id, post_time=current_slot)
flog.article_posted(d, current_slot, CHANNEL_ID, msg.message_id)
await notify_reviewers(
bot,
f"📤 <b>Fünf vor Acht gepostet!</b>\n{d} · {current_slot} Uhr"
)
except Exception as e:
flog.posting_failed(d, current_slot, str(e))
await notify_reviewers(
bot,
f"❌ <b>Posting fehlgeschlagen!</b>\n\n"
f"📅 {d} · ⏰ {current_slot} Uhr\n"
f"Kanal: {CHANNEL_ID}\n\n"
f"<b>Ursache:</b> {str(e)[:250]}\n\n"
f"👉 Dashboard: http://100.73.171.62:8080"
)
async def job_check_notify(bot: Bot):
"""Prüft alle 5 Min ob scheduled-Artikel zur Bot-Benachrichtigung fällig sind."""
due = db.get_due_notifications()
for article in due:
d = article['date']
pt = article['post_time']
text = (
f"📋 <b>Review: {d} · {pt} Uhr</b>\n"
f"Version {article['version']}\n"
f"──────────────────────\n\n"
f"{article['content_final']}\n\n"
f"──────────────────────\n"
f"Freigeben oder bearbeiten?"
)
await notify_reviewers(bot, text,
reply_markup=review_keyboard(d, pt))
db.update_article_status(d, 'sent_to_bot', post_time=pt)
flog.article_sent_to_bot(d, pt, db.get_reviewer_chat_ids())
async def job_morning_briefing(bot: Bot):
"""Morgen-Briefing: was ist heute geplant, was fehlt."""
d = today_str()
now_berlin = datetime.now(TZ)
channel = db.get_channel()
articles_today = db.get_articles_by_date(d)
approved = [a for a in articles_today if a['status'] in ('approved', 'scheduled', 'sent_to_bot')]
missing = [a for a in articles_today if a['status'] in ('draft', 'pending_review')]
# Nächste 3 Tage für Ausblick
plan_lines = []
for i in range(1, 4):
next_d = (now_berlin + timedelta(days=i)).strftime('%Y-%m-%d')
arts = db.get_articles_by_date(next_d)
if arts:
slots = ', '.join(f"{a['post_time']}" if a['status'] in ('approved', 'scheduled') else f"{a['post_time']} 📝" for a in arts)
plan_lines.append(f" {next_d}: {slots}")
else:
plan_lines.append(f" {next_d}: ❌ leer")
lines = [f"☀️ <b>Guten Morgen — FünfVorAcht Briefing</b>\n",
f"📅 <b>Heute: {d}</b>"]
if approved:
lines.append(f"✅ Eingeplant: {len(approved)} Slot(s)")
for a in approved:
lines.append(f"{a['post_time']} Uhr — {(a['content_final'] or '')[:50]}")
else:
lines.append("⚠️ Noch kein Artikel für heute freigegeben!")
if missing:
lines.append(f"📝 Entwürfe (noch nicht freigegeben): {len(missing)}")
if not articles_today:
lines.append("❌ Kein Artikel erstellt — bitte jetzt anlegen.")
lines.append(f"\n📆 <b>Nächste 3 Tage:</b>")
lines.extend(plan_lines)
lines.append(f"\n👉 Dashboard: http://100.73.171.62:8080")
await notify_reviewers(bot, '\n'.join(lines))
flog.info('morning_briefing_sent', date=d)
async def job_reminder_afternoon(bot: Bot):
"""Nachmittags-Reminder wenn Hauptslot noch nicht freigegeben."""
d = today_str()
channel = db.get_channel()
post_t = channel.get('post_time', POST_TIME)
art = db.get_article_by_date(d, post_t)
if art and art['status'] in ('sent_to_bot', 'pending_review', 'draft', 'scheduled'):
await notify_reviewers(
bot,
f"⚠️ <b>Noch nicht freigegeben!</b>\n\n"
f"Posting um {post_t} Uhr — bitte jetzt freigeben.",
reply_markup=review_keyboard(d, post_t) if art['status'] == 'sent_to_bot' else None
)
# ── Main ──────────────────────────────────────────────────────────────────────
def main():
db.init_db()
# Standard-Reviewer aus ENV falls DB leer
reviewers_in_db = db.get_reviewers()
if not reviewers_in_db:
import os as _os
raw = _os.environ.get('REVIEW_CHAT_IDS', '')
admin = _os.environ.get('ADMIN_CHAT_ID', '')
ids_raw = [x.strip() for x in raw.split(',') if x.strip()] if raw else []
if admin and admin not in ids_raw:
ids_raw.append(admin)
for cid in ids_raw:
try:
db.add_reviewer(int(cid), f"Redakteur {cid}")
except Exception:
pass
application = Application.builder().token(BOT_TOKEN).build()
bot = application.bot
channel = db.get_channel()
post_t = channel.get('post_time', POST_TIME) if channel else POST_TIME
location = db.get_current_location()
if location:
(rem_m_h, rem_m_m), (rem_a_h, rem_a_m) = db.get_reminder_times_in_berlin(location)
flog.info('scheduler_init',
location=location['name'],
morning=f"{rem_m_h:02d}:{rem_m_m:02d}",
afternoon=f"{rem_a_h:02d}:{rem_a_m:02d}")
else:
rem_m_h, rem_m_m = 10, 0
rem_a_h, rem_a_m = 17, 45
scheduler = AsyncIOScheduler(timezone=TZ)
# Jede Minute prüfen ob ein Artikel zu posten ist
scheduler.add_job(job_post_articles, 'cron', minute='*',
kwargs={'bot': bot})
# Alle 5 Min auf fällige Benachrichtigungen prüfen
scheduler.add_job(job_check_notify, 'cron', minute='*/5',
kwargs={'bot': bot})
# Morgen-Briefing 10:00 MEZ
scheduler.add_job(job_morning_briefing, 'cron',
hour=rem_m_h, minute=rem_m_m,
kwargs={'bot': bot})
# Nachmittags-Reminder
scheduler.add_job(job_reminder_afternoon, 'cron',
hour=rem_a_h, minute=rem_a_m,
kwargs={'bot': bot})
scheduler.start()
flog.info('bot_started', post_time=post_t,
briefing=f"{rem_m_h:02d}:{rem_m_m:02d}",
afternoon=f"{rem_a_h:02d}:{rem_a_m:02d}")
application.add_handler(CommandHandler("start", cmd_start))
application.add_handler(CommandHandler("heute", cmd_heute))
application.add_handler(CommandHandler("queue", cmd_queue))
application.add_handler(CommandHandler("skip", cmd_skip))
application.add_handler(CallbackQueryHandler(handle_callback))
application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message))
application.run_polling(drop_pending_updates=True)
if __name__ == '__main__':
main()

View file

@ -0,0 +1,694 @@
import sqlite3
import os
import logging
from datetime import datetime
import logger as flog
_logger = logging.getLogger(__name__)
DB_PATH = os.environ.get('DB_PATH', '/data/fuenfvoracht.db')
DEFAULT_PROMPT = '''Du erstellst einen strukturierten Beitrag für den Telegram-Kanal "Fünf vor Acht".
Der Beitrag präsentiert einen Inhalt (Video, Artikel, Vortrag) neutral und informativ.
Leser sollen sich selbst ein Bild machen können.
EINGABE: {source}
DATUM: {date}
THEMA: {tag}
AUFGABE:
Analysiere die Quelle und erstelle einen Telegram-Beitrag nach exakt diesem FORMAT.
Wähle passende Emojis für die Sektions-Überschriften je nach Thema.
Schreibe sachlich und ohne eigene Wertung.
FORMAT exakt so ausgeben (Telegram-kompatibel, kein HTML):
[Kategorie-Emoji] [Typ]: [Vollständiger Titel]
🔗 [Quelle ansehen / Artikel lesen / Video ansehen]:
[URL aus der Eingabe]
[Themen-Emoji] Inhaltlicher Schwerpunkt
[2-3 Sätze: Wer spricht/schreibt worüber und in welchem Kontext]
Themen im Überblick:
[Kernthema 1]
[Kernthema 2]
[Kernthema 3]
[Kernthema 4]
[Kernthema 5]
[2. Themen-Emoji] [Zweiter Schwerpunkt falls vorhanden]
[2-3 Sätze zum zweiten Teil]
[Unterpunkt 1]
[Unterpunkt 2]
[Unterpunkt 3]
📌 Einordnung
[2-3 Sätze: Neutrale Beschreibung des Formats/der Methode. Kein Urteil. Leser können Quellen selbst prüfen.]'''
def get_conn():
conn = sqlite3.connect(DB_PATH, check_same_thread=False)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
return conn
def init_db():
conn = get_conn()
c = conn.cursor()
# Basis-Tabellen
c.executescript('''
CREATE TABLE IF NOT EXISTS articles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
post_time TEXT NOT NULL DEFAULT '19:55',
source_input TEXT,
content_raw TEXT,
content_final TEXT,
status TEXT DEFAULT 'draft',
version INTEGER DEFAULT 1,
review_message_id INTEGER,
review_chat_id INTEGER,
prompt_id INTEGER,
tag TEXT,
notify_at TEXT,
scheduled_at TEXT,
created_at TEXT DEFAULT (datetime('now')),
sent_to_bot_at TEXT,
approved_at TEXT,
posted_at TEXT,
UNIQUE(date, post_time)
);
CREATE TABLE IF NOT EXISTS article_versions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
article_id INTEGER NOT NULL,
version_nr INTEGER NOT NULL,
content TEXT,
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (article_id) REFERENCES articles(id)
);
CREATE TABLE IF NOT EXISTS post_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
article_id INTEGER NOT NULL,
channel_message_id INTEGER,
posted_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (article_id) REFERENCES articles(id)
);
CREATE TABLE IF NOT EXISTS prompts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
system_prompt TEXT NOT NULL,
is_default INTEGER DEFAULT 0,
last_tested_at TEXT,
test_result TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS sources_favorites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
label TEXT NOT NULL,
url TEXT,
used_count INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS article_tags (
article_id INTEGER NOT NULL,
tag_id INTEGER NOT NULL,
PRIMARY KEY (article_id, tag_id),
FOREIGN KEY (article_id) REFERENCES articles(id),
FOREIGN KEY (tag_id) REFERENCES tags(id)
);
CREATE TABLE IF NOT EXISTS channels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
telegram_id TEXT,
post_time TEXT DEFAULT '19:55',
timezone TEXT DEFAULT 'Europe/Berlin',
active INTEGER DEFAULT 1
);
CREATE TABLE IF NOT EXISTS locations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
flag TEXT NOT NULL,
timezone TEXT NOT NULL,
reminder_morning TEXT DEFAULT '10:00',
reminder_afternoon TEXT DEFAULT '18:00'
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT
);
CREATE TABLE IF NOT EXISTS reviewers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
chat_id INTEGER NOT NULL UNIQUE,
name TEXT NOT NULL,
active INTEGER DEFAULT 1,
added_at TEXT DEFAULT (datetime('now'))
);
''')
# Migration: post_time Spalte zu bestehender articles-Tabelle hinzufügen falls fehlt
cols = [r[1] for r in c.execute("PRAGMA table_info(articles)").fetchall()]
if 'post_time' not in cols:
c.execute("ALTER TABLE articles ADD COLUMN post_time TEXT NOT NULL DEFAULT '19:55'")
_logger.info("Migration: post_time Spalte hinzugefügt")
if 'notify_at' not in cols:
c.execute("ALTER TABLE articles ADD COLUMN notify_at TEXT")
if 'scheduled_at' not in cols:
c.execute("ALTER TABLE articles ADD COLUMN scheduled_at TEXT")
# reviewers-Tabelle Migration
reviewer_cols = [r[1] for r in c.execute("PRAGMA table_info(reviewers)").fetchall()]
if not reviewer_cols:
_logger.info("Migration: reviewers Tabelle erstellt")
# Standard-Daten
c.execute("SELECT COUNT(*) FROM prompts")
if c.fetchone()[0] == 0:
c.execute(
"INSERT INTO prompts (name, system_prompt, is_default) VALUES (?, ?, 1)",
("Standard", DEFAULT_PROMPT)
)
c.execute("SELECT COUNT(*) FROM channels")
if c.fetchone()[0] == 0:
c.execute(
"INSERT INTO channels (name, telegram_id, post_time, timezone) VALUES (?, ?, ?, ?)",
("Fünf vor Acht", "", "19:55", "Europe/Berlin")
)
for tag in ["Politik", "Wirtschaft", "Tech", "Gesellschaft", "Umwelt", "Kultur", "Sport"]:
c.execute("INSERT OR IGNORE INTO tags (name) VALUES (?)", (tag,))
locations = [
("Deutschland", "🇩🇪", "Europe/Berlin", "10:00", "18:00"),
("Kambodscha", "🇰🇭", "Asia/Phnom_Penh", "10:00", "18:00"),
("Thailand", "🇹🇭", "Asia/Bangkok", "10:00", "18:00"),
("USA Ostküste", "🇺🇸", "America/New_York", "10:00", "18:00"),
("USA Westküste","🇺🇸", "America/Los_Angeles", "10:00", "18:00"),
("Spanien", "🇪🇸", "Europe/Madrid", "10:00", "18:00"),
]
c.execute("SELECT COUNT(*) FROM locations")
if c.fetchone()[0] == 0:
for loc in locations:
c.execute(
"INSERT INTO locations (name, flag, timezone, reminder_morning, reminder_afternoon) VALUES (?,?,?,?,?)",
loc
)
c.execute("INSERT OR IGNORE INTO settings (key, value) VALUES ('user_location_id', '1')")
conn.commit()
conn.close()
_logger.info("DB initialisiert: %s", DB_PATH)
# ── Article CRUD ──────────────────────────────────────────────────────────────
def get_article_by_date(date_str, post_time=None):
"""Gibt ersten Artikel des Tages zurück, oder den mit spezifischer post_time."""
conn = get_conn()
if post_time:
row = conn.execute(
"SELECT * FROM articles WHERE date=? AND post_time=?", (date_str, post_time)
).fetchone()
else:
row = conn.execute(
"SELECT * FROM articles WHERE date=? ORDER BY post_time ASC LIMIT 1", (date_str,)
).fetchone()
conn.close()
return dict(row) if row else None
def get_articles_by_date(date_str):
"""Gibt alle Artikel eines Tages zurück (mehrere Slots)."""
conn = get_conn()
rows = conn.execute(
"SELECT * FROM articles WHERE date=? ORDER BY post_time ASC", (date_str,)
).fetchall()
conn.close()
return [dict(r) for r in rows]
def get_article_by_id(article_id):
conn = get_conn()
row = conn.execute("SELECT * FROM articles WHERE id=?", (article_id,)).fetchone()
conn.close()
return dict(row) if row else None
def slot_is_taken(date_str, post_time, exclude_id=None):
"""Prüft ob ein Zeitslot bereits belegt ist."""
conn = get_conn()
if exclude_id:
row = conn.execute(
"SELECT id FROM articles WHERE date=? AND post_time=? AND id!=? AND status NOT IN ('skipped','rejected')",
(date_str, post_time, exclude_id)
).fetchone()
else:
row = conn.execute(
"SELECT id FROM articles WHERE date=? AND post_time=? AND status NOT IN ('skipped','rejected')",
(date_str, post_time)
).fetchone()
conn.close()
return row is not None
def get_taken_slots(date_str):
"""Gibt alle belegten Zeitslots eines Tages zurück."""
conn = get_conn()
rows = conn.execute(
"SELECT post_time FROM articles WHERE date=? AND status NOT IN ('skipped','rejected')",
(date_str,)
).fetchall()
conn.close()
return [r[0] for r in rows]
def create_article(date_str, source_input, content, prompt_id, tag="allgemein", post_time="19:55"):
conn = get_conn()
try:
conn.execute(
"INSERT INTO articles (date, post_time, source_input, content_raw, content_final, prompt_id, tag) VALUES (?,?,?,?,?,?,?)",
(date_str, post_time, source_input, content, content, prompt_id, tag)
)
conn.execute(
"INSERT INTO article_versions (article_id, version_nr, content) "
"VALUES ((SELECT id FROM articles WHERE date=? AND post_time=?), 1, ?)",
(date_str, post_time, content)
)
conn.commit()
flog.article_generated(date_str, source_input or '', 1, tag)
finally:
conn.close()
def reschedule_article(article_id, new_date, new_post_time):
"""Verschiebt einen Artikel auf einen neuen Datum/Zeit-Slot."""
conn = get_conn()
try:
conn.execute(
"UPDATE articles SET date=?, post_time=? WHERE id=?",
(new_date, new_post_time, article_id)
)
conn.commit()
flog.info('article_rescheduled', article_id=article_id,
new_date=new_date, new_post_time=new_post_time)
return True
except sqlite3.IntegrityError:
flog.slot_conflict(new_date, new_post_time)
return False
finally:
conn.close()
def delete_article(article_id):
conn = get_conn()
conn.execute("DELETE FROM article_versions WHERE article_id=?", (article_id,))
conn.execute("DELETE FROM articles WHERE id=?", (article_id,))
conn.commit()
conn.close()
flog.info('article_deleted', article_id=article_id)
def update_article_status(date_str, status, message_id=None, chat_id=None, post_time=None):
conn = get_conn()
ts = datetime.utcnow().isoformat()
where = "date=? AND post_time=?" if post_time else "date=?"
params_base = (date_str, post_time) if post_time else (date_str,)
if status == 'approved':
conn.execute(
f"UPDATE articles SET status=?, approved_at=?, review_message_id=?, review_chat_id=? WHERE {where}",
(status, ts, message_id, chat_id) + params_base
)
elif status == 'posted':
conn.execute(
f"UPDATE articles SET status=?, posted_at=? WHERE {where}",
(status, ts) + params_base
)
elif status == 'sent_to_bot':
conn.execute(
f"UPDATE articles SET status=?, sent_to_bot_at=?, review_message_id=?, review_chat_id=? WHERE {where}",
(status, ts, message_id, chat_id) + params_base
)
elif status == 'scheduled':
conn.execute(
f"UPDATE articles SET status=?, scheduled_at=? WHERE {where}",
(status, ts) + params_base
)
else:
conn.execute(f"UPDATE articles SET status=? WHERE {where}", (status,) + params_base)
conn.commit()
conn.close()
def schedule_article(date_str, post_time, notify_at):
"""Artikel einplanen mit Bot-Benachrichtigungs-Zeitpunkt."""
conn = get_conn()
ts = datetime.utcnow().isoformat()
conn.execute(
"UPDATE articles SET status='scheduled', scheduled_at=?, post_time=?, notify_at=? WHERE date=? AND post_time=?",
(ts, post_time, notify_at, date_str, post_time)
)
conn.commit()
conn.close()
flog.article_scheduled(date_str, post_time, notify_at)
def get_due_notifications():
"""Gibt alle scheduled-Artikel zurück deren notify_at <= jetzt."""
conn = get_conn()
now = datetime.utcnow().isoformat()
rows = conn.execute(
"SELECT * FROM articles WHERE status='scheduled' AND notify_at IS NOT NULL AND notify_at <= ?",
(now,)
).fetchall()
conn.close()
return [dict(r) for r in rows]
def update_article_content(date_str, content, new_version=False, post_time=None):
conn = get_conn()
where = "date=? AND post_time=?" if post_time else "date=?"
params_base = (date_str, post_time) if post_time else (date_str,)
if new_version:
version = conn.execute(
f"SELECT version FROM articles WHERE {where}", params_base
).fetchone()
new_v = (version[0] or 1) + 1
conn.execute(
f"UPDATE articles SET content_raw=?, content_final=?, version=?, status='pending_review' WHERE {where}",
(content, content, new_v) + params_base
)
article_id = conn.execute(
f"SELECT id FROM articles WHERE {where}", params_base
).fetchone()[0]
conn.execute(
"INSERT INTO article_versions (article_id, version_nr, content) VALUES (?,?,?)",
(article_id, new_v, content)
)
else:
conn.execute(
f"UPDATE articles SET content_final=? WHERE {where}", (content,) + params_base
)
conn.commit()
conn.close()
def save_post_history(date_str, channel_message_id, post_time=None):
conn = get_conn()
where = "date=? AND post_time=?" if post_time else "date=?"
params = (date_str, post_time) if post_time else (date_str,)
article_id = conn.execute(
f"SELECT id FROM articles WHERE {where}", params
).fetchone()
if article_id:
conn.execute(
"INSERT INTO post_history (article_id, channel_message_id) VALUES (?,?)",
(article_id[0], channel_message_id)
)
conn.commit()
conn.close()
def get_recent_articles(limit=30):
conn = get_conn()
rows = conn.execute(
"SELECT * FROM articles ORDER BY date DESC, post_time ASC LIMIT ?", (limit,)
).fetchall()
conn.close()
return [dict(r) for r in rows]
def get_week_articles(from_date, to_date):
conn = get_conn()
rows = conn.execute(
"SELECT * FROM articles WHERE date BETWEEN ? AND ? ORDER BY date ASC, post_time ASC",
(from_date, to_date)
).fetchall()
conn.close()
return [dict(r) for r in rows]
def get_last_posted():
conn = get_conn()
row = conn.execute(
"SELECT date, post_time, posted_at FROM articles WHERE status='posted' ORDER BY posted_at DESC LIMIT 1"
).fetchone()
conn.close()
return dict(row) if row else None
# ── Prompts ───────────────────────────────────────────────────────────────────
def get_prompts():
conn = get_conn()
rows = conn.execute("SELECT * FROM prompts ORDER BY is_default DESC, id ASC").fetchall()
conn.close()
return [dict(r) for r in rows]
def get_default_prompt():
conn = get_conn()
row = conn.execute("SELECT * FROM prompts WHERE is_default=1 LIMIT 1").fetchone()
if not row:
row = conn.execute("SELECT * FROM prompts LIMIT 1").fetchone()
conn.close()
return dict(row) if row else None
def save_prompt(prompt_id, name, system_prompt):
conn = get_conn()
conn.execute("UPDATE prompts SET name=?, system_prompt=? WHERE id=?", (name, system_prompt, prompt_id))
conn.commit()
conn.close()
def create_prompt(name, system_prompt):
conn = get_conn()
conn.execute("INSERT INTO prompts (name, system_prompt) VALUES (?,?)", (name, system_prompt))
conn.commit()
conn.close()
def set_default_prompt(prompt_id):
conn = get_conn()
conn.execute("UPDATE prompts SET is_default=0")
conn.execute("UPDATE prompts SET is_default=1 WHERE id=?", (prompt_id,))
conn.commit()
conn.close()
def delete_prompt(prompt_id):
conn = get_conn()
conn.execute("DELETE FROM prompts WHERE id=? AND is_default=0", (prompt_id,))
conn.commit()
conn.close()
def save_prompt_test_result(prompt_id, result):
conn = get_conn()
conn.execute(
"UPDATE prompts SET test_result=?, last_tested_at=? WHERE id=?",
(result, datetime.utcnow().isoformat(), prompt_id)
)
conn.commit()
conn.close()
# ── Channel / Settings ────────────────────────────────────────────────────────
def get_channel():
conn = get_conn()
row = conn.execute("SELECT * FROM channels WHERE active=1 LIMIT 1").fetchone()
conn.close()
return dict(row) if row else {}
def update_channel(telegram_id, post_time="19:55"):
conn = get_conn()
conn.execute(
"UPDATE channels SET telegram_id=?, post_time=? WHERE active=1",
(telegram_id, post_time)
)
conn.commit()
conn.close()
# ── Reviewers ─────────────────────────────────────────────────────────────────
def get_reviewers(active_only=True):
conn = get_conn()
if active_only:
rows = conn.execute(
"SELECT * FROM reviewers WHERE active=1 ORDER BY added_at ASC"
).fetchall()
else:
rows = conn.execute("SELECT * FROM reviewers ORDER BY added_at ASC").fetchall()
conn.close()
return [dict(r) for r in rows]
def add_reviewer(chat_id: int, name: str):
conn = get_conn()
try:
conn.execute(
"INSERT INTO reviewers (chat_id, name, active) VALUES (?,?,1)",
(chat_id, name)
)
conn.commit()
flog.reviewer_added(chat_id, name)
return True
except sqlite3.IntegrityError:
return False
finally:
conn.close()
def remove_reviewer(chat_id: int):
conn = get_conn()
conn.execute("UPDATE reviewers SET active=0 WHERE chat_id=?", (chat_id,))
conn.commit()
conn.close()
flog.reviewer_removed(chat_id)
def get_reviewer_chat_ids():
"""Gibt aktive Reviewer-Chat-IDs aus DB zurück, Fallback auf ENV."""
reviewers = get_reviewers(active_only=True)
if reviewers:
return [r['chat_id'] for r in reviewers]
# Fallback: ENV
import os
ids = []
raw = os.environ.get('REVIEW_CHAT_IDS', '')
if raw.strip():
for part in raw.split(','):
try:
ids.append(int(part.strip()))
except ValueError:
pass
admin = os.environ.get('ADMIN_CHAT_ID', '')
if admin:
try:
ids.append(int(admin))
except ValueError:
pass
unique = []
for cid in ids:
if cid not in unique:
unique.append(cid)
return unique
# ── Favorites ─────────────────────────────────────────────────────────────────
def get_favorites():
conn = get_conn()
rows = conn.execute("SELECT * FROM sources_favorites ORDER BY used_count DESC").fetchall()
conn.close()
return [dict(r) for r in rows]
def add_favorite(label, url):
conn = get_conn()
conn.execute("INSERT INTO sources_favorites (label, url) VALUES (?,?)", (label, url))
conn.commit()
conn.close()
def increment_favorite(fav_id):
conn = get_conn()
conn.execute("UPDATE sources_favorites SET used_count=used_count+1 WHERE id=?", (fav_id,))
conn.commit()
conn.close()
# ── Tags ──────────────────────────────────────────────────────────────────────
def get_tags():
conn = get_conn()
rows = conn.execute("SELECT * FROM tags ORDER BY name").fetchall()
conn.close()
return [dict(r) for r in rows]
# ── Locations ─────────────────────────────────────────────────────────────────
def get_locations():
conn = get_conn()
rows = conn.execute("SELECT * FROM locations ORDER BY id").fetchall()
conn.close()
return [dict(r) for r in rows]
def get_current_location():
conn = get_conn()
loc_id = conn.execute(
"SELECT value FROM settings WHERE key='user_location_id'"
).fetchone()
if not loc_id:
conn.close()
return None
row = conn.execute("SELECT * FROM locations WHERE id=?", (loc_id[0],)).fetchone()
conn.close()
return dict(row) if row else None
def set_location(location_id):
conn = get_conn()
conn.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES ('user_location_id', ?)",
(str(location_id),)
)
conn.commit()
conn.close()
def get_reminder_times_in_berlin(location: dict) -> tuple:
import pytz
from datetime import datetime, date
user_tz = pytz.timezone(location['timezone'])
berlin_tz = pytz.timezone('Europe/Berlin')
today = date.today()
def convert(local_time_str):
h, m = map(int, local_time_str.split(':'))
local_dt = user_tz.localize(datetime(today.year, today.month, today.day, h, m))
berlin_dt = local_dt.astimezone(berlin_tz)
return berlin_dt.hour, berlin_dt.minute
return convert(location['reminder_morning']), convert(location['reminder_afternoon'])
# ── Stats ─────────────────────────────────────────────────────────────────────
def get_monthly_stats():
conn = get_conn()
from datetime import date
month = date.today().strftime('%Y-%m')
total = conn.execute(
"SELECT COUNT(*) FROM articles WHERE date LIKE ?", (f"{month}%",)
).fetchone()[0]
posted = conn.execute(
"SELECT COUNT(*) FROM articles WHERE date LIKE ? AND status='posted'", (f"{month}%",)
).fetchone()[0]
skipped = conn.execute(
"SELECT COUNT(*) FROM articles WHERE date LIKE ? AND status='skipped'", (f"{month}%",)
).fetchone()[0]
avg_version = conn.execute(
"SELECT AVG(version) FROM articles WHERE date LIKE ?", (f"{month}%",)
).fetchone()[0] or 0
conn.close()
return {"total": total, "posted": posted, "skipped": skipped, "avg_version": round(avg_version, 1)}

100
fuenfvoracht/src/logger.py Normal file
View file

@ -0,0 +1,100 @@
"""
Strukturiertes Logging für FünfVorAcht.
Schreibt JSON-Lines nach /logs/fuenfvoracht.log
"""
import json
import logging
import os
from datetime import datetime
LOG_PATH = os.environ.get('LOG_PATH', '/logs/fuenfvoracht.log')
_file_handler = None
def _get_file_handler():
global _file_handler
if _file_handler is None:
os.makedirs(os.path.dirname(LOG_PATH), exist_ok=True)
_file_handler = logging.FileHandler(LOG_PATH, encoding='utf-8')
_file_handler.setLevel(logging.DEBUG)
return _file_handler
def _write(level: str, event: str, **kwargs):
entry = {
'ts': datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ'),
'level': level,
'event': event,
**kwargs,
}
line = json.dumps(entry, ensure_ascii=False)
try:
handler = _get_file_handler()
record = logging.LogRecord(
name='fuenfvoracht', level=getattr(logging, level),
pathname='', lineno=0, msg=line, args=(), exc_info=None
)
handler.emit(record)
except Exception:
pass
# Auch in stdout damit docker logs es zeigt
print(line, flush=True)
def info(event: str, **kwargs):
_write('INFO', event, **kwargs)
def warning(event: str, **kwargs):
_write('WARNING', event, **kwargs)
def error(event: str, **kwargs):
_write('ERROR', event, **kwargs)
# Kurzformen für häufige Events
def article_generated(date: str, source: str, version: int, tag: str):
info('article_generated', date=date, source=source[:120], version=version, tag=tag)
def article_saved(date: str, post_time: str):
info('article_saved', date=date, post_time=post_time)
def article_scheduled(date: str, post_time: str, notify_at: str):
info('article_scheduled', date=date, post_time=post_time, notify_at=notify_at)
def article_sent_to_bot(date: str, post_time: str, chat_ids: list):
info('article_sent_to_bot', date=date, post_time=post_time, chat_ids=chat_ids)
def article_approved(date: str, post_time: str, by_chat_id: int):
info('article_approved', date=date, post_time=post_time, by_chat_id=by_chat_id)
def article_posted(date: str, post_time: str, channel_id: str, message_id: int):
info('article_posted', date=date, post_time=post_time,
channel_id=channel_id, message_id=message_id)
def article_skipped(date: str, post_time: str):
info('article_skipped', date=date, post_time=post_time)
def posting_failed(date: str, post_time: str, reason: str):
error('posting_failed', date=date, post_time=post_time, reason=reason[:300])
def reviewer_added(chat_id: int, name: str):
info('reviewer_added', chat_id=chat_id, name=name)
def reviewer_removed(chat_id: int):
info('reviewer_removed', chat_id=chat_id)
def slot_conflict(date: str, post_time: str):
warning('slot_conflict', date=date, post_time=post_time)

View file

@ -0,0 +1,72 @@
import os
import logging
import aiohttp
import asyncio
logger = logging.getLogger(__name__)
OPENROUTER_API_KEY = os.environ.get('OPENROUTER_API_KEY', '')
OPENROUTER_BASE = "https://openrouter.ai/api/v1"
DEFAULT_MODEL = os.environ.get('AI_MODEL', 'openai/gpt-4o-mini')
async def generate_article(source: str, prompt_template: str, date_str: str, tag: str = "allgemein") -> str:
system_prompt = prompt_template.format(
source=source,
date=date_str,
tag=tag.lower().replace(" ", "")
)
payload = {
"model": DEFAULT_MODEL,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": f"Schreibe jetzt den Artikel basierend auf dieser Quelle:\n\n{source}"}
],
"max_tokens": 600,
"temperature": 0.8
}
headers = {
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
"Content-Type": "application/json",
"HTTP-Referer": "https://fuenfvoracht.orbitalo.net",
"X-Title": "FünfVorAcht Bot"
}
async with aiohttp.ClientSession() as session:
async with session.post(f"{OPENROUTER_BASE}/chat/completions", json=payload, headers=headers) as resp:
data = await resp.json()
if resp.status != 200:
raise Exception(f"OpenRouter Fehler {resp.status}: {data}")
return data["choices"][0]["message"]["content"].strip()
async def get_balance() -> dict:
headers = {
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
"Content-Type": "application/json"
}
try:
async with aiohttp.ClientSession() as session:
async with session.get(f"{OPENROUTER_BASE}/auth/key", headers=headers) as resp:
if resp.status == 200:
data = await resp.json()
key_data = data.get("data", {})
limit = key_data.get("limit")
usage = key_data.get("usage", 0)
if limit:
remaining = round(limit - usage, 4)
else:
remaining = None
return {
"usage": round(usage, 4),
"limit": limit,
"remaining": remaining,
"label": key_data.get("label", ""),
"is_free_tier": key_data.get("is_free_tier", False)
}
except Exception as e:
logger.error("Balance-Abfrage fehlgeschlagen: %s", e)
return {"usage": None, "limit": None, "remaining": None}
def get_balance_sync() -> dict:
return asyncio.run(get_balance())

View file

@ -0,0 +1,4 @@
python-telegram-bot==20.7
apscheduler==3.10.4
aiohttp==3.9.3
pytz==2024.1

View file

@ -0,0 +1,5 @@
flask==3.0.2
aiohttp==3.9.3
pytz==2024.1
gunicorn==21.2.0
requests==2.31.0

View file

@ -0,0 +1,383 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Anleitung — FünfVorAcht</title>
<link rel="stylesheet" href="/static/tailwind.min.css">
<style>
body { background: #0f172a; color: #e2e8f0; }
.card { background: #1e293b; border: 1px solid #334155; border-radius: 12px; }
.step { background: #0f172a; border: 1px solid #334155; border-radius: 8px; }
.badge { border-radius: 999px; font-size: 0.7rem; font-weight: 700; padding: 2px 10px; }
.tag-new { background: #1e3a5f; color: #60a5fa; }
.tag-bot { background: #14532d; color: #4ade80; }
.tag-plan { background: #4c1d95; color: #c4b5fd; }
details summary { cursor: pointer; list-style: none; }
details summary::-webkit-details-marker { display: none; }
details[open] summary .chevron { transform: rotate(90deg); }
.chevron { display: inline-block; transition: transform .2s; }
code { background: #0f172a; border: 1px solid #334155; border-radius: 4px; padding: 1px 6px; font-size: 0.8rem; color: #94a3b8; }
kbd { background: #334155; border-radius: 4px; padding: 1px 6px; font-size: 0.8rem; color: #e2e8f0; }
</style>
</head>
<body class="min-h-screen">
<!-- Nav -->
<nav class="bg-slate-900 border-b border-slate-700 px-6 py-3 flex items-center justify-between sticky top-0 z-50">
<div class="flex items-center gap-3">
<span class="text-xl">🕗</span>
<span class="text-lg font-bold text-white">FünfVorAcht</span>
</div>
<div class="flex gap-5 text-sm">
<a href="/" class="text-slate-400 hover:text-white">Studio</a>
<a href="/history" class="text-slate-400 hover:text-white">History</a>
<a href="/prompts" class="text-slate-400 hover:text-white">Prompts</a>
<a href="/settings" class="text-slate-400 hover:text-white">Einstellungen</a>
<a href="/hilfe" class="text-blue-400 font-semibold">❓ Hilfe</a>
</div>
</nav>
<div class="max-w-4xl mx-auto px-6 py-8 space-y-4">
<div class="flex items-center gap-4 mb-6">
<h1 class="text-2xl font-bold text-white">Anleitung</h1>
<span class="text-slate-500 text-sm">FünfVorAcht — KI-gestützter Telegram-Poster</span>
</div>
<!-- Schnellübersicht -->
<div class="card p-5">
<h2 class="text-base font-semibold text-white mb-3">⚡ Schnellübersicht — Normaler Tagesablauf</h2>
<div class="flex flex-wrap gap-2 items-center text-sm text-slate-300">
<div class="step px-3 py-2">1. Quelle eingeben</div>
<span class="text-slate-600"></span>
<div class="step px-3 py-2">2. Artikel generieren</div>
<span class="text-slate-600"></span>
<div class="step px-3 py-2">3. Redigieren &amp; speichern</div>
<span class="text-slate-600"></span>
<div class="step px-3 py-2">4. Einplanen (Uhrzeit)</div>
<span class="text-slate-600"></span>
<div class="step px-3 py-2">5. Zum Bot senden</div>
<span class="text-slate-600"></span>
<div class="step px-3 py-2">6. Im Bot freigeben ✅</div>
<span class="text-slate-600"></span>
<div class="step bg-green-900/30 border-green-700 px-3 py-2 text-green-400">7. Automatisch gepostet 📤</div>
</div>
</div>
<!-- 1. Artikel erstellen -->
<details class="card" open>
<summary class="p-5 flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="text-xl">✏️</span>
<h2 class="text-base font-semibold text-white">1. Artikel erstellen</h2>
</div>
<span class="chevron text-slate-400 text-sm"></span>
</summary>
<div class="px-5 pb-5 space-y-4 border-t border-slate-700 pt-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="step p-4 space-y-2">
<div class="text-sm font-semibold text-blue-400">Quelle eingeben</div>
<p class="text-xs text-slate-400">URL eines Artikels, Videos oder Vortrags einfügen — oder ein Thema als Text beschreiben.</p>
<p class="text-xs text-slate-500">Tipp: Häufig genutzte Quellen als <span class="text-slate-300">Favoriten</span> speichern → Dropdown nutzen.</p>
</div>
<div class="step p-4 space-y-2">
<div class="text-sm font-semibold text-blue-400">Thema &amp; Prompt wählen</div>
<p class="text-xs text-slate-400">Tag (z.B. Politik, Tech) und den gewünschten KI-Prompt auswählen.</p>
<p class="text-xs text-slate-500">Prompts können unter <a href="/prompts" class="text-blue-400 hover:underline">Prompts</a> bearbeitet und getestet werden.</p>
</div>
</div>
<div class="step p-4 space-y-2">
<div class="text-sm font-semibold text-blue-400">⚡ Generieren</div>
<p class="text-xs text-slate-400">Button <kbd>Artikel generieren</kbd> klicken — die KI erstellt einen fertigen Telegram-Beitrag. Rechts erscheint sofort die Telegram-Vorschau.</p>
<p class="text-xs text-slate-500">Nicht zufrieden? <kbd>Neu generieren</kbd> erstellt eine neue Version (v2, v3 …). Alle Versionen werden gespeichert.</p>
</div>
<div class="step p-4 space-y-2">
<div class="text-sm font-semibold text-blue-400">✍️ Redigieren</div>
<p class="text-xs text-slate-400">Text im Editor direkt bearbeiten. Die Telegram-Vorschau aktualisiert sich in Echtzeit. Zeichenanzahl wird live angezeigt (max. 4096).</p>
<p class="text-xs text-slate-500">Das Markenzeichen wird automatisch am Ende eingefügt — nicht manuell nötig.</p>
</div>
</div>
</details>
<!-- 2. Zeitlich einplanen -->
<details class="card">
<summary class="p-5 flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="text-xl">📅</span>
<h2 class="text-base font-semibold text-white">2. Zeitlich einplanen</h2>
<span class="badge tag-new">NEU</span>
</div>
<span class="chevron text-slate-400 text-sm"></span>
</summary>
<div class="px-5 pb-5 space-y-4 border-t border-slate-700 pt-4">
<div class="step p-4 space-y-2">
<div class="text-sm font-semibold text-purple-400">📅 Einplanen-Panel</div>
<p class="text-xs text-slate-400">Nach dem Generieren auf <kbd>Einplanen</kbd> klicken. Ein Panel öffnet sich:</p>
<ul class="text-xs text-slate-400 space-y-1 mt-2 ml-3">
<li><span class="text-white">Datum</span> — aus dem Redaktionsplan übernommen oder frei wählbar</li>
<li><span class="text-white">Uhrzeit</span> — 15-Minuten-Raster (z.B. 07:00, 19:45, 19:55)</li>
<li><span class="text-white">Bot-Benachrichtigung</span> — Sofort / Vortag 17:00 / Posting-Tag 10:00</li>
</ul>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
<div class="step p-3">
<div class="text-xs font-semibold text-slate-300 mb-1">Artikel für heute</div>
<p class="text-xs text-slate-500">Bot-Benachrichtigung: <span class="text-yellow-400">Sofort</span> vorausgewählt</p>
</div>
<div class="step p-3">
<div class="text-xs font-semibold text-slate-300 mb-1">Artikel für morgen/später</div>
<p class="text-xs text-slate-500">Automatisch: <span class="text-yellow-400">Vortag 17:00 Uhr</span> vorausgewählt</p>
</div>
<div class="step p-3">
<div class="text-xs font-semibold text-slate-300 mb-1">Zeitkonflikt</div>
<p class="text-xs text-slate-500"><span class="text-red-400">Blockiert</span> wenn Slot belegt — belegte Zeiten sind ausgegraut</p>
</div>
</div>
<div class="bg-blue-900/20 border border-blue-800/50 rounded-lg p-3 text-xs text-slate-300">
<span class="text-blue-400 font-semibold"> Mehrere Posts pro Tag:</span> Für jeden Zeitslot einen eigenen Artikel anlegen. Jeder Slot wird unabhängig eingeplant und gepostet.
</div>
</div>
</details>
<!-- 3. Freigabe & Review -->
<details class="card">
<summary class="p-5 flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="text-xl">📱</span>
<h2 class="text-base font-semibold text-white">3. Freigabe &amp; Review im Telegram-Bot</h2>
<span class="badge tag-bot">BOT</span>
</div>
<span class="chevron text-slate-400 text-sm"></span>
</summary>
<div class="px-5 pb-5 space-y-4 border-t border-slate-700 pt-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="step p-4 space-y-2">
<div class="text-sm font-semibold text-green-400">Review erhalten</div>
<p class="text-xs text-slate-400">Zum geplanten Zeitpunkt (oder sofort bei manuell senden) schickt der Bot den Artikel an alle Redakteure mit zwei Buttons:</p>
<div class="flex gap-2 mt-2">
<span class="text-xs bg-green-900/40 border border-green-700 text-green-400 px-2 py-1 rounded">✅ Freigeben</span>
<span class="text-xs bg-slate-700 border border-slate-600 text-slate-300 px-2 py-1 rounded">✏️ Bearbeiten</span>
</div>
</div>
<div class="step p-4 space-y-2">
<div class="text-sm font-semibold text-yellow-400">Bearbeiten im Bot</div>
<p class="text-xs text-slate-400">✏️ drücken → Bot zeigt aktuellen Text → einfach neue Version als nächste Nachricht schicken → Bot bestätigt + zeigt erneut Review-Buttons.</p>
</div>
</div>
<div class="step p-4">
<div class="text-sm font-semibold text-slate-300 mb-2">Bot-Befehle</div>
<div class="grid grid-cols-2 gap-2 text-xs">
<div><code>/start</code> <span class="text-slate-400 ml-2">Übersicht &amp; Hilfe</span></div>
<div><code>/heute</code> <span class="text-slate-400 ml-2">Alle Slots von heute</span></div>
<div><code>/queue</code> <span class="text-slate-400 ml-2">Nächste 3 Tage</span></div>
<div><code>/skip</code> <span class="text-slate-400 ml-2">Hauptslot heute überspringen</span></div>
</div>
</div>
<div class="step p-4 space-y-2">
<div class="text-sm font-semibold text-blue-400">☀️ Morgen-Briefing (10:00 Uhr MEZ)</div>
<p class="text-xs text-slate-400">Täglich um 10:00 Uhr schickt der Bot automatisch einen Überblick:</p>
<ul class="text-xs text-slate-400 space-y-1 mt-1 ml-3">
<li>• Welche Slots heute geplant sind (mit Status)</li>
<li>• Ob noch etwas fehlt oder freigegeben werden muss</li>
<li>• Ausblick auf die nächsten 3 Tage</li>
</ul>
</div>
</div>
</details>
<!-- 4. Automatisches Posting -->
<details class="card">
<summary class="p-5 flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="text-xl">📤</span>
<h2 class="text-base font-semibold text-white">4. Automatisches Posting</h2>
</div>
<span class="chevron text-slate-400 text-sm"></span>
</summary>
<div class="px-5 pb-5 space-y-4 border-t border-slate-700 pt-4">
<div class="step p-4 space-y-2">
<div class="text-sm font-semibold text-slate-300">Ablauf</div>
<p class="text-xs text-slate-400">Der Scheduler prüft jede Minute: gibt es einen <span class="text-green-400">freigegebenen</span> Artikel dessen Uhrzeit jetzt fällig ist?</p>
<ul class="text-xs text-slate-400 space-y-1 mt-2 ml-3">
<li><span class="text-green-400">Freigegeben + Uhrzeit erreicht</span> → wird in den Kanal gepostet</li>
<li><span class="text-yellow-400">Nicht freigegeben</span> → Nachmittags-Reminder (18:00 Uhr)</li>
<li><span class="text-red-400">Fehler beim Posting</span> → sofortiger Fehler-Alarm an alle Redakteure</li>
</ul>
</div>
<div class="grid grid-cols-3 gap-3 text-center text-xs">
<div class="step p-3">
<div class="text-lg mb-1"></div>
<div class="text-slate-300 font-semibold">Freigegeben</div>
<div class="text-slate-500 mt-1">Postet automatisch</div>
</div>
<div class="step p-3">
<div class="text-lg mb-1">⚠️</div>
<div class="text-slate-300 font-semibold">Kein Artikel</div>
<div class="text-slate-500 mt-1">Alarm + überspringen</div>
</div>
<div class="step p-3">
<div class="text-lg mb-1"></div>
<div class="text-slate-300 font-semibold">Posting-Fehler</div>
<div class="text-slate-500 mt-1">Sofort-Alarm mit Ursache</div>
</div>
</div>
<div class="bg-slate-700/30 rounded-lg p-3 text-xs text-slate-400">
Das <span class="text-white">Markenzeichen</span> wird automatisch unter jeden Beitrag gesetzt — auch wenn es im Editor noch nicht sichtbar ist.
</div>
</div>
</details>
<!-- 5. Board verwalten -->
<details class="card">
<summary class="p-5 flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="text-xl">🗂️</span>
<h2 class="text-base font-semibold text-white">5. Redaktionsplan verwalten</h2>
<span class="badge tag-new">NEU</span>
</div>
<span class="chevron text-slate-400 text-sm"></span>
</summary>
<div class="px-5 pb-5 space-y-4 border-t border-slate-700 pt-4">
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
<div class="step p-4 space-y-2">
<div class="text-sm font-semibold text-blue-400">📅 Tag anklicken</div>
<p class="text-xs text-slate-400">Klick auf einen Tag im Redaktionsplan lädt den Artikel direkt ins Studio — ohne neu generieren.</p>
</div>
<div class="step p-4 space-y-2">
<div class="text-sm font-semibold text-yellow-400">🔄 Umplanen</div>
<p class="text-xs text-slate-400">Direkt im Board: neues Datum oder Uhrzeit wählen. Bei Zeitkonflikt wird geblockt und ein freier Slot vorgeschlagen.</p>
</div>
<div class="step p-4 space-y-2">
<div class="text-sm font-semibold text-red-400">🗑️ Löschen</div>
<p class="text-xs text-slate-400">Artikel aus einem Slot entfernen — mit Sicherheitsabfrage. Slot wird danach wieder als frei angezeigt.</p>
</div>
</div>
<div class="step p-4">
<div class="text-sm font-semibold text-slate-300 mb-2">Status-Übersicht</div>
<div class="grid grid-cols-3 md:grid-cols-6 gap-2 text-xs text-center">
<div><span class="text-slate-400 text-base">📝</span><div class="text-slate-500 mt-1">Entwurf</div></div>
<div><span class="text-purple-400 text-base">🗓️</span><div class="text-slate-500 mt-1">Eingeplant</div></div>
<div><span class="text-blue-400 text-base">📱</span><div class="text-slate-500 mt-1">Beim Bot</div></div>
<div><span class="text-green-400 text-base"></span><div class="text-slate-500 mt-1">Freigegeben</div></div>
<div><span class="text-sky-400 text-base">📤</span><div class="text-slate-500 mt-1">Gepostet</div></div>
<div><span class="text-slate-600 text-base">⏭️</span><div class="text-slate-500 mt-1">Skip</div></div>
</div>
</div>
</div>
</details>
<!-- 6. Einstellungen -->
<details class="card">
<summary class="p-5 flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="text-xl">⚙️</span>
<h2 class="text-base font-semibold text-white">6. Einstellungen</h2>
</div>
<span class="chevron text-slate-400 text-sm"></span>
</summary>
<div class="px-5 pb-5 space-y-4 border-t border-slate-700 pt-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="step p-4 space-y-2">
<div class="text-sm font-semibold text-slate-300">📢 Telegram Kanal</div>
<p class="text-xs text-slate-400">Kanal-ID oder <code>@username</code> des Ziel-Kanals eintragen. Der Bot muss Admin im Kanal sein.</p>
</div>
<div class="step p-4 space-y-2">
<div class="text-sm font-semibold text-slate-300">⏰ Standard-Posting-Zeit</div>
<p class="text-xs text-slate-400">Default-Uhrzeit für neue Artikel. Kann pro Artikel beim Einplanen überschrieben werden.</p>
</div>
<div class="step p-4 space-y-2">
<div class="text-sm font-semibold text-slate-300">👥 Redakteure</div>
<p class="text-xs text-slate-400">Neue Redakteure per Chat-ID hinzufügen. Beim Hinzufügen erhält der neue Redakteur automatisch eine Willkommensnachricht. Chat-ID herausfinden: <code>@userinfobot</code> in Telegram.</p>
</div>
<div class="step p-4 space-y-2">
<div class="text-sm font-semibold text-slate-300">📌 Quellen-Favoriten</div>
<p class="text-xs text-slate-400">Häufig genutzte URLs speichern — erscheinen im Studio als Dropdown für schnellen Zugriff.</p>
</div>
<div class="step p-4 space-y-2">
<div class="text-sm font-semibold text-slate-300">🌍 Aufenthaltsort</div>
<p class="text-xs text-slate-400">Aktuellen Standort einstellen. Die Reminder-Zeiten werden automatisch auf MEZ umgerechnet.</p>
</div>
<div class="step p-4 space-y-2">
<div class="text-sm font-semibold text-slate-300">🧠 Prompts</div>
<p class="text-xs text-slate-400">KI-Prompts erstellen, bearbeiten und mit einer Testquelle ausprobieren. Default-Prompt für neue Artikel festlegen.</p>
</div>
</div>
</div>
</details>
<!-- FAQ -->
<details class="card">
<summary class="p-5 flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="text-xl"></span>
<h2 class="text-base font-semibold text-white">Häufige Fragen</h2>
</div>
<span class="chevron text-slate-400 text-sm"></span>
</summary>
<div class="px-5 pb-5 space-y-3 border-t border-slate-700 pt-4">
<div class="step p-4">
<div class="text-sm font-semibold text-slate-300 mb-1">Was passiert wenn ich vergesse freizugeben?</div>
<p class="text-xs text-slate-400">Um 18:00 Uhr kommt ein Reminder. Falls bis zur Posting-Zeit kein freigegebener Artikel vorhanden ist, wird der Slot übersprungen und ein Alarm gesendet.</p>
</div>
<div class="step p-4">
<div class="text-sm font-semibold text-slate-300 mb-1">Kann ich einen bereits geposteten Artikel bearbeiten?</div>
<p class="text-xs text-slate-400">Im Dashboard nicht rückwirkend — aber in Telegram kannst du die Nachricht direkt bearbeiten (Telegram-Editier-Funktion).</p>
</div>
<div class="step p-4">
<div class="text-sm font-semibold text-slate-300 mb-1">Wo finde ich die Chat-ID für einen neuen Redakteur?</div>
<p class="text-xs text-slate-400">In Telegram <code>@userinfobot</code> anschreiben → gibt die eigene Chat-ID zurück. Oder die Person schreibt dem <code>@Diendemleben_bot</code> — die ID erscheint dann im Bot-Log.</p>
</div>
<div class="step p-4">
<div class="text-sm font-semibold text-slate-300 mb-1">Wie sehe ich ob der Bot läuft?</div>
<p class="text-xs text-slate-400">Im Dashboard-Header: letzter Post-Zeitstempel. Im Bot: <code>/start</code> senden — Antwort bedeutet Bot ist aktiv. Auf dem Server: <code>docker ps</code> in CT 112.</p>
</div>
<div class="step p-4">
<div class="text-sm font-semibold text-slate-300 mb-1">Kann ich mehrere Artikel pro Tag planen?</div>
<p class="text-xs text-slate-400">Ja — jeden Zeitslot (15-Min-Raster) einmal belegen. Jeder Slot wird unabhängig gepostet. Doppelt belegte Slots werden automatisch blockiert.</p>
</div>
</div>
</details>
<div class="text-center text-xs text-slate-600 pt-4 pb-8">
FünfVorAcht · CT 112 auf pve-hetzner · Dashboard: <a href="https://fuenfvoracht.orbitalo.net" class="text-blue-400 hover:underline">fuenfvoracht.orbitalo.net</a>
</div>
</div>
<script>
// Alle details initial geschlossen außer erstem
document.querySelectorAll('details').forEach((d, i) => {
if (i > 0) d.open = false;
});
</script>
</body>
</html>

View file

@ -0,0 +1,69 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>History — FünfVorAcht</title>
<link rel="stylesheet" href="/static/tailwind.min.css">
<style>
body { background: #0f172a; color: #e2e8f0; }
.card { background: #1e293b; border: 1px solid #334155; border-radius: 12px; }
.tg-preview { background: #212d3b; border-left: 3px solid #3b82f6; font-family: system-ui; white-space: pre-wrap; line-height: 1.6; }
</style>
</head>
<body class="min-h-screen">
<nav class="bg-slate-900 border-b border-slate-700 px-6 py-4 flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="text-2xl">🕗</span>
<span class="text-xl font-bold text-white">FünfVorAcht</span>
</div>
<div class="flex gap-4 text-sm">
<a href="/" class="text-slate-400 hover:text-white">Studio</a>
<a href="/history" class="text-blue-400 font-semibold">History</a>
<a href="/prompts" class="text-slate-400 hover:text-white">Prompts</a>
<a href="/settings" class="text-slate-400 hover:text-white">Einstellungen</a>
<a href="/hilfe" class="text-slate-400 hover:text-white">❓ Hilfe</a>
</div>
</nav>
<div class="max-w-4xl mx-auto px-6 py-8">
<h1 class="text-2xl font-bold text-white mb-6">📋 Artikel-History</h1>
<div class="space-y-4">
{% for art in articles %}
<div class="card p-4">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-3">
<span class="font-semibold text-white">{{ art.date }}</span>
<span class="text-xs px-2 py-0.5 rounded-full
{% if art.status == 'posted' %}bg-blue-900 text-blue-300
{% elif art.status == 'approved' %}bg-green-900 text-green-300
{% elif art.status == 'pending_review' %}bg-yellow-900 text-yellow-300
{% else %}bg-slate-700 text-slate-400{% endif %}">
{{ {'posted': '📤 Gepostet', 'approved': '✅ Freigegeben',
'pending_review': '⏳ Offen', 'skipped': '⏭️ Skip'}.get(art.status, art.status) }}
</span>
<span class="text-xs text-slate-500">v{{ art.version }}</span>
</div>
<button onclick="toggleArticle({{ art.id }})"
class="text-xs text-blue-400 hover:underline">anzeigen</button>
</div>
<div class="text-xs text-slate-500 mb-2">
Quelle: {{ art.source_input[:80] if art.source_input else '—' }}
</div>
<div id="art-{{ art.id }}" class="hidden">
<div class="tg-preview rounded-lg p-4 text-sm text-slate-200 mt-2">{{ art.content_final or '—' }}</div>
</div>
</div>
{% else %}
<div class="text-slate-400">Noch keine Artikel vorhanden.</div>
{% endfor %}
</div>
</div>
<script>
function toggleArticle(id) {
const el = document.getElementById('art-' + id);
el.classList.toggle('hidden');
}
</script>
</body>
</html>

View file

@ -0,0 +1,735 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🕗 FünfVorAcht Dashboard</title>
<link rel="stylesheet" href="/static/tailwind.min.css">
<style>
body { background: #0f172a; color: #e2e8f0; }
.card { background: #1e293b; border: 1px solid #334155; border-radius: 12px; }
.status-draft { background: #1e293b22; border-color: #475569; color: #94a3b8; }
.status-pending { background: #92400e22; border-color: #d97706; color: #fbbf24; }
.status-sent { background: #4c1d9522; border-color: #7c3aed; color: #c4b5fd; }
.status-approved { background: #14532d22; border-color: #16a34a; color: #4ade80; }
.status-posted { background: #1e3a5f22; border-color: #3b82f6; color: #60a5fa; }
.status-skipped { background: #1e293b; border-color: #475569; color: #64748b; }
.tg-preview { background: #17212b; border-left: 3px solid #3b82f6; font-family: -apple-system,system-ui,sans-serif; white-space: pre-wrap; line-height: 1.7; }
textarea { background: #0f172a; border: 1px solid #334155; color: #e2e8f0; border-radius: 8px; resize: vertical; font-family: inherit; line-height: 1.6; }
textarea:focus { outline: none; border-color: #3b82f6; box-shadow: 0 0 0 2px #3b82f620; }
input[type=text], input[type=time], select {
background: #0f172a; border: 1px solid #334155; color: #e2e8f0;
border-radius: 8px; padding: 0.5rem 0.75rem; font-size: 0.875rem;
}
input:focus, select:focus { outline: none; border-color: #3b82f6; }
.btn { border-radius: 8px; font-size: 0.875rem; font-weight: 500; padding: 0.5rem 1.25rem; transition: all .15s; cursor: pointer; }
.btn-primary { background: #2563eb; color: #fff; }
.btn-primary:hover { background: #1d4ed8; }
.btn-success { background: #15803d; color: #fff; }
.btn-success:hover { background: #166534; }
.btn-purple { background: #6d28d9; color: #fff; }
.btn-purple:hover { background: #5b21b6; }
.btn-ghost { background: #334155; color: #cbd5e1; }
.btn-ghost:hover { background: #475569; }
.btn-danger { background: #991b1b; color: #fff; }
.btn-danger:hover { background: #7f1d1d; }
.spinner { animation: spin 1s linear infinite; display: inline-block; }
@keyframes spin { to { transform: rotate(360deg); } }
.countdown { font-variant-numeric: tabular-nums; }
</style>
</head>
<body class="min-h-screen">
<!-- Nav -->
<nav class="bg-slate-900 border-b border-slate-700 px-6 py-3 flex items-center justify-between sticky top-0 z-50">
<div class="flex items-center gap-3">
<span class="text-xl">🕗</span>
<span class="text-lg font-bold text-white">FünfVorAcht</span>
</div>
<div class="flex items-center gap-5 text-sm">
<a href="/" class="text-blue-400 font-semibold">Studio</a>
<a href="/history" class="text-slate-400 hover:text-white">History</a>
<a href="/prompts" class="text-slate-400 hover:text-white">Prompts</a>
<a href="/settings" class="text-slate-400 hover:text-white">Einstellungen</a>
<a href="/hilfe" class="text-slate-400 hover:text-white">❓ Hilfe</a>
<!-- Aufenthaltsort-Schalter -->
<div class="relative">
<button onclick="toggleLocationMenu()"
class="flex items-center gap-2 bg-slate-800 hover:bg-slate-700 border border-slate-600 px-3 py-1.5 rounded-lg text-sm transition"
id="location-btn">
<span id="location-flag">{{ current_location.flag if current_location else '🌍' }}</span>
<span id="location-name" class="text-slate-200">{{ current_location.name if current_location else 'Ort wählen' }}</span>
<span class="text-slate-500"></span>
</button>
<div id="location-menu" class="hidden absolute right-0 top-10 bg-slate-800 border border-slate-600 rounded-xl shadow-xl z-50 w-52 py-1 overflow-hidden">
{% for loc in locations %}
<button onclick="setLocation({{ loc.id }}, '{{ loc.flag }}', '{{ loc.name }}')"
class="w-full text-left px-4 py-2.5 hover:bg-slate-700 flex items-center gap-3 text-sm
{% if current_location and current_location.id == loc.id %}bg-slate-700 text-blue-400{% else %}text-slate-200{% endif %}">
<span class="text-base">{{ loc.flag }}</span>
<div>
<div class="font-medium">{{ loc.name }}</div>
<div class="text-xs text-slate-400">{{ loc.timezone }}</div>
</div>
{% if current_location and current_location.id == loc.id %}
<span class="ml-auto text-blue-400"></span>
{% endif %}
</button>
{% endfor %}
</div>
</div>
<!-- Live-Uhr + Countdown -->
<div class="text-right">
<div class="flex items-center gap-2 justify-end">
<span class="text-white font-mono text-sm" id="live-clock-berlin">--:--:--</span>
<span class="text-xs text-slate-500">🇩🇪</span>
</div>
<div id="local-clock-row" class="flex items-center gap-2 justify-end" style="display:none">
<span class="text-slate-400 font-mono text-xs" id="live-clock-local">--:--:--</span>
<span class="text-xs text-slate-600" id="local-flag"></span>
</div>
<div class="text-xs text-green-400 countdown" id="countdown">--</div>
</div>
</div>
</nav>
<div class="max-w-6xl mx-auto px-6 py-6 space-y-6">
<!-- ═══ ARTIKEL-STUDIO ═══ -->
<div class="card p-6">
<div class="flex items-center justify-between mb-5">
<h2 class="text-lg font-bold text-white">✏️ Artikel-Studio — <span id="studio-date" class="text-blue-400">{{ today }}</span></h2>
<div class="flex items-center gap-3">
{% if article_today %}
<span id="status-badge" class="border text-xs px-3 py-1 rounded-full
{% if article_today.status == 'draft' %}status-draft
{% elif article_today.status == 'sent_to_bot' %}status-sent
{% elif article_today.status == 'approved' %}status-approved
{% elif article_today.status == 'posted' %}status-posted
{% elif article_today.status == 'skipped' %}status-skipped
{% else %}status-pending{% endif %}">
{{ {'draft':'📝 Entwurf','sent_to_bot':'📱 Beim Bot','approved':'✅ Freigegeben','posted':'📤 Gepostet','skipped':'⏭️ Übersprungen','pending_review':'⏳ Offen'}.get(article_today.status, article_today.status) }}
</span>
{% else %}
<span id="status-badge" class="border text-xs px-3 py-1 rounded-full status-draft">📝 Neu</span>
{% endif %}
<button onclick="clearStudio()" class="text-slate-500 hover:text-white text-xs px-2 py-1 rounded hover:bg-slate-700 transition" title="Editor leeren">✕ Leeren</button>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Linke Spalte: Eingabe + Editor -->
<div class="space-y-4">
<!-- Quelle -->
<div>
<label class="text-xs font-semibold text-slate-400 uppercase tracking-wide mb-1 block">Quelle / Inhaltswunsch</label>
<div class="flex gap-2">
<input type="text" id="source-input"
class="flex-1"
placeholder="URL einfügen oder Thema beschreiben…"
value="{{ article_today.source_input if article_today and article_today.source_input else '' }}">
<!-- Favoriten-Dropdown -->
<select id="fav-select" class="w-40" onchange="useFavorite(this)">
<option value="">📌 Favoriten</option>
{% for f in favorites %}
<option value="{{ f.url }}">{{ f.label }}</option>
{% endfor %}
</select>
</div>
</div>
<!-- Tag + Prompt -->
<div class="flex gap-3">
<div class="flex-1">
<label class="text-xs font-semibold text-slate-400 uppercase tracking-wide mb-1 block">Thema</label>
<select id="tag-select" class="w-full">
{% for t in tags %}
<option value="{{ t.name }}" {% if article_today and article_today.tag == t.name %}selected{% endif %}>{{ t.name }}</option>
{% endfor %}
</select>
</div>
<div class="flex-1">
<label class="text-xs font-semibold text-slate-400 uppercase tracking-wide mb-1 block">Prompt</label>
<select id="prompt-select" class="w-full">
{% for p in prompts %}
<option value="{{ p.id }}" {% if p.is_default %}selected{% endif %}>{{ p.name }}</option>
{% endfor %}
</select>
</div>
</div>
<!-- Generieren -->
<button onclick="generateArticle()" id="gen-btn" class="btn btn-primary w-full py-2.5">
⚡ Artikel generieren
</button>
<!-- Editor -->
<div>
<div class="flex items-center justify-between mb-1">
<label class="text-xs font-semibold text-slate-400 uppercase tracking-wide">Artikel bearbeiten</label>
<span id="char-count" class="text-xs text-slate-500"></span>
</div>
<textarea id="article-editor" rows="14"
class="w-full px-3 py-2.5 text-sm"
placeholder="Hier erscheint der generierte Artikel — direkt bearbeitbar…"
oninput="updatePreview()">{{ article_today.content_final if article_today and article_today.content_final else '' }}</textarea>
</div>
<!-- Aktions-Buttons -->
<div class="flex gap-2 flex-wrap">
<button onclick="saveArticle()" class="btn btn-ghost">💾 Speichern</button>
<button onclick="regenerate()" class="btn btn-ghost">🔄 Neu generieren</button>
<button onclick="sendToBot()" id="send-btn" class="btn btn-purple flex-1">
📱 Zum Bot senden
</button>
<button onclick="skipToday()" class="btn btn-danger">⏭️ Überspringen</button>
</div>
</div>
<!-- Rechte Spalte: Telegram-Vorschau + Timer -->
<div class="space-y-4">
<!-- Telegram-Vorschau -->
<div>
<div class="flex items-center justify-between mb-1">
<label class="text-xs font-semibold text-slate-400 uppercase tracking-wide">📱 Telegram-Vorschau</label>
<span id="tg-char" class="text-xs text-slate-500"></span>
</div>
<div class="tg-preview rounded-xl p-4 text-sm min-h-[200px]" id="tg-preview-box">
<span class="text-slate-500 italic">Vorschau erscheint beim Bearbeiten…</span>
</div>
</div>
<!-- Posting-Timer kompakt -->
<div class="card p-4 border border-slate-600">
<div class="flex items-center justify-between mb-2">
<h3 class="text-white font-semibold text-sm">⏰ Posting</h3>
<div class="flex items-center gap-2">
<input type="time" id="post-time-input" value="{{ channel.post_time or '19:55' }}" class="w-28 text-xs py-1">
<span class="text-xs text-slate-500">🇩🇪</span>
<button onclick="savePostTime()" class="text-xs text-slate-500 hover:text-white">💾</button>
<span id="save-time-ok" class="text-green-400 text-xs hidden"></span>
</div>
</div>
<div class="text-xs text-slate-400">
<span class="text-white font-semibold" id="next-post-time">{{ channel.post_time or '19:55' }} Uhr 🇩🇪</span>
<span id="next-post-local" class="text-slate-500 ml-2"></span>
</div>
<div id="studio-status-line" class="mt-1 text-xs text-slate-500"></div>
</div>
</div>
</div>
</div>
<!-- ═══ REDAKTIONSPLAN ═══ -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Planungsliste: Nächste 7 Tage -->
<div class="card p-5 lg:col-span-2">
<div class="flex items-center justify-between mb-4">
<h2 class="text-base font-semibold text-white">📅 Redaktionsplan — Nächste 7 Tage</h2>
<span class="text-xs text-slate-500">Posting: {{ channel.post_time or '19:55' }} Uhr 🇩🇪</span>
</div>
<div class="space-y-0">
{% set wday_names = {'0':'Mo','1':'Di','2':'Mi','3':'Do','4':'Fr','5':'Sa','6':'So'} %}
{% for d in plan_days %}
{% set art = plan_articles.get(d) %}
{% set is_today = (d == today) %}
<div onclick="loadDate('{{ d }}')"
class="flex items-center gap-4 py-3 px-3 -mx-3 rounded-lg cursor-pointer transition
hover:bg-slate-700/40
{% if is_today %}bg-blue-900/20 border border-blue-800/50{% else %}border-b border-slate-700/30{% endif %}"
id="plan-row-{{ d }}">
<!-- Wochentag + Datum -->
<div class="w-24 shrink-0">
<div class="text-sm font-semibold {% if is_today %}text-blue-400{% else %}text-white{% endif %}">
{% set d_date = d.split('-') %}
{% set weekday_idx = (d_date[0]|int, d_date[1]|int, d_date[2]|int) %}
{{ d[8:] }}.{{ d[5:7] }}.
</div>
<div class="text-xs text-slate-500">
{% if is_today %}Heute{% else %}{{ d }}{% endif %}
</div>
</div>
<!-- Uhrzeit -->
<div class="w-16 shrink-0 text-xs text-slate-400 font-mono">
{{ channel.post_time or '19:55' }}
</div>
<!-- Status-Icon -->
<div class="w-8 shrink-0 text-center text-base">
{% if art %}
{% if art.status == 'posted' %}📤
{% elif art.status == 'approved' %}✅
{% elif art.status == 'sent_to_bot' %}📱
{% elif art.status in ('pending_review','draft') %}📝
{% else %}⏭️{% endif %}
{% else %}<span class="text-slate-600"></span>{% endif %}
</div>
<!-- Inhalt-Preview -->
<div class="flex-1 min-w-0">
{% if art and art.content_final %}
<div class="text-sm text-slate-300 truncate">{{ art.content_final[:80] }}</div>
<div class="text-xs text-slate-500 mt-0.5">v{{ art.version }} · {{ art.tag or '' }}</div>
{% else %}
<div class="text-sm text-slate-600 italic">Kein Artikel geplant</div>
{% endif %}
</div>
<!-- Status-Badge -->
<div class="shrink-0">
{% if art %}
<span class="text-xs px-2 py-0.5 rounded-full border
{% if art.status == 'posted' %}status-posted
{% elif art.status == 'approved' %}status-approved
{% elif art.status == 'sent_to_bot' %}status-sent
{% elif art.status in ('draft','pending_review') %}status-pending
{% else %}status-skipped{% endif %}">
{{ {'draft':'Entwurf','sent_to_bot':'Beim Bot','approved':'Freigegeben','posted':'Gepostet','skipped':'Skip','pending_review':'Offen'}.get(art.status, art.status) }}
</span>
{% else %}
<span class="text-xs text-slate-600">leer</span>
{% endif %}
</div>
</div>
{% endfor %}
</div>
</div>
<!-- Monatskalender + Stats -->
<div class="space-y-4">
<!-- Mini-Monatskalender -->
<div class="card p-4">
<h3 class="text-white font-semibold mb-3 text-sm">📆 Monatsübersicht</h3>
<div class="grid grid-cols-7 gap-1 text-center text-xs mb-2">
<div class="text-slate-500 font-semibold">Mo</div>
<div class="text-slate-500 font-semibold">Di</div>
<div class="text-slate-500 font-semibold">Mi</div>
<div class="text-slate-500 font-semibold">Do</div>
<div class="text-slate-500 font-semibold">Fr</div>
<div class="text-slate-500 font-semibold">Sa</div>
<div class="text-slate-500 font-semibold">So</div>
</div>
<div id="month-grid" class="grid grid-cols-7 gap-1 text-center text-xs"></div>
<div class="flex items-center gap-4 mt-3 text-xs text-slate-500">
<span><span class="inline-block w-2 h-2 rounded-full bg-blue-500 mr-1"></span>Gepostet</span>
<span><span class="inline-block w-2 h-2 rounded-full bg-green-500 mr-1"></span>Geplant</span>
<span><span class="inline-block w-2 h-2 rounded-full bg-yellow-500 mr-1"></span>Entwurf</span>
<span><span class="inline-block w-2 h-2 rounded-full bg-slate-600 mr-1"></span>Leer</span>
</div>
</div>
<!-- Stats -->
<div class="card p-4 border border-slate-700">
<h3 class="text-slate-300 font-semibold mb-2 text-sm">📊 {{ today[:7] }}</h3>
<div class="grid grid-cols-3 gap-2 text-center">
<div>
<div class="text-2xl font-bold text-green-400">{{ stats.posted }}</div>
<div class="text-xs text-slate-400">Gepostet</div>
</div>
<div>
<div class="text-2xl font-bold text-slate-400">{{ stats.skipped }}</div>
<div class="text-xs text-slate-400">Skip</div>
</div>
<div>
<div class="text-2xl font-bold text-blue-400">{{ stats.avg_version }}×</div>
<div class="text-xs text-slate-400">Ø Regen.</div>
</div>
</div>
<div class="mt-3 pt-3 border-t border-slate-700">
<div class="flex items-center justify-between">
<span class="text-xs text-slate-400">💰 OpenRouter</span>
<button onclick="loadBalance()" class="text-xs text-slate-500 hover:text-white">🔄</button>
</div>
<div id="balance-inline" class="text-sm font-semibold text-green-400 mt-1">laden…</div>
</div>
</div>
<!-- Letzte Posts kompakt -->
<div class="card p-4">
<div class="flex items-center justify-between mb-2">
<h3 class="text-slate-300 font-semibold text-sm">📋 Letzte Posts</h3>
<a href="/history" class="text-blue-400 text-xs hover:underline">Alle →</a>
</div>
{% for art in recent[:4] %}
<div class="flex items-center gap-2 py-1.5 border-b border-slate-700/30 last:border-0 cursor-pointer hover:bg-slate-700/30 rounded px-1 -mx-1"
onclick="loadDate('{{ art.date }}')">
<span class="text-xs text-slate-500 w-16 shrink-0">{{ art.date[5:] }}</span>
<span class="text-xs text-slate-400 flex-1 truncate">{{ art.content_final[:50] if art.content_final else '—' }}</span>
<span class="text-xs">{{ {'posted':'📤','approved':'✅','sent_to_bot':'📱','draft':'📝','skipped':'⏭️'}.get(art.status,'?') }}</span>
</div>
{% else %}
<div class="text-slate-500 text-xs py-2">Keine Artikel.</div>
{% endfor %}
</div>
</div>
</div>
</div>
<script>
const TODAY = "{{ today }}";
let selectedDate = TODAY;
let userTimezone = "{{ current_location.timezone if current_location else 'Europe/Berlin' }}";
let userFlag = "{{ current_location.flag if current_location else '🇩🇪' }}";
const BERLIN_TZ = "Europe/Berlin";
const MONTH_ARTICLES = {{ month_articles | tojson }};
function getBerlinTime(date) {
return new Date(date.toLocaleString('en-US', {timeZone: BERLIN_TZ}));
}
function formatTime(date, tz) {
return date.toLocaleTimeString('de-DE', {hour:'2-digit', minute:'2-digit', second:'2-digit', timeZone: tz});
}
function formatTimeShort(date, tz) {
return date.toLocaleTimeString('de-DE', {hour:'2-digit', minute:'2-digit', timeZone: tz});
}
function updateClock() {
const now = new Date();
document.getElementById('live-clock-berlin').textContent = formatTime(now, BERLIN_TZ);
const isLocal = userTimezone !== BERLIN_TZ;
const localRow = document.getElementById('local-clock-row');
if (isLocal) {
localRow.style.display = 'flex';
document.getElementById('live-clock-local').textContent = formatTime(now, userTimezone);
document.getElementById('local-flag').textContent = userFlag;
} else {
localRow.style.display = 'none';
}
const postTime = document.getElementById('post-time-input').value || '19:55';
const [ph, pm] = postTime.split(':').map(Number);
const berlinNow = getBerlinTime(now);
const berlinPost = new Date(berlinNow);
berlinPost.setHours(ph, pm, 0, 0);
if (berlinPost <= berlinNow) berlinPost.setDate(berlinPost.getDate() + 1);
const diff = Math.floor((berlinPost - berlinNow) / 1000);
const h = Math.floor(diff / 3600);
const m = Math.floor((diff % 3600) / 60);
const s = diff % 60;
document.getElementById('countdown').textContent =
`Posting in ${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
updateLocalPostTime();
}
function updateLocalPostTime() {
const el = document.getElementById('next-post-local');
if (userTimezone === BERLIN_TZ) {
el.textContent = '';
return;
}
const postTime = document.getElementById('post-time-input').value || '19:55';
const [ph, pm] = postTime.split(':').map(Number);
const now = new Date();
const berlinStr = now.toLocaleDateString('en-US', {timeZone: BERLIN_TZ});
const berlinDate = new Date(berlinStr);
berlinDate.setHours(ph, pm, 0, 0);
const utcOffset = berlinDate.getTime() - new Date(berlinDate.toLocaleString('en-US', {timeZone: BERLIN_TZ})).getTime();
const postUTC = new Date(berlinDate.getTime() + utcOffset);
const localTimeStr = postUTC.toLocaleTimeString('de-DE', {hour:'2-digit', minute:'2-digit', timeZone: userTimezone});
el.textContent = `= ${localTimeStr} Uhr ${userFlag}`;
}
setInterval(updateClock, 1000);
updateClock();
// ── Telegram-Vorschau rendern ──────────────────────────────────────────────
function renderTelegram(raw) {
if (!raw) return '<span class="text-slate-500 italic">Vorschau erscheint beim Bearbeiten…</span>';
let html = raw
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/&lt;b&gt;([\s\S]*?)&lt;\/b&gt;/g, '<strong>$1</strong>')
.replace(/&lt;i&gt;([\s\S]*?)&lt;\/i&gt;/g, '<em class="text-slate-300">$1</em>')
.replace(/&lt;a href="(.*?)"&gt;(.*?)&lt;\/a&gt;/g, '<a href="$1" class="text-blue-400 underline">$2</a>')
.replace(/\n/g, '<br>');
return html;
}
function updatePreview() {
const text = document.getElementById('article-editor').value;
const len = text.length;
document.getElementById('tg-preview-box').innerHTML = renderTelegram(text);
document.getElementById('char-count').textContent = `${len} Zeichen`;
document.getElementById('tg-char').textContent = `${len}/4096 ${len > 4096 ? '⚠️' : '✓'}`;
}
// Init Preview wenn Artikel vorhanden
const initText = document.getElementById('article-editor').value;
if (initText) updatePreview();
// ── Favorit verwenden ──────────────────────────────────────────────────────
function useFavorite(sel) {
if (sel.value) {
document.getElementById('source-input').value = sel.value;
sel.value = '';
}
}
// ── Editor leeren ─────────────────────────────────────────────────────────
function clearStudio() {
selectedDate = TODAY;
document.getElementById('studio-date').textContent = TODAY;
document.getElementById('source-input').value = '';
document.getElementById('article-editor').value = '';
document.getElementById('tg-preview-box').innerHTML = '<span class="text-slate-500 italic">Vorschau erscheint beim Bearbeiten…</span>';
document.getElementById('char-count').textContent = '';
document.getElementById('tg-char').textContent = '';
updateStatusBadge('draft', '📝 Neu');
document.querySelectorAll('[id^="plan-row-"]').forEach(el => {
el.classList.remove('ring-2', 'ring-yellow-500');
});
}
// ── Datum wechseln (Redaktionsplan klick) ─────────────────────────────────
async function loadDate(dateStr) {
selectedDate = dateStr;
document.getElementById('studio-date').textContent = dateStr;
document.querySelectorAll('[id^="plan-row-"]').forEach(el => {
el.classList.remove('ring-2', 'ring-yellow-500');
});
const row = document.getElementById('plan-row-' + dateStr);
if (row) row.classList.add('ring-2', 'ring-yellow-500');
try {
const r = await fetch('/api/article/' + dateStr);
if (r.ok) {
const art = await r.json();
document.getElementById('source-input').value = art.source_input || '';
document.getElementById('article-editor').value = art.content_final || '';
const statusMap = {draft:'📝 Entwurf',sent_to_bot:'📱 Beim Bot',approved:'✅ Freigegeben',posted:'📤 Gepostet',skipped:'⏭️ Übersprungen',pending_review:'⏳ Offen'};
updateStatusBadge(art.status, statusMap[art.status] || art.status);
updatePreview();
} else {
document.getElementById('source-input').value = '';
document.getElementById('article-editor').value = '';
updateStatusBadge('draft', '📝 Neu');
document.getElementById('tg-preview-box').innerHTML = '<span class="text-slate-500 italic">Kein Artikel für dieses Datum</span>';
document.getElementById('char-count').textContent = '';
document.getElementById('tg-char').textContent = '';
}
} catch(e) {
console.error('loadDate error:', e);
}
}
// ── Artikel generieren ─────────────────────────────────────────────────────
async function generateArticle() {
const source = document.getElementById('source-input').value.trim();
if (!source) { alert('Bitte Quelle oder Thema eingeben'); return; }
const tag = document.getElementById('tag-select').value;
const promptId = document.getElementById('prompt-select').value;
const btn = document.getElementById('gen-btn');
btn.innerHTML = '<span class="spinner">⚙️</span> Generiere…';
btn.disabled = true;
try {
const r = await fetch('/api/generate', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({source, tag, prompt_id: promptId, date: selectedDate})
});
const d = await r.json();
if (d.success) {
document.getElementById('article-editor').value = d.content;
updatePreview();
updateStatusBadge('draft', '📝 Entwurf');
} else {
alert('Fehler: ' + d.error);
}
} catch(e) {
alert('Verbindungsfehler: ' + e);
} finally {
btn.innerHTML = '⚡ Artikel generieren';
btn.disabled = false;
}
}
async function regenerate() { await generateArticle(); }
// ── Artikel speichern ──────────────────────────────────────────────────────
async function saveArticle() {
const content = document.getElementById('article-editor').value.trim();
if (!content) return;
await fetch('/api/article/' + selectedDate + '/save', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({content})
});
showToast('💾 Gespeichert (' + selectedDate + ')');
}
// ── Zum Bot senden ─────────────────────────────────────────────────────────
async function sendToBot() {
const content = document.getElementById('article-editor').value.trim();
if (!content) { alert('Bitte erst Artikel generieren oder eingeben.'); return; }
const btn = document.getElementById('send-btn');
btn.innerHTML = '<span class="spinner">📡</span> Sende…';
btn.disabled = true;
await fetch('/api/article/' + selectedDate + '/save', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({content})
});
const r = await fetch('/api/article/' + selectedDate + '/send-to-bot', {method:'POST'});
const d = await r.json();
if (d.success) {
updateStatusBadge('sent_to_bot', '📱 Beim Bot');
btn.innerHTML = '✅ Gesendet';
setTimeout(() => { btn.innerHTML = '📱 Zum Bot senden'; btn.disabled = false; }, 3000);
} else {
alert('Fehler: ' + d.error);
btn.innerHTML = '📱 Zum Bot senden';
btn.disabled = false;
}
}
// ── Überspringen ───────────────────────────────────────────────────────────
async function skipToday() {
if (!confirm(selectedDate + ' überspringen?')) return;
await fetch('/api/article/' + selectedDate + '/skip', {method:'POST'});
updateStatusBadge('skipped', '⏭️ Übersprungen');
}
// ── Posting-Zeit speichern ─────────────────────────────────────────────────
async function savePostTime() {
const t = document.getElementById('post-time-input').value;
await fetch('/api/settings/post-time', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({post_time: t})
});
document.getElementById('next-post-time').textContent = t + ' Uhr';
const ok = document.getElementById('save-time-ok');
ok.classList.remove('hidden');
setTimeout(() => ok.classList.add('hidden'), 2000);
}
// ── Status-Badge aktualisieren ─────────────────────────────────────────────
function updateStatusBadge(status, label) {
const badge = document.getElementById('status-badge');
badge.textContent = label;
badge.className = 'border text-xs px-3 py-1 rounded-full status-' +
(status === 'sent_to_bot' ? 'sent' : status === 'pending_review' ? 'pending' : status);
}
// ── Aufenthaltsort ─────────────────────────────────────────────────────────
function toggleLocationMenu() {
const menu = document.getElementById('location-menu');
menu.classList.toggle('hidden');
}
document.addEventListener('click', (e) => {
if (!e.target.closest('#location-btn') && !e.target.closest('#location-menu')) {
document.getElementById('location-menu').classList.add('hidden');
}
});
async function setLocation(id, flag, name) {
const r = await fetch('/api/settings/location', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({location_id: id})
});
const d = await r.json();
if (d.success) {
document.getElementById('location-flag').textContent = flag;
document.getElementById('location-name').textContent = name;
document.getElementById('location-menu').classList.add('hidden');
userTimezone = d.location.timezone;
userFlag = flag;
updateClock();
showToast(`📍 ${flag} ${name} — Reminder: ${d.reminders_berlin.morning} & ${d.reminders_berlin.afternoon} Uhr (Berlin)`);
}
}
function showToast(msg) {
const t = document.createElement('div');
t.className = 'fixed bottom-6 left-1/2 -translate-x-1/2 bg-slate-700 border border-slate-500 text-white text-sm px-5 py-3 rounded-xl shadow-xl z-50 transition-opacity';
t.textContent = msg;
document.body.appendChild(t);
setTimeout(() => { t.style.opacity = '0'; setTimeout(() => t.remove(), 500); }, 4000);
}
// ── OpenRouter Balance ─────────────────────────────────────────────────────
async function loadBalance() {
const el = document.getElementById('balance-inline');
try {
const r = await fetch('/api/balance');
const d = await r.json();
if (d.remaining !== null && d.remaining !== undefined) {
el.textContent = '$' + d.remaining.toFixed(4);
el.className = d.remaining < 0.5
? 'text-sm font-semibold text-red-400 mt-1'
: 'text-sm font-semibold text-green-400 mt-1';
} else if (d.limit === null) {
el.textContent = '∞ Free Tier';
el.className = 'text-sm font-semibold text-blue-400 mt-1';
} else {
el.textContent = '—';
}
} catch { el.textContent = 'Fehler'; }
}
// Balance nur auf Knopfdruck laden, nicht automatisch beim Start
// ── Monatskalender rendern ────────────────────────────────────────────────
function renderMonthCalendar() {
const grid = document.getElementById('month-grid');
if (!grid) return;
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth();
const firstDay = new Date(year, month, 1);
let startWeekday = firstDay.getDay() - 1;
if (startWeekday < 0) startWeekday = 6;
const daysInMonth = new Date(year, month + 1, 0).getDate();
const todayDate = now.getDate();
let html = '';
for (let i = 0; i < startWeekday; i++) {
html += '<div></div>';
}
for (let d = 1; d <= daysInMonth; d++) {
const dateStr = `${year}-${String(month+1).padStart(2,'0')}-${String(d).padStart(2,'0')}`;
const status = MONTH_ARTICLES[dateStr];
const isToday = d === todayDate;
let dotColor = 'bg-slate-700';
if (status === 'posted') dotColor = 'bg-blue-500';
else if (status === 'approved' || status === 'sent_to_bot') dotColor = 'bg-green-500';
else if (status === 'draft' || status === 'pending_review') dotColor = 'bg-yellow-500';
else if (status === 'skipped') dotColor = 'bg-slate-600';
html += `<div onclick="loadDate('${dateStr}')"
class="py-1 rounded cursor-pointer hover:bg-slate-600/50 transition
${isToday ? 'ring-1 ring-blue-400 font-bold text-blue-400' : 'text-slate-400'}">
${d}
<div class="w-1.5 h-1.5 rounded-full ${dotColor} mx-auto mt-0.5"></div>
</div>`;
}
grid.innerHTML = html;
}
renderMonthCalendar();
</script>
</body>
</html>

View file

@ -0,0 +1,221 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Prompts — FünfVorAcht</title>
<link rel="stylesheet" href="/static/tailwind.min.css">
<style>
body { background: #0f172a; color: #e2e8f0; }
.card { background: #1e293b; border: 1px solid #334155; border-radius: 12px; }
.tg-preview { background: #212d3b; border-left: 3px solid #3b82f6; font-family: system-ui; white-space: pre-wrap; line-height: 1.6; }
textarea { background: #0f172a; border: 1px solid #334155; color: #e2e8f0; border-radius: 8px; resize: vertical; }
textarea:focus { outline: none; border-color: #3b82f6; }
input[type=text] { background: #0f172a; border: 1px solid #334155; color: #e2e8f0; border-radius: 8px; }
input[type=text]:focus { outline: none; border-color: #3b82f6; }
</style>
</head>
<body class="min-h-screen">
<nav class="bg-slate-900 border-b border-slate-700 px-6 py-4 flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="text-2xl">🕗</span>
<span class="text-xl font-bold text-white">FünfVorAcht</span>
</div>
<div class="flex gap-4 text-sm">
<a href="/" class="text-slate-400 hover:text-white">Studio</a>
<a href="/history" class="text-slate-400 hover:text-white">History</a>
<a href="/prompts" class="text-blue-400 font-semibold">Prompts</a>
<a href="/settings" class="text-slate-400 hover:text-white">Einstellungen</a>
<a href="/hilfe" class="text-slate-400 hover:text-white">❓ Hilfe</a>
</div>
</nav>
<div class="max-w-6xl mx-auto px-6 py-8">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-white">🧠 Prompt-Bibliothek</h1>
<button onclick="showNewPrompt()"
class="bg-blue-600 hover:bg-blue-500 text-white px-4 py-2 rounded-lg text-sm transition">
+ Neuer Prompt
</button>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Prompt Liste -->
<div class="space-y-4">
{% for p in prompts %}
<div class="card p-4 cursor-pointer hover:border-blue-500 transition {% if p.is_default %}border-green-600{% endif %}"
onclick="loadPrompt({{ p.id }}, {{ p.system_prompt|tojson }}, {{ p.name|tojson }}, {{ p.test_result|tojson if p.test_result else 'null' }}, {{ p.is_default }})">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<span class="font-semibold text-white">{{ p.name }}</span>
{% if p.is_default %}
<span class="bg-green-800 text-green-300 text-xs px-2 py-0.5 rounded-full">Standard</span>
{% endif %}
</div>
{% if not p.is_default %}
<form action="/prompts/delete/{{ p.id }}" method="POST" onclick="event.stopPropagation()" onsubmit="return confirm('Prompt «{{ p.name }}» wirklich löschen?')">
<button type="submit" class="text-red-400 hover:text-red-300 text-xs px-2 py-1 rounded hover:bg-red-900/30 transition">🗑</button>
</form>
{% endif %}
</div>
<div class="text-xs text-slate-400 truncate">{{ p.system_prompt[:100] }}…</div>
{% if p.last_tested_at %}
<div class="text-xs text-slate-500 mt-1">Zuletzt getestet: {{ p.last_tested_at[:16] }}</div>
{% endif %}
</div>
{% endfor %}
</div>
<!-- Editor + Preview -->
<div class="space-y-4">
<div class="card p-4" id="editor-card">
<h2 class="text-white font-semibold mb-4" id="editor-title">Prompt auswählen</h2>
<input type="hidden" id="prompt-id">
<div class="mb-3">
<label class="text-xs text-slate-400 mb-1 block">Name</label>
<input type="text" id="prompt-name" class="w-full px-3 py-2 text-sm" placeholder="z.B. Standard">
</div>
<div class="mb-3">
<label class="text-xs text-slate-400 mb-1 block">
System-Prompt
<span class="text-slate-600 ml-2">Variablen: {source} {date} {tag}</span>
</label>
<textarea id="prompt-text" class="w-full px-3 py-2 text-sm" rows="12" placeholder="Prompt hier eingeben…"></textarea>
</div>
<div class="mb-3">
<label class="text-xs text-slate-400 mb-1 block">Test-Quelle (URL oder Text)</label>
<input type="text" id="test-source" class="w-full px-3 py-2 text-sm"
value="https://tagesschau.de" placeholder="URL oder Text zum Testen">
</div>
<div class="flex gap-2 flex-wrap">
<button onclick="testPrompt()"
class="bg-yellow-700 hover:bg-yellow-600 text-white px-4 py-2 rounded-lg text-sm transition">
🧪 Testen
</button>
<button onclick="savePrompt()"
class="bg-blue-600 hover:bg-blue-500 text-white px-4 py-2 rounded-lg text-sm transition">
💾 Speichern
</button>
<button onclick="setDefault()"
class="bg-green-700 hover:bg-green-600 text-white px-4 py-2 rounded-lg text-sm transition">
⭐ Als Standard
</button>
<button onclick="deleteCurrentPrompt()" id="btn-delete"
class="bg-red-800 hover:bg-red-700 text-white px-4 py-2 rounded-lg text-sm transition ml-auto"
style="display:none">
🗑 Löschen
</button>
</div>
</div>
<!-- Test-Preview -->
<div class="card p-4" id="preview-card" style="display:none">
<div class="flex items-center justify-between mb-3">
<h3 class="text-white font-semibold">📱 Telegram-Vorschau</h3>
<span class="text-xs text-slate-500" id="char-count"></span>
</div>
<div id="tg-preview" class="tg-preview rounded-lg p-4 text-sm text-slate-200"></div>
</div>
</div>
</div>
</div>
<script>
let currentPromptId = null;
let currentIsDefault = false;
function loadPrompt(id, text, name, testResult, isDefault) {
currentPromptId = id;
currentIsDefault = !!isDefault;
document.getElementById('prompt-id').value = id;
document.getElementById('prompt-name').value = name;
document.getElementById('prompt-text').value = text;
document.getElementById('editor-title').textContent = `Bearbeiten: ${name}`;
document.getElementById('btn-delete').style.display = isDefault ? 'none' : 'block';
if (testResult) {
showPreview(testResult);
}
}
function showNewPrompt() {
currentPromptId = null;
currentIsDefault = false;
document.getElementById('prompt-id').value = '';
document.getElementById('prompt-name').value = '';
document.getElementById('prompt-text').value = '';
document.getElementById('editor-title').textContent = 'Neuer Prompt';
document.getElementById('btn-delete').style.display = 'none';
}
async function deleteCurrentPrompt() {
if (!currentPromptId) return;
if (currentIsDefault) return alert('Standard-Prompt kann nicht gelöscht werden');
const name = document.getElementById('prompt-name').value;
if (!confirm(`Prompt «${name}» wirklich löschen?`)) return;
await fetch(`/prompts/delete/${currentPromptId}`, {method: 'POST'});
location.reload();
}
async function testPrompt() {
const system_prompt = document.getElementById('prompt-text').value;
const source = document.getElementById('test-source').value;
const btn = event.target;
btn.textContent = '⏳ Generiere...';
btn.disabled = true;
try {
const r = await fetch('/prompts/test', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({system_prompt, source, tag: 'Politik', prompt_id: currentPromptId})
});
const d = await r.json();
if (d.success) {
showPreview(d.result);
} else {
alert('Fehler: ' + d.error);
}
} catch(e) {
alert('Verbindungsfehler: ' + e);
} finally {
btn.textContent = '🧪 Testen';
btn.disabled = false;
}
}
function showPreview(text) {
const preview = document.getElementById('tg-preview');
const card = document.getElementById('preview-card');
// Render Telegram HTML tags visually
let html = text
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/&lt;b&gt;(.*?)&lt;\/b&gt;/gs, '<strong>$1</strong>')
.replace(/&lt;i&gt;(.*?)&lt;\/i&gt;/gs, '<em>$1</em>')
.replace(/&lt;a href="(.*?)"&gt;(.*?)&lt;\/a&gt;/gs, '<a href="$1" class="text-blue-400 underline">$2</a>');
preview.innerHTML = html;
document.getElementById('char-count').textContent = `${text.length} Zeichen ${text.length > 4096 ? '⚠️ ZU LANG' : '✓'}`;
card.style.display = 'block';
}
async function savePrompt() {
const form = new FormData();
form.append('id', document.getElementById('prompt-id').value);
form.append('name', document.getElementById('prompt-name').value);
form.append('system_prompt', document.getElementById('prompt-text').value);
const r = await fetch('/prompts/save', {method: 'POST', body: form});
location.reload();
}
async function setDefault() {
if (!currentPromptId) return alert('Zuerst Prompt auswählen');
const r = await fetch(`/prompts/default/${currentPromptId}`, {method: 'POST'});
location.reload();
}
</script>
</body>
</html>

View file

@ -0,0 +1,93 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Einstellungen — FünfVorAcht</title>
<link rel="stylesheet" href="/static/tailwind.min.css">
<style>
body { background: #0f172a; color: #e2e8f0; }
.card { background: #1e293b; border: 1px solid #334155; border-radius: 12px; }
input, select { background: #0f172a; border: 1px solid #334155; color: #e2e8f0; border-radius: 8px; }
input:focus, select:focus { outline: none; border-color: #3b82f6; }
</style>
</head>
<body class="min-h-screen">
<nav class="bg-slate-900 border-b border-slate-700 px-6 py-4 flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="text-2xl">🕗</span>
<span class="text-xl font-bold text-white">FünfVorAcht</span>
</div>
<div class="flex gap-4 text-sm">
<a href="/" class="text-slate-400 hover:text-white">Studio</a>
<a href="/history" class="text-slate-400 hover:text-white">History</a>
<a href="/prompts" class="text-slate-400 hover:text-white">Prompts</a>
<a href="/settings" class="text-blue-400 font-semibold">Einstellungen</a>
<a href="/hilfe" class="text-slate-400 hover:text-white">❓ Hilfe</a>
</div>
</nav>
<div class="max-w-2xl mx-auto px-6 py-8 space-y-6">
<h1 class="text-2xl font-bold text-white">⚙️ Einstellungen</h1>
<!-- Kanal -->
<div class="card p-6">
<h2 class="text-white font-semibold mb-4">📢 Telegram Kanal</h2>
<form method="POST" action="/settings/channel" class="space-y-4">
<div>
<label class="text-xs text-slate-400 block mb-1">Kanal-ID oder @username</label>
<input type="text" name="telegram_id" value="{{ channel.telegram_id or '' }}"
class="w-full px-3 py-2 text-sm" placeholder="@meinkanal oder -1001234567890">
</div>
<div>
<label class="text-xs text-slate-400 block mb-1">Posting-Uhrzeit (HH:MM)</label>
<input type="text" name="post_time" value="{{ channel.post_time or '19:55' }}"
class="w-full px-3 py-2 text-sm" placeholder="19:55">
</div>
<button type="submit"
class="bg-blue-600 hover:bg-blue-500 text-white px-4 py-2 rounded-lg text-sm transition">
💾 Speichern
</button>
</form>
</div>
<!-- Quellen-Favoriten -->
<div class="card p-6">
<h2 class="text-white font-semibold mb-4">📌 Quellen-Favoriten</h2>
{% if favorites %}
<div class="space-y-2 mb-4">
{% for f in favorites %}
<div class="flex items-center justify-between py-2 border-b border-slate-700">
<div>
<span class="text-sm text-white">{{ f.label }}</span>
<span class="text-xs text-slate-400 ml-2">{{ f.url[:50] }}{% if f.url|length > 50 %}…{% endif %}</span>
</div>
<span class="text-xs text-slate-500">{{ f.used_count }}×</span>
</div>
{% endfor %}
</div>
{% endif %}
<form method="POST" action="/settings/favorite/add" class="flex gap-2">
<input type="text" name="label" placeholder="Name" class="px-3 py-2 text-sm w-32">
<input type="text" name="url" placeholder="https://…" class="px-3 py-2 text-sm flex-1">
<button type="submit"
class="bg-slate-600 hover:bg-slate-500 text-white px-3 py-2 rounded-lg text-sm transition">
+ Hinzufügen
</button>
</form>
</div>
<!-- Tags -->
<div class="card p-6">
<h2 class="text-white font-semibold mb-3">🏷️ Themen-Tags</h2>
<div class="flex flex-wrap gap-2">
{% for tag in tags %}
<span class="bg-slate-700 text-slate-300 text-xs px-3 py-1 rounded-full">#{{ tag.name }}</span>
{% endfor %}
</div>
<div class="text-xs text-slate-500 mt-3">Tags werden im Bot beim Generieren automatisch aus dem Prompt übernommen.</div>
</div>
</div>
</body>
</html>

View file

@ -1,52 +1,56 @@
# Infrastruktur — Live State # Infrastruktur — Live State
> Auto-generiert: 2026-02-26 16:00 > Auto-generiert: 2026-02-22 06:30
## pve-hetzner Disk ## pve-hetzner Disk
| Mount | Belegt | | Mount | Belegt |
|---|---| |---|---|
| / (root) | 11% von 98G | | / (root) | 11% von 98G |
| /var/lib/vz (VMs/CTs) | 2% von 2.9T | | /var/lib/vz (VMs) | 5% von 2.9T |
## Aktive Container auf pve-hetzner ## Container auf pve-hetzner
| CT | Name | Tailscale IP | Dienste | | CT | Name | Tailscale IP | Dienste |
|---|---|---|---| |---|---|---|---|
| 101 | wordpress-v2 | 100.91.212.19 | WordPress + MySQL (Docker) | | 100 | traefik | 100.78.77.115 | Traefik, Pangolin, Gerbil, Uptime-Kuma, cloudflared (Tunnel 7bcbd550) |
| 101 | moltbot | 100.91.212.19 | @MutterbotAI_bot |
| 102 | dify | 100.113.136.30 | Dify RAG + @DifyRagBot |
| 103 | seafile | 100.75.247.60 | Seafile (seafile.orbitalo.net) | | 103 | seafile | 100.75.247.60 | Seafile (seafile.orbitalo.net) |
| 109 | rss-manager | 100.113.244.101 | RSS Manager + Matomo | | 104 | n8n | 100.125.102.93 | n8n (Workflows deaktiviert) |
| 110 | portainer | 100.109.206.43 | Portainer Docker UI | | 106 | wordpress-news | — | WordPress + MySQL (Docker), Cloudflare Tunnel ef43618e |
| 111 | forgejo | 100.89.246.60 | Forgejo Git (http://100.89.246.60:3000) | | 107 | ragflow | 100.116.125.12 | RAGFlow (in Einrichtung) |
| 109 | rss-manager | — | RSS Manager + KI |
| 110 | portainer | 100.109.206.43 | Portainer UI |
| 144 | muldenstein-backup | — | Backup-Archiv | | 144 | muldenstein-backup | — | Backup-Archiv |
| 999 | cluster-docu | 100.79.8.49 | Dokumentation (http://100.79.8.49:8080) | | 999 | cluster-docu | 100.79.8.49 | Dokumentation (/root/docs/) |
## Gelöschte Container (24.02.2026) ## Container auf pve1 Kambodscha
| CT | Name | Grund | | CT | Name | IP | Tailscale | Dienste |
|---|---|---| |---|---|---|---|---|
| 100 | traefik | Abgelöst durch Cloudflare Tunnel | | 136 | gold-silber-v3 | 192.168.0.159 | 100.72.230.87 | Dashboard V3 (blei.orbitalo.info), Telegram Bot V3, Cloudflare Tunnel |
| 102 | dify | Experiment fehlgeschlagen | | 888 | MCP-Proxmox | 192.168.0.116 | — | Proxmox MCP |
| 104 | n8n | Nicht aktiv genutzt | | 999 | cluster-docu | 192.168.0.209 | — | Doku-Mirror |
| 105 | debian-12 | Nicht genutzt | | ~~135~~ | ~~gold-silber-v2~~ | ~~192.168.0.219~~ | — | ⛔ gestoppt 2026-02-23 |
| 106 | wordpress-news | Abgelöst durch CT 101 |
| 113 | matomo | Integriert in CT 109 |
## Container auf pve1 (Kambodscha) ## Container auf pve3 Muldenstein
| CT | Name | Dienste | | CT | Name | IP | Dienste |
|---|---|---| |---|---|---|---|
| 136 | gold-silber-v3 | Edelmetall-Bot (Tailscale: 100.72.230.87) | | 143 | raspi-broker | 192.168.178.36 | InfluxDB, Grafana, ioBroker |
| 143 | smart-home | ioBroker + Grafana + InfluxDB | | ~~134~~ | ~~gold-silber-de~~ | ~~100.69.161.128~~ | ⛔ gestoppt |
## Routing
- Cloudflare Tunnel CT 101: arakava-news-2.orbitalo.net → :80
- Cloudflare Tunnel CT 109: matomo.orbitalo.net → :80
- Kein Traefik, kein PBS-Gateway mehr
## Zugangsdaten ## Zugangsdaten
- pve-hetzner: root / Astral-Proxmox!2026 - pve-hetzner: root / Astral-Proxmox!2026
- pve1: root / astral66 - pve1: root / astral66
- Alle CTs: root / astral66 - Alle lokalen CTs: root / astral66
- Seafile: admin@orbitalo.net / astral66 - Seafile: admin@orbitalo.net / astral66
- Forgejo: orbitalo / astral66 - n8n: wuttig@gmx.de / Astral66
- Dify: admin@orbitalo.net / astral66
## Telegram Bots ## Änderungen 2026-02-23
| Bot | Token (Auszug) | Chat-ID |
|---|---|---| - CT 136 (gold-silber-v3) neu auf pve1 erstellt — Edelmetall Dashboard V3
| Mutter (@MutterbotAI_bot) | 8551565940:... | 674951792 | - CT 135 gestoppt (ersetzt durch CT 136)
- CT 134 auf pve3 gestoppt (war blei.orbitalo.info, jetzt CT 136)
- CT 106 (Bot): Code auf neue Struktur /opt/edelmetall/ migriert, alter Code in /root/edelmetall_v2_archive/
- CT 100 (Traefik): edelmetall-router/service entfernt — blei.orbitalo.info läuft jetzt über Cloudflare Tunnel in CT 136
- pve1: iptables-persistent installiert (NAT-Regel temporär, danach entfernt)
## Notizen (manuell)

View file

@ -160,17 +160,10 @@ $OR_BALANCE
## Code (CT 109: /opt/rss-manager/) ## Code (CT 109: /opt/rss-manager/)
poster.py, scheduler.py, app.py, db.py poster.py, scheduler.py, app.py, db.py
## WP-Cron Konfiguration
- DISABLE_WP_CRON = true in wp-config.php (Race Conditions behoben)
- System-Cron: */5 * * * * curl -sk https://arakava-news-2.orbitalo.net/wp-cron.php?doing_wp_cron
- WordPress + Blocksy auf Deutsch (de_DE)
## Änderungshistorie ## Änderungshistorie
- 24.02.2026: Scheduler Lock gegen Doppelstarts - 24.02.2026: Scheduler Lock gegen Doppelstarts
- 24.02.2026: Telegram auf HTML-Modus (Sonderzeichen-Fix) - 24.02.2026: Telegram auf HTML-Modus (Sonderzeichen-Fix)
- 24.02.2026: Werbeartikel-Blacklist (Anzeige:, Sponsored, etc.) - 24.02.2026: Werbeartikel-Blacklist (Anzeige:, Sponsored, etc.)
- 24.02.2026: DISABLE_WP_CRON + System-Cron (Race Condition Fix)
- 24.02.2026: WordPress auf Deutsch (de_DE), Blocksy Theme DE
- 23.02.2026: Matomo von CT 113 → CT 109 migriert - 23.02.2026: Matomo von CT 113 → CT 109 migriert
- 23.02.2026: CT 100/102/104/105/106/113 gelöscht - 23.02.2026: CT 100/102/104/105/106/113 gelöscht
EOF EOF