Compare commits
2 commits
ca9ccb03d4
...
cbe681b0c4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbe681b0c4 | ||
|
|
a9ef7739be |
24 changed files with 3843 additions and 252 deletions
|
|
@ -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 |
|
||||||
|
|
|
||||||
148
MASTER_INDEX.md
148
MASTER_INDEX.md
|
|
@ -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
|
|
||||||
```
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
9
fuenfvoracht/.env
Normal 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
4
fuenfvoracht/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
.env
|
||||||
189
fuenfvoracht/STATE.md
Normal file
189
fuenfvoracht/STATE.md
Normal 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
|
||||||
36
fuenfvoracht/docker-compose.yml
Normal file
36
fuenfvoracht/docker-compose.yml
Normal 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
|
||||||
6
fuenfvoracht/src/Dockerfile.bot
Normal file
6
fuenfvoracht/src/Dockerfile.bot
Normal 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"]
|
||||||
11
fuenfvoracht/src/Dockerfile.web
Normal file
11
fuenfvoracht/src/Dockerfile.web
Normal 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
531
fuenfvoracht/src/app.py
Normal 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
402
fuenfvoracht/src/bot.py
Normal 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()
|
||||||
694
fuenfvoracht/src/database.py
Normal file
694
fuenfvoracht/src/database.py
Normal 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
100
fuenfvoracht/src/logger.py
Normal 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)
|
||||||
72
fuenfvoracht/src/openrouter.py
Normal file
72
fuenfvoracht/src/openrouter.py
Normal 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())
|
||||||
4
fuenfvoracht/src/requirements-bot.txt
Normal file
4
fuenfvoracht/src/requirements-bot.txt
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
python-telegram-bot==20.7
|
||||||
|
apscheduler==3.10.4
|
||||||
|
aiohttp==3.9.3
|
||||||
|
pytz==2024.1
|
||||||
5
fuenfvoracht/src/requirements-web.txt
Normal file
5
fuenfvoracht/src/requirements-web.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
flask==3.0.2
|
||||||
|
aiohttp==3.9.3
|
||||||
|
pytz==2024.1
|
||||||
|
gunicorn==21.2.0
|
||||||
|
requests==2.31.0
|
||||||
383
fuenfvoracht/src/templates/hilfe.html
Normal file
383
fuenfvoracht/src/templates/hilfe.html
Normal 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 & 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 & 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 & 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 & 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>
|
||||||
69
fuenfvoracht/src/templates/history.html
Normal file
69
fuenfvoracht/src/templates/history.html
Normal 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>
|
||||||
735
fuenfvoracht/src/templates/index.html
Normal file
735
fuenfvoracht/src/templates/index.html
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>')
|
||||||
|
.replace(/<b>([\s\S]*?)<\/b>/g, '<strong>$1</strong>')
|
||||||
|
.replace(/<i>([\s\S]*?)<\/i>/g, '<em class="text-slate-300">$1</em>')
|
||||||
|
.replace(/<a href="(.*?)">(.*?)<\/a>/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>
|
||||||
221
fuenfvoracht/src/templates/prompts.html
Normal file
221
fuenfvoracht/src/templates/prompts.html
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
.replace(/<b>(.*?)<\/b>/gs, '<strong>$1</strong>')
|
||||||
|
.replace(/<i>(.*?)<\/i>/gs, '<em>$1</em>')
|
||||||
|
.replace(/<a href="(.*?)">(.*?)<\/a>/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>
|
||||||
93
fuenfvoracht/src/templates/settings.html
Normal file
93
fuenfvoracht/src/templates/settings.html
Normal 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>
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue