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 |
|
||||
|-----------------------------------|------------------------------|
|
||||
| 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 |
|
||||
| Smart Home / ioBroker / Grafana | smart-home/STATE.md |
|
||||
| ESP32 / Display / Heizung | esp32/PLAN.md |
|
||||
| FünfVorAcht / Telegram KI-Poster | fuenfvoracht/STATE.md |
|
||||
| Server / Container / Proxmox | infrastructure/STATE.md |
|
||||
| Telegram Bots allgemein | infrastructure/STATE.md |
|
||||
| Alle Projekte / Übersicht | MASTER_INDEX.md |
|
||||
|
|
|
|||
148
MASTER_INDEX.md
148
MASTER_INDEX.md
|
|
@ -1,122 +1,50 @@
|
|||
# Homelab Master Index
|
||||
> Eine Seite — alles drauf. Für Details: gezielt die STATE.md des Projekts laden.
|
||||
> Letztes Update: Februar 2026
|
||||
> Einmalig lesen für Übersicht. Danach gezielt die STATE.md des betroffenen Projekts laden.
|
||||
> 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 |
|
||||
|--------|----------|----------|----------|----------|
|
||||
| **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) |
|
||||
## Server
|
||||
|
||||
> **Wichtig:** DNS für `*.orbitalo.net` zeigt auf `159.69.37.185` (PBS).
|
||||
> Traefik-Config: `/opt/config/traefik/dynamic_config.yml` auf PBS.
|
||||
| Server | Standort | Tailscale IP | Funktion |
|
||||
|---|---|---|---|
|
||||
| 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)
|
||||
|
||||
| CT | Name | Funktion | Interne IP | Tailscale IP |
|
||||
|-----|-------------------|-----------------------------------|----------------|-------------------|
|
||||
| 100 | traefik | Traefik + Pangolin + Gerbil | 10.10.10.100 | — |
|
||||
| 101 | wordpress-v2 | WordPress (alt) | 10.10.10.101 | — |
|
||||
| 103 | seafile | Seafile Fileserver | 10.10.10.103 | 100.114.178.113 |
|
||||
| 104 | n8n | n8n Workflow Automation | 10.10.10.104 | — |
|
||||
| 106 | wordpress-news | WordPress Arakava News (v3) | 10.10.10.106 | — |
|
||||
| 109 | rss-manager | RSS Manager + Research Dashboard | 10.10.10.109 | 100.113.244.101 |
|
||||
| 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 |
|
||||
|
||||
---
|
||||
| System | Login |
|
||||
|---|---|
|
||||
| pve-hetzner SSH | root / Astral-Proxmox!2026 |
|
||||
| pve1 SSH | root / astral66 |
|
||||
| Alle lokalen CTs | root / astral66 |
|
||||
| WordPress Admin | admin / astral66 |
|
||||
| Seafile | admin@orbitalo.net / astral66 |
|
||||
| n8n | wuttig@gmx.de / Astral66 |
|
||||
| Dify | admin@orbitalo.net / astral66 |
|
||||
| Grafana | admin / astral66 |
|
||||
|
||||
## Telegram Bots
|
||||
|
||||
| Bot | Token | Chat-ID | Einsatz |
|
||||
|-----|-------|---------|---------|
|
||||
| Arakava / Alerts | `8551565940:AAHIUpZND-tCNGv9yEoNPRyPt4GxEPYBJdE` | 674951792 | RSS Alerts, WP Kommentare |
|
||||
| DifyRagBot | `8390483455:AAEUyRWkvESSGQBtvjzAIQ5UKqmpoMTQZ00` | 674951792 | Dify / Grafana |
|
||||
|
||||
---
|
||||
| Bot | Token | Chat-ID | Projekt |
|
||||
|---|---|---|---|
|
||||
| @MutterbotAI_bot | (in infrastructure/STATE.md) | 674951792 | Moltbot allgemein |
|
||||
| @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
|
||||
|
||||
- `sync-state.sh` läuft alle **15 Minuten** auf pve-hetzner (`/opt/homelab-brain/scripts/`)
|
||||
- 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
|
||||
```
|
||||
Die STATE.md Dateien werden täglich um 03:00 Uhr automatisch aktualisiert.
|
||||
Script: `scripts/sync-state.sh` läuft als Cron-Job auf pve-hetzner.
|
||||
|
|
|
|||
|
|
@ -1,72 +1,130 @@
|
|||
# Arakava News — Live State
|
||||
> Auto-generiert: 2026-02-26 16:00
|
||||
# STATE: Arakava News
|
||||
**Stand: 24.02.2026**
|
||||
|
||||
## Service Status
|
||||
| Service | CT | Status |
|
||||
|---|---|---|
|
||||
| rss-manager | 109 | active |
|
||||
| WordPress Docker | 101 | running |
|
||||
---
|
||||
|
||||
## Letzte Feed-Aktivität (Top 5)
|
||||
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
|
||||
## Aktiver Zustand
|
||||
|
||||
## Fehler (letzte 24h)
|
||||
- Fehler gesamt: 0
|
||||
0
|
||||
- Letzter Fehler:
|
||||
### Container-Landschaft (nach Bereinigung)
|
||||
|
||||
## OpenRouter Guthaben
|
||||
$12.20 verbleibend
|
||||
| CT | Dienst | Status | Tailscale |
|
||||
|----|--------|--------|-----------|
|
||||
| 101 | WordPress + MySQL (Docker) | ✅ Läuft | 100.91.212.19 |
|
||||
| 109 | RSS Manager + Matomo | ✅ Läuft | 100.113.244.101 |
|
||||
|
||||
## URLs
|
||||
- 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)
|
||||
**Gelöscht (24.02.2026):** CT 100 (Traefik), CT 102 (Dify), CT 104 (n8n), CT 105, CT 106, CT 113 (Matomo alt)
|
||||
|
||||
## Container
|
||||
| CT | Dienst | Tailscale |
|
||||
|---|---|---|
|
||||
| 101 | WordPress + MySQL (Docker) | 100.91.212.19 |
|
||||
| 109 | RSS Manager + Matomo | 100.113.244.101 |
|
||||
### URLs
|
||||
|
||||
## Aktive Feeds (17)
|
||||
| ID | Name | Schedule |
|
||||
|---|---|---|
|
||||
| 1 | Dr. Bines Substack | 08/14/20 Uhr |
|
||||
| 3 | NachDenkSeiten | 07/13/19 Uhr |
|
||||
| 4 | Tichys Einblick | 07:30/13:30/19:30 |
|
||||
| 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 |
|
||||
| Dienst | URL |
|
||||
|--------|-----|
|
||||
| Blog | https://arakava-news-2.orbitalo.net |
|
||||
| Admin | https://arakava-news-2.orbitalo.net/wp-admin |
|
||||
| RSS Manager | http://100.113.244.101:8080 |
|
||||
| Matomo | https://matomo.orbitalo.net |
|
||||
|
||||
## Code (CT 109: /opt/rss-manager/)
|
||||
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)
|
||||
## CT 101 — WordPress
|
||||
|
||||
## Änderungshistorie
|
||||
- 24.02.2026: Scheduler Lock gegen Doppelstarts
|
||||
- 24.02.2026: Telegram auf HTML-Modus (Sonderzeichen-Fix)
|
||||
- 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: CT 100/102/104/105/106/113 gelöscht
|
||||
**Stack:** Docker (wordpress-app + wordpress-mysql)
|
||||
**Compose:** /opt/wordpress/docker-compose.yml
|
||||
|
||||
### Plugins
|
||||
|
||||
| Plugin | Pfad | Funktion |
|
||||
|--------|------|---------|
|
||||
| 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
|
||||
> Auto-generiert täglich 03:00. Manueller Abschnitt am Ende.
|
||||
> Zuletzt aktualisiert: 24.02.2026
|
||||
|
||||
## Services
|
||||
|
||||
| Service | Container | URL | Status |
|
||||
|---|---|---|---|
|
||||
| Dashboard Kambodscha | CT 135, pve1 | http://192.168.0.219:8501 | auto-aktualisiert |
|
||||
| Dashboard Deutschland | CT 134, pve3 | https://blei.orbitalo.info | auto-aktualisiert |
|
||||
| **Dashboard V3** | CT 136, pve1 (Kambodscha) | https://blei.orbitalo.info | ✅ Aktiv |
|
||||
| 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
|
||||
|
||||
```bash
|
||||
# Dashboard Kambodscha starten/stoppen
|
||||
pct exec 135 -- pkill -f streamlit
|
||||
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 &"
|
||||
ssh root@100.122.56.60 # pve1 Kambodscha (Tailscale)
|
||||
pct exec 136 -- bash
|
||||
|
||||
# Dashboard Deutschland (pve3)
|
||||
ssh root@100.122.163.2 "pct exec 134 -- ..."
|
||||
systemctl status edelmetall-dashboard # Streamlit
|
||||
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)
|
||||
dashboard/validation.py — Preis-Validierung
|
||||
bot.py — Telegram Bot
|
||||
spot_api.py — Spot-Preis API
|
||||
venv/ — Python venv
|
||||
core/
|
||||
db.py DB-Verbindung + Schema
|
||||
prices.py Spot + Händler, COALESCE(buy_price, sell_price)
|
||||
portfolio.py Portfolio-Berechnung (Krügerrand-Ankaufspreis)
|
||||
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 |
|
||||
| ⚖️ Ratio | Gold/Silber Ratio + Regime-Analyse |
|
||||
| 📊 Strategie | **Allocation Signal Indikator** |
|
||||
| 📊 Spreads | Händler-Spreads |
|
||||
| 💎 Portfolio | Persönliches Portfolio |
|
||||
| 🌍 Makro Liquidität | Makro-Analyse |
|
||||
| 🔧 Scraper | Scraper Status |
|
||||
| Token | 8262992299:AAEf8YHPsz42ZdP85DV7JqC4822Ts75GqF4 |
|
||||
| Service | edelmetall-telegram.service |
|
||||
| Befehle | /start /portfolio /preise /ratio /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 |
|
||||
| 🟢 REBALANCING | percentile >= 90 OR ratio > 85 | Silber relativ günstig |
|
||||
| 🟡 NEUTRAL | sonst | Halten |
|
||||
| spot_prices | 2.031 | 2025-12-18 bis heute |
|
||||
| gold_prices | 16.776 | 2025-12-18 bis heute |
|
||||
| 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 |
|
||||
|---|---|---|---|---|
|
||||
| D1603 | 16 | 29.745 EUR | Antonio Ramirez | 250 USD/M |
|
||||
| G2010B | 20 | 34.000 USD | Cheng Qiu | 250 USD/M |
|
||||
## Fixes 24.02.2026
|
||||
|
||||
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
|
||||
- [ ] Beide Dashboards in CT 112 v3 migrieren
|
||||
- [ ] Bot-Alerts für Allocation Signal
|
||||
## Konnektivität CT 136
|
||||
|
||||
## Notizen (manuell)
|
||||
<!-- Hier können manuelle Ergänzungen eingetragen werden -->
|
||||
| Typ | Wert |
|
||||
|---|---|
|
||||
| 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
|
||||
> Auto-generiert: 2026-02-26 16:00
|
||||
> Auto-generiert: 2026-02-22 06:30
|
||||
|
||||
## pve-hetzner Disk
|
||||
| Mount | Belegt |
|
||||
|---|---|
|
||||
| / (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 |
|
||||
|---|---|---|---|
|
||||
| 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) |
|
||||
| 109 | rss-manager | 100.113.244.101 | RSS Manager + Matomo |
|
||||
| 110 | portainer | 100.109.206.43 | Portainer Docker UI |
|
||||
| 111 | forgejo | 100.89.246.60 | Forgejo Git (http://100.89.246.60:3000) |
|
||||
| 104 | n8n | 100.125.102.93 | n8n (Workflows deaktiviert) |
|
||||
| 106 | wordpress-news | — | WordPress + MySQL (Docker), Cloudflare Tunnel ef43618e |
|
||||
| 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 |
|
||||
| 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)
|
||||
| CT | Name | Grund |
|
||||
|---|---|---|
|
||||
| 100 | traefik | Abgelöst durch Cloudflare Tunnel |
|
||||
| 102 | dify | Experiment fehlgeschlagen |
|
||||
| 104 | n8n | Nicht aktiv genutzt |
|
||||
| 105 | debian-12 | Nicht genutzt |
|
||||
| 106 | wordpress-news | Abgelöst durch CT 101 |
|
||||
| 113 | matomo | Integriert in CT 109 |
|
||||
## Container auf pve1 Kambodscha
|
||||
| CT | Name | IP | Tailscale | Dienste |
|
||||
|---|---|---|---|---|
|
||||
| 136 | gold-silber-v3 | 192.168.0.159 | 100.72.230.87 | Dashboard V3 (blei.orbitalo.info), Telegram Bot V3, Cloudflare Tunnel |
|
||||
| 888 | MCP-Proxmox | 192.168.0.116 | — | Proxmox MCP |
|
||||
| 999 | cluster-docu | 192.168.0.209 | — | Doku-Mirror |
|
||||
| ~~135~~ | ~~gold-silber-v2~~ | ~~192.168.0.219~~ | — | ⛔ gestoppt 2026-02-23 |
|
||||
|
||||
## Container auf pve1 (Kambodscha)
|
||||
| CT | Name | Dienste |
|
||||
|---|---|---|
|
||||
| 136 | gold-silber-v3 | Edelmetall-Bot (Tailscale: 100.72.230.87) |
|
||||
| 143 | smart-home | ioBroker + Grafana + InfluxDB |
|
||||
|
||||
## 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
|
||||
## Container auf pve3 Muldenstein
|
||||
| CT | Name | IP | Dienste |
|
||||
|---|---|---|---|
|
||||
| 143 | raspi-broker | 192.168.178.36 | InfluxDB, Grafana, ioBroker |
|
||||
| ~~134~~ | ~~gold-silber-de~~ | ~~100.69.161.128~~ | ⛔ gestoppt |
|
||||
|
||||
## Zugangsdaten
|
||||
- pve-hetzner: root / Astral-Proxmox!2026
|
||||
- pve1: root / astral66
|
||||
- Alle CTs: root / astral66
|
||||
- Alle lokalen CTs: root / astral66
|
||||
- Seafile: admin@orbitalo.net / astral66
|
||||
- Forgejo: orbitalo / astral66
|
||||
- n8n: wuttig@gmx.de / Astral66
|
||||
- Dify: admin@orbitalo.net / astral66
|
||||
|
||||
## Telegram Bots
|
||||
| Bot | Token (Auszug) | Chat-ID |
|
||||
|---|---|---|
|
||||
| Mutter (@MutterbotAI_bot) | 8551565940:... | 674951792 |
|
||||
## Änderungen 2026-02-23
|
||||
|
||||
- CT 136 (gold-silber-v3) neu auf pve1 erstellt — Edelmetall Dashboard V3
|
||||
- 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/)
|
||||
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
|
||||
- 24.02.2026: Scheduler Lock gegen Doppelstarts
|
||||
- 24.02.2026: Telegram auf HTML-Modus (Sonderzeichen-Fix)
|
||||
- 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: CT 100/102/104/105/106/113 gelöscht
|
||||
EOF
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue