Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc6a32697f | ||
|
|
9f2c2c33d3 | ||
|
|
6b3fbfdbb5 | ||
|
|
8bdd06e598 | ||
|
|
4f72e4d71b | ||
|
|
7b5d039323 | ||
|
|
fe4edcc217 | ||
|
|
3e9e12bbc5 | ||
|
|
7b9b3ff0eb | ||
|
|
191c8e9143 | ||
|
|
f5b5deaae7 | ||
|
|
545752fa60 | ||
|
|
e9d2c59eed | ||
|
|
63497765b0 | ||
|
|
88c42aa1e5 | ||
|
|
bcb04711c0 | ||
|
|
694d0c5072 | ||
|
|
1ccab5e522 | ||
|
|
fe21d01a3f | ||
|
|
1babaab1e2 | ||
|
|
3abfa18182 | ||
|
|
967909c3a7 | ||
|
|
80e38ab64a | ||
|
|
786ce47bd4 | ||
|
|
88d49f85e5 | ||
|
|
c9cbc56441 | ||
|
|
6a93539a97 | ||
|
|
77a0e56d96 | ||
|
|
0489ef7d62 | ||
|
|
2bde04617f | ||
|
|
7336f7bfff | ||
|
|
d044eba97a | ||
|
|
109162889c | ||
|
|
42f37a89f8 | ||
|
|
be96b1234f | ||
|
|
39441067bc | ||
|
|
6e98fd83b2 | ||
|
|
ea079c9852 | ||
|
|
a9d386c2e9 | ||
|
|
2dfce6967c | ||
|
|
0162f9dcd3 | ||
|
|
b104b2f6a2 | ||
|
|
bb31002e19 | ||
|
|
5376ddbc20 | ||
|
|
7e82424ca2 | ||
|
|
dc874415b4 | ||
|
|
309f4e4856 | ||
|
|
9a857ac23b | ||
|
|
505249748e | ||
|
|
71e8909251 | ||
|
|
e2944efb3e | ||
|
|
24e788ba6b | ||
|
|
9c32d8e44c | ||
|
|
e8c0046c51 | ||
|
|
3d78c94a58 | ||
|
|
03a5d38672 | ||
|
|
7aca5dfe70 | ||
|
|
fbd934965d | ||
|
|
9ac1ee5f35 | ||
|
|
a610ec32d0 | ||
|
|
2a6709d5bc | ||
|
|
64137350b4 | ||
|
|
9af18e5879 | ||
|
|
d445d22fda |
@@ -20,11 +20,94 @@ A professional "Octobot Journalist" that generates daily, weekly, and monthly re
|
||||
- **Topics:** IT News, GCP, AI (Anthropic/OpenAI), Hacker News, Regensburg Local News.
|
||||
- **Special Features:** Monday catch-up, Drill-down ("Tell me more"), Gardening (Hochbeet), Supermarket Shopping Guide.
|
||||
|
||||
### [memobird](skills/memobird/SKILL.md)
|
||||
Direct printing to the Memobird thermal printer. Allows sending text, images, and notes to the device on the local network.
|
||||
- **Features:** Text styling (big, bold, underline), automated 1-bit image conversion, separator lines.
|
||||
- **Target:** Default targets the static IP `192.168.10.165`.
|
||||
|
||||
### [rvv-regensburg](skills/rvv-regensburg/SKILL.md)
|
||||
Real-time public transport information for Regensburg (RVV). Provides departures, stop finding, and journey planning via direct API access.
|
||||
- **Features:** Real-time departures with delay info, connection search, Stop ID resolver.
|
||||
- **Tools:** Includes `rvv_query.py` for direct EFA API interaction.
|
||||
|
||||
### [text-to-audio](skills/text-to-audio/SKILL.md)
|
||||
Audio dossier and podcast producer. Converts arbitrary research, summaries, notes, or articles into audio dossiers with duration target, importance compression, and solo or NotebookLM-style dialogue.
|
||||
- **Features:** Target duration pacing (~130 words/min), importance filtering, solo monologue or multi-speaker dialogue.
|
||||
- **Voices:** Primary Google Gemini 3.1 Flash TTS with automatic MiniMax Speech 02 HD fallback. German voices (`Puck` host/solo, `Aoede` co-host, `Charon` deep), English voices (`Zephyr` host/solo, `Aoede` co-host).
|
||||
- **Tools:** Includes `scripts/text_to_audio.py` for parallel chunk synthesis, auto-fallback, and MP3 generation.
|
||||
|
||||
### [jelly-media](skills/jelly-media/SKILL.md)
|
||||
Seamless Jellyfin and Jellyseerr integration. Enables natural-language discovery, stream & download URLs, living room playback dispatch, continue watching, and media requests.
|
||||
- **Features:** Smart search cross-referenced with library availability, discover with genre & rating filters, direct stream & download links, Jellyfin active sessions & remote play, Jellyseerr requests.
|
||||
- **Tools:** Standalone MCP server and CLI at `scripts/jelly_media.py`.
|
||||
|
||||
### [shopping](skills/shopping/SKILL.md)
|
||||
Smart shopping advisor and Vorrats-Concierge.
|
||||
- **Features:** German supermarket & discounter weekly discounts (Edeka, Rewe, Aldi, Lidl, Kaufland, Netto, Penny, dm, Rossmann), Vorratskauf multi-brand comparison (Mutti / Oro di Parma), diaper piece-price calculator (ct/Windel), Geizhals price comparison & all-time-low price history, and eBay marketplace search.
|
||||
- **Tools:** Standalone MCP servers and CLIs at `scripts/supermarket_deals.py`, `scripts/geizhals_mcp.py`, and `scripts/ebay_mcp.py`.
|
||||
|
||||
### [spotify](skills/spotify/SKILL.md)
|
||||
Spotify music intelligence, catalog search, playback control, playlist curation, party queueing, and personal listening history.
|
||||
- **Features:** 35+ tools via `mcp-server-spotify` (search, playlists, player/queue, listening stats, top items).
|
||||
- **Synergy:** Bridges Spotify search with Home Assistant to stream Spotify URIs directly on Sonos multi-room speakers and sync Philips Hue lighting.
|
||||
- **Tools:** Includes one-time token generator `scripts/spotify_auth.py`.
|
||||
|
||||
### [markitdown](skills/markitdown/SKILL.md)
|
||||
Universal document and media converter powered by Microsoft MarkItDown.
|
||||
- **Features:** Converts PDF, Word (.docx), Excel (.xlsx), PowerPoint (.pptx), HTML, CSV, audio, and images to clean Markdown.
|
||||
- **Tools:** Standalone CLI and URL downloader at `scripts/convert_to_markdown.py`.
|
||||
|
||||
### [regensburg-stadtrat](skills/regensburg-stadtrat/SKILL.md)
|
||||
Municipal politics, city council resolutions, meeting agendas, and Ratsinformation for the City of Regensburg (ALLRIS net).
|
||||
- **Features:** Live Latin-1 full-text search across 20+ years of council records, native XML calendar and agenda feeds, Vorlagen metadata & verbatim resolution extraction, and compilation into the Obsidian knowledge vault.
|
||||
- **Tools:** Includes `scripts/regensburg_ris.py` with `search`, `calendar`, `session`, `vorlage`, and `compile-topic` commands.
|
||||
|
||||
### [google-calendar](skills/google-calendar/SKILL.md)
|
||||
Two-way Google Calendar integration for Family Appointments and Regensburg Local Events via GCP Service Account.
|
||||
- **Features:** Read, search, create, update, and delete events across two distinct calendars (`family` and `regensburg`).
|
||||
- **Tools:** Standalone MCP server and CLI at `scripts/google_calendar.py` (`calendar_list_events`, `calendar_create_event`, `calendar_update_event`, `calendar_delete_event`, `calendar_get_event`).
|
||||
|
||||
### [hosting](skills/hosting/SKILL.md)
|
||||
Public S3 file sharing and static website hosting on `https://hosting.haumdaucher.de`.
|
||||
- **Features:** Upload HTML research dossiers, recipes, documents, and images with clean URLs (`/{category}/{YYYYMMDD}-{slug}.{ext}`).
|
||||
- **Workflow:** Proactively triggers `ask_user` with Telegram inline buttons to offer generating a branded QR code for the public link.
|
||||
- **Tools:** Standalone publisher CLI at `scripts/publish_file.py`.
|
||||
|
||||
### [qr-code](skills/qr-code/SKILL.md)
|
||||
Universal QR code generator featuring the Haumdaucher logo in the center.
|
||||
- **Features:** High-redundancy QR code synthesis (`ERROR_CORRECT_H`), logo overlay with clean white badge, and native Telegram image delivery via `MEDIA:<path>`.
|
||||
- **Tools:** Standalone CLI at `scripts/generate_qr.py`.
|
||||
|
||||
### [chart-to-chat](skills/chart-to-chat/SKILL.md)
|
||||
Instant chart and diagram generation for chat and Telegram.
|
||||
- **Features:** Transforms structured data (JSON, CSV, key-value metrics) into publication-grade PNG diagrams (bar, line, area, donut, KPI cards, tables) powered by dbt-charts (`dct`) with QuickChart.io fallback.
|
||||
- **Delivery:** Generates local PNGs and outputs `MEDIA:<path>` for native Telegram image rendering.
|
||||
- **Tools:** Standalone CLI at `scripts/chart.py`.
|
||||
|
||||
### [create-website](skills/create-website/SKILL.md)
|
||||
Creative Web Director and Senior Frontend Engineer for single-page applications and interactive showcases.
|
||||
- **Features:** Builds fancy, responsive, zero-build SPAs (Tailwind CSS Play CDN, Alpine.js reactivity, Lucide icons, Google Fonts) with anti-AI-slop design archetypes (Dark Obsidian, Editorial Warmth, Neo-Brutalism, Modern Clean SaaS).
|
||||
- **Hosting:** Direct validation and upload to `https://hosting.haumdaucher.de/showcase/...` via the `hosting` skill pipeline, with native Telegram document delivery and QR code generation.
|
||||
- **Tools:** Standalone validation & publisher CLI at `scripts/publish_website.py`.
|
||||
|
||||
### [grill-me](skills/grill-me/SKILL.md)
|
||||
Relentless, structured interview to stress-test and sharpen plans, architectures, decisions, or ideas before writing code.
|
||||
- **Features:** Decision tree & frontier exploration, Telegram/mobile-optimized batching (1-3 questions per round), facts-first environment research, anti-sycophancy challenge, and final specification/ADR synthesis.
|
||||
|
||||
### [zwift-workout](skills/zwift-workout/SKILL.md)
|
||||
Builds, previews, validates and publishes Zwift `.zwo` workout files (bike-only).
|
||||
- **Workflow:** draft JSON spec -> human-readable preview -> user confirms -> publish via hosting -> file into Knowledge Wiki.
|
||||
- **Features:** Step types `Warmup`/`Cooldown`/`SteadyState`/`Ramp`/`IntervalsT`/`FreeRide`/`MaxEffort`/`SolidState`; power as FTP fractions; guardrails (0.20-1.50 FTP, XML escaping, round-trip parse check); TSS/duration estimate; templates (`endurance`, `sweetspot`, `vo2max`, `sprint`).
|
||||
- **Tools:** CLI at `scripts/zwo.py` (`build`, `from-template`, `validate`, `parse`, `render`, `templates`) + builder at `scripts/zwo_builder.py`.
|
||||
- **Integration:** Fitness agent / Tacx Flux 2; synergistic with `garmin-workout` (one spec, two targets).
|
||||
|
||||
### [datastore](skills/datastore/SKILL.md)
|
||||
Structured personal data layer for OpenClaw powered by in-memory DuckDB and git-backed JSONL in `knowledge-wiki/data/`.
|
||||
- **Purpose:** Eliminates LLM arithmetic errors across nutrition intake, baby sleep tracking, and daily caloric balances (ADR 0004).
|
||||
- **Features:** Unified time-series events (`nutrition.intake`, `sleep.session` with open intervals `end=NULL`, `feeding.bottle`), entity catalogs (`foods`, `recipes`, `places`, `people`), dual-engine geospatial radius queries (DuckDB spatial + Haversine fallback), and nightly Garmin Connect mirror.
|
||||
- **Presentation:** Automatically renders Markdown logs in `entities/` and responsive HTML dashboards on `hosting.haumdaucher.de/family/`.
|
||||
- **Tools:** Standalone MCP server and CLI at `scripts/datastore.py`, `scripts/garmin_sync.py`, and `scripts/render_views.py`.
|
||||
|
||||
### [skill-creator](skills/skill-creator/SKILL.md)
|
||||
Guidelines and scripts to help developers build high-quality, token-efficient Claude skills.
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
---
|
||||
name: brother-printer
|
||||
description: Print PDF documents, text, and files directly to the Brother HL-L8240CDW duplex color laser printer at Grabengasse 7 (192.168.10.91). Handles interactive Telegram print options (Color/B&W, Pages per Sheet / N-Up, Duplex/Simplex) and live-updating Telegram status messages.
|
||||
---
|
||||
|
||||
# 🖨️ Brother HL-L8240CDW Laser Printer
|
||||
|
||||
Direct network printing to the **Brother HL-L8240CDW** duplex color laser printer on the home network (`192.168.10.91:631`) via IPP Everywhere.
|
||||
|
||||
## 🎯 When to Use
|
||||
- Whenever the user asks to print something: *"Bitte drucke XY"*, *"Druck das bitte aus"*, *"Kannst du das drucken?"*.
|
||||
- Whenever the user sends a PDF, image, document, or ticket on Telegram with a request to print.
|
||||
- Whenever the user asks to print recipes, shopping lists, flight tickets, packing lists, or notes.
|
||||
- Whenever the user asks for printer status or toner levels: *"Wie viel Toner ist noch im Drucker?"*, *"Ist der Drucker online?"*.
|
||||
|
||||
---
|
||||
|
||||
## 🕹️ Interactive Telegram Options Protocol (CRITICAL)
|
||||
|
||||
When the user requests a print without specifying options (e.g. *"Bitte drucke die Rechnung"* or sends a PDF):
|
||||
- **Single Invocation**: Call `ask_user` exactly ONCE. Do NOT print duplicate text option lists in the chat; `ask_user` renders the interactive buttons natively.
|
||||
- **Skip if Already Specified**: If the user already gave preferences in their prompt (e.g. *"Druck das in Farbe"*, *"Drucke das bitte schwarz-weiß beidseitig auf 2 Seiten"*), **DO NOT call `ask_user`** — execute the print job immediately with their specified options!
|
||||
- **Never Duplicate or Retry**: Once `ask_user` is sent, wait for the user to tap an option. Never re-trigger `ask_user` for the same document.
|
||||
|
||||
### Fast-Path Preset Menu via `ask_user`
|
||||
|
||||
Call `ask_user` with:
|
||||
- `id`: `"print_options"`
|
||||
- `header`: `"Drucker"`
|
||||
- `question`: `"Wie möchtest du das Dokument drucken?"`
|
||||
- `options`:
|
||||
1. `label`: `"🌱 Spar-Druck: S/W, 2 Seiten, Duplex (Recommended)"`
|
||||
`description`: `"Schwarz-Weiß, 2 Seiten pro Blatt, beidseitig (spart Papier)"`
|
||||
*(Maps to: `--color monochrome --nup 2 --duplex two-sided-long-edge`)*
|
||||
2. `label`: `"📄 Standard: S/W, 1 Seite, Duplex"`
|
||||
`description`: `"Schwarz-Weiß, 1 Seite pro Blatt, beidseitig"`
|
||||
*(Maps to: `--color monochrome --nup 1 --duplex two-sided-long-edge`)*
|
||||
3. `label`: `"🎨 Vollfarbe: 1 Seite, Duplex"`
|
||||
`description`: `"Farbdruck, 1 Seite pro Blatt, beidseitig"`
|
||||
*(Maps to: `--color color --nup 1 --duplex two-sided-long-edge`)*
|
||||
4. `label`: `"📄 Einseitig (Simplex): S/W, 1 Seite"`
|
||||
`description`: `"Schwarz-Weiß, 1 Seite pro Blatt, nur Vorderseite"`
|
||||
*(Maps to: `--color monochrome --nup 1 --duplex one-sided`)*
|
||||
|
||||
---
|
||||
|
||||
## 📍 Active Conversation & Thread Routing (CRITICAL)
|
||||
|
||||
**Never hardcode a chat ID!** To ensure status updates appear in the exact conversation and topic where requested (whether requested by Moritz, Amelie, or in a group topic like the Mila topic):
|
||||
|
||||
1. **`--chat-id`**: Set to the CURRENT session's chat ID (e.g. `-1004326921510` for group chat, or user ID if in private DM).
|
||||
2. **`--thread-id`**: If the incoming message came from a forum topic / thread (e.g. `threadId: 22` / `message_thread_id: 22`), **YOU MUST PASS `--thread-id <threadId>`**. Without `--thread-id`, Telegram routes messages to the General topic instead of the active conversation thread!
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Execution & Live Telegram Status Updates
|
||||
|
||||
Execute the bundled driver script with `uv run`:
|
||||
|
||||
### 1. Print a PDF file with selected options (in a topic or group)
|
||||
```bash
|
||||
uv run /home/node/mop-skills/mop-skills/skills/brother-printer/scripts/brother_printer.py print \
|
||||
--file "/home/node/.openclaw/workspace/document.pdf" \
|
||||
--color monochrome \
|
||||
--nup 2 \
|
||||
--duplex two-sided-long-edge \
|
||||
--chat-id "<current_chat_id>" \
|
||||
--thread-id "<current_thread_id>"
|
||||
```
|
||||
|
||||
### 2. Print a document from a URL
|
||||
```bash
|
||||
uv run /home/node/mop-skills/mop-skills/skills/brother-printer/scripts/brother_printer.py print \
|
||||
--url "https://example.com/ticket.pdf" \
|
||||
--title "Konzertticket" \
|
||||
--color color \
|
||||
--nup 1 \
|
||||
--duplex two-sided-long-edge \
|
||||
--chat-id "<current_chat_id>"
|
||||
```
|
||||
|
||||
### 3. Print plain text or notes
|
||||
```bash
|
||||
uv run /home/node/mop-skills/mop-skills/skills/brother-printer/scripts/brother_printer.py print \
|
||||
--text "Einkaufsliste für Samstag:\n- Hafermilch\n- Kaffee\n- Bananen" \
|
||||
--title "Einkaufszettel" \
|
||||
--color monochrome \
|
||||
--nup 1 \
|
||||
--duplex one-sided \
|
||||
--chat-id "<current_chat_id>"
|
||||
```
|
||||
|
||||
### 4. Check status & toner levels
|
||||
```bash
|
||||
uv run /home/node/mop-skills/mop-skills/skills/brother-printer/scripts/brother_printer.py status
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ CLI Options Reference
|
||||
|
||||
| Argument | Values / Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `--file` | File path | None | Local PDF path to print |
|
||||
| `--url` | URL string | None | Download and print PDF from web |
|
||||
| `--text` | String | None | Text content to format as A4 PDF and print |
|
||||
| `--title` | String | Basename | Document title for print queue and Telegram header |
|
||||
| `--color` | `monochrome`, `color` | `monochrome` | Print color mode |
|
||||
| `--nup` | `1`, `2`, `4` | `1` | Hardware pages per sheet (1 = normal, 2 = 2 pages/sheet) |
|
||||
| `--duplex` | `two-sided-long-edge`, `one-sided`, `two-sided-short-edge` | `two-sided-long-edge` | Duplex binding mode |
|
||||
| `--copies` | Integer | `1` | Number of printed copies |
|
||||
| `--chat-id` | Telegram Chat ID | None | Target chat ID for live status updates (use current chat) |
|
||||
| `--thread-id` | Integer | None | Target forum topic / message thread ID (mandatory for topics) |
|
||||
| `--max-pages`| Integer | `30` | Safety limit to prevent runaway printing |
|
||||
| `--force` | Flag | `false` | Bypass page limit |
|
||||
@@ -0,0 +1,479 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# dependencies = [
|
||||
# "pyipp>=0.16.0",
|
||||
# "requests>=2.31.0",
|
||||
# "pypdf>=4.0.0",
|
||||
# "reportlab>=4.0.0",
|
||||
# ]
|
||||
# ///
|
||||
"""
|
||||
Brother HL-L8240CDW Network Printer Controller & IPP Driver for OpenClaw.
|
||||
Supports direct PDF printing, hardware N-Up, duplex, color modes,
|
||||
and live Telegram status updates via Bot API.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import json
|
||||
import argparse
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
import requests
|
||||
from pypdf import PdfReader
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
||||
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer
|
||||
from reportlab.lib.units import cm
|
||||
|
||||
import pyipp
|
||||
from pyipp import IPP
|
||||
from pyipp.enums import IppOperation
|
||||
from pyipp.tags import IppTag
|
||||
import pyipp.serializer
|
||||
|
||||
# Register extra IPP attribute tags for Brother HL-L8240CDW
|
||||
pyipp.serializer.ATTRIBUTE_TAG_MAP["print-color-mode"] = IppTag.KEYWORD
|
||||
pyipp.serializer.ATTRIBUTE_TAG_MAP["page-ranges"] = IppTag.RANGE
|
||||
|
||||
DEFAULT_PRINTER_HOST = os.environ.get("BROTHER_PRINTER_HOST", "192.168.10.91")
|
||||
DEFAULT_PRINTER_PORT = int(os.environ.get("BROTHER_PRINTER_PORT", "631"))
|
||||
DEFAULT_PRINTER_URI = f"ipp://{DEFAULT_PRINTER_HOST}:{DEFAULT_PRINTER_PORT}/ipp/print"
|
||||
TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Telegram Helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TelegramNotifier:
|
||||
def __init__(self, token: Optional[str] = None, chat_id: Optional[str] = None, thread_id: Optional[int] = None, message_id: Optional[int] = None):
|
||||
self.token = token or TELEGRAM_BOT_TOKEN
|
||||
self.chat_id = chat_id
|
||||
self.thread_id = int(thread_id) if thread_id else None
|
||||
self.message_id = int(message_id) if message_id else None
|
||||
|
||||
def can_notify(self) -> bool:
|
||||
return bool(self.token and self.chat_id)
|
||||
|
||||
def send_initial(self, text: str) -> Optional[int]:
|
||||
if not self.can_notify():
|
||||
return None
|
||||
url = f"https://api.telegram.org/bot{self.token}/sendMessage"
|
||||
payload = {
|
||||
"chat_id": self.chat_id,
|
||||
"text": text,
|
||||
"parse_mode": "Markdown",
|
||||
}
|
||||
if self.thread_id:
|
||||
payload["message_thread_id"] = self.thread_id
|
||||
try:
|
||||
resp = requests.post(url, json=payload, timeout=8)
|
||||
if resp.ok:
|
||||
data = resp.json()
|
||||
self.message_id = data.get("result", {}).get("message_id")
|
||||
return self.message_id
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"Telegram send error: {e}\n")
|
||||
return None
|
||||
|
||||
def update(self, text: str):
|
||||
if not self.can_notify() or not self.message_id:
|
||||
return
|
||||
url = f"https://api.telegram.org/bot{self.token}/editMessageText"
|
||||
payload = {
|
||||
"chat_id": self.chat_id,
|
||||
"message_id": self.message_id,
|
||||
"text": text,
|
||||
"parse_mode": "Markdown",
|
||||
}
|
||||
try:
|
||||
requests.post(url, json=payload, timeout=8)
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"Telegram edit error: {e}\n")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Text to PDF Converter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def convert_text_to_pdf(text: str, output_path: str, title: str = "Dokument") -> str:
|
||||
"""Convert plain text or simple markdown into a clean A4 PDF."""
|
||||
doc = SimpleDocTemplate(
|
||||
output_path,
|
||||
pagesize=A4,
|
||||
rightMargin=2 * cm,
|
||||
leftMargin=2 * cm,
|
||||
topMargin=2 * cm,
|
||||
bottomMargin=2 * cm,
|
||||
)
|
||||
styles = getSampleStyleSheet()
|
||||
normal_style = styles["Normal"]
|
||||
normal_style.fontSize = 11
|
||||
normal_style.leading = 14
|
||||
|
||||
heading_style = styles["Heading1"]
|
||||
heading_style.fontSize = 16
|
||||
heading_style.leading = 20
|
||||
|
||||
story = [
|
||||
Paragraph(title, heading_style),
|
||||
Spacer(1, 0.5 * cm),
|
||||
]
|
||||
|
||||
for paragraph in text.split("\n\n"):
|
||||
clean_p = paragraph.replace("\n", "<br/>")
|
||||
story.append(Paragraph(clean_p, normal_style))
|
||||
story.append(Spacer(1, 0.3 * cm))
|
||||
|
||||
doc.build(story)
|
||||
return output_path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Printer Service
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class BrotherPrinter:
|
||||
def __init__(self, host: str = DEFAULT_PRINTER_HOST, port: int = DEFAULT_PRINTER_PORT):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.uri = f"ipp://{self.host}:{self.port}/ipp/print"
|
||||
|
||||
async def get_status(self) -> Dict[str, Any]:
|
||||
"""Fetch status, markers, and printer details via IPP."""
|
||||
async with IPP(self.host, port=self.port) as ipp:
|
||||
printer = await ipp.printer()
|
||||
markers = {}
|
||||
for marker in printer.markers:
|
||||
markers[marker.name] = {
|
||||
"level": marker.level,
|
||||
"type": marker.marker_type,
|
||||
"color": marker.color,
|
||||
}
|
||||
return {
|
||||
"name": printer.info.name,
|
||||
"model": printer.info.model,
|
||||
"state": printer.state.printer_state,
|
||||
"reasons": printer.state.reasons,
|
||||
"markers": markers,
|
||||
"uri": self.uri,
|
||||
}
|
||||
|
||||
async def validate_job(
|
||||
self,
|
||||
color_mode: str = "monochrome",
|
||||
nup: int = 1,
|
||||
duplex: str = "two-sided-long-edge",
|
||||
copies: int = 1,
|
||||
) -> bool:
|
||||
"""Dry-run validation of job parameters without printing."""
|
||||
async with IPP(self.host, port=self.port) as ipp:
|
||||
msg = {
|
||||
"version": (2, 0),
|
||||
"operation": IppOperation.VALIDATE_JOB,
|
||||
"operation-attributes-tag": {
|
||||
"printer-uri": self.uri,
|
||||
"requesting-user-name": "openclaw",
|
||||
"document-format": "application/pdf",
|
||||
},
|
||||
"job-attributes-tag": {
|
||||
"sides": duplex,
|
||||
"number-up": nup,
|
||||
"print-color-mode": color_mode,
|
||||
"copies": copies,
|
||||
},
|
||||
}
|
||||
res = await ipp.execute(IppOperation.VALIDATE_JOB, msg)
|
||||
return res.get("status-code") == 0
|
||||
|
||||
async def send_print_job(
|
||||
self,
|
||||
pdf_bytes: bytes,
|
||||
job_name: str = "OpenClaw Print",
|
||||
color_mode: str = "monochrome",
|
||||
nup: int = 1,
|
||||
duplex: str = "two-sided-long-edge",
|
||||
copies: int = 1,
|
||||
) -> Dict[str, Any]:
|
||||
"""Send a Print-Job request directly with raw PDF data."""
|
||||
async with IPP(self.host, port=self.port) as ipp:
|
||||
msg = {
|
||||
"version": (2, 0),
|
||||
"operation": IppOperation.PRINT_JOB,
|
||||
"operation-attributes-tag": {
|
||||
"printer-uri": self.uri,
|
||||
"requesting-user-name": "openclaw",
|
||||
"job-name": job_name,
|
||||
"document-format": "application/pdf",
|
||||
},
|
||||
"job-attributes-tag": {
|
||||
"sides": duplex,
|
||||
"number-up": nup,
|
||||
"print-color-mode": color_mode,
|
||||
"copies": copies,
|
||||
},
|
||||
"data": pdf_bytes,
|
||||
}
|
||||
res = await ipp.execute(IppOperation.PRINT_JOB, msg)
|
||||
return res
|
||||
|
||||
async def get_job_attributes(self, job_id: int) -> Dict[str, Any]:
|
||||
"""Poll job status using GET_JOB_ATTRIBUTES."""
|
||||
async with IPP(self.host, port=self.port) as ipp:
|
||||
msg = {
|
||||
"version": (2, 0),
|
||||
"operation": IppOperation.GET_JOB_ATTRIBUTES,
|
||||
"operation-attributes-tag": {
|
||||
"printer-uri": self.uri,
|
||||
"job-id": job_id,
|
||||
"requesting-user-name": "openclaw",
|
||||
},
|
||||
}
|
||||
try:
|
||||
res = await ipp.execute(IppOperation.GET_JOB_ATTRIBUTES, msg)
|
||||
jobs = res.get("jobs", [])
|
||||
if jobs:
|
||||
return jobs[0]
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"Job query warning: {e}\n")
|
||||
return {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI Command Handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def handle_status(args):
|
||||
printer = BrotherPrinter(host=args.host, port=args.port)
|
||||
data = asyncio.run(printer.get_status())
|
||||
if args.json:
|
||||
print(json.dumps(data, indent=2))
|
||||
return
|
||||
|
||||
print("🖨️ Brother HL-L8240CDW Status")
|
||||
print("=" * 35)
|
||||
print(f"Modell: {data['model']}")
|
||||
print(f"Status: {data['state']}")
|
||||
print(f"URI: {data['uri']}")
|
||||
print("\n📊 Tonerstände:")
|
||||
for name, m in data["markers"].items():
|
||||
color_label = {"BK": "Schwarz", "C": "Cyan", "M": "Magenta", "Y": "Gelb"}.get(name, name)
|
||||
bar = "█" * (m["level"] // 10) + "░" * (10 - (m["level"] // 10))
|
||||
print(f" [{name:2}] {color_label:8}: {bar} {m['level']}%")
|
||||
|
||||
|
||||
def handle_validate(args):
|
||||
printer = BrotherPrinter(host=args.host, port=args.port)
|
||||
ok = asyncio.run(
|
||||
printer.validate_job(
|
||||
color_mode=args.color,
|
||||
nup=args.nup,
|
||||
duplex=args.duplex,
|
||||
copies=args.copies,
|
||||
)
|
||||
)
|
||||
if ok:
|
||||
print("✅ Konfiguration ist gültig und wird vom Drucker unterstützt.")
|
||||
else:
|
||||
print("❌ Konfiguration wurde vom Drucker abgewiesen.")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def handle_print(args):
|
||||
notifier = TelegramNotifier(chat_id=args.chat_id, thread_id=args.thread_id, message_id=args.message_id)
|
||||
|
||||
# 1. Resolve source document
|
||||
doc_title = args.title or "Dokument"
|
||||
pdf_path = None
|
||||
temp_files = []
|
||||
|
||||
if args.file:
|
||||
p = Path(args.file)
|
||||
if not p.exists():
|
||||
err = f"❌ Datei nicht gefunden: {args.file}"
|
||||
print(err)
|
||||
notifier.update(err)
|
||||
sys.exit(1)
|
||||
pdf_path = str(p)
|
||||
doc_title = args.title or p.name
|
||||
elif args.url:
|
||||
notifier.update(f"⏳ [1/4] Lade Dokument herunter: `{args.url[:40]}...`")
|
||||
resp = requests.get(args.url, timeout=30)
|
||||
tmp = f"/tmp/print_{int(time.time())}.pdf"
|
||||
with open(tmp, "wb") as f:
|
||||
f.write(resp.content)
|
||||
temp_files.append(tmp)
|
||||
pdf_path = tmp
|
||||
doc_title = args.title or Path(args.url).name
|
||||
elif args.text:
|
||||
notifier.update("⏳ [1/4] Formatiere Text als Druck-PDF...")
|
||||
tmp = f"/tmp/print_{int(time.time())}.pdf"
|
||||
convert_text_to_pdf(args.text, tmp, title=doc_title)
|
||||
temp_files.append(tmp)
|
||||
pdf_path = tmp
|
||||
else:
|
||||
err = "❌ Kein Dokument angegeben (--file, --url oder --text erforderlich)."
|
||||
print(err)
|
||||
notifier.update(err)
|
||||
sys.exit(1)
|
||||
|
||||
# 2. Inspect PDF properties
|
||||
try:
|
||||
reader = PdfReader(pdf_path)
|
||||
total_pages = len(reader.pages)
|
||||
except Exception as e:
|
||||
total_pages = 1
|
||||
|
||||
# Safety guardrail: check max pages
|
||||
if total_pages > args.max_pages and not args.force:
|
||||
err = f"⚠️ *Druck abgebrochen*: Dokument hat {total_pages} Seiten (Limit: {args.max_pages}). Verwende `--force` zum Überschreiben."
|
||||
print(err)
|
||||
notifier.update(err)
|
||||
sys.exit(1)
|
||||
|
||||
# Calculate estimated sheets
|
||||
sheets = (total_pages + (args.nup - 1)) // args.nup
|
||||
if args.duplex != "one-sided":
|
||||
sheets = (sheets + 1) // 2
|
||||
|
||||
color_desc = "Farbe 🎨" if args.color == "color" else "Schwarz-Weiß 🖤"
|
||||
duplex_desc = "Duplex (beidseitig) 🔄" if args.duplex != "one-sided" else "Simplex (einseitig) 📄"
|
||||
nup_desc = f"{args.nup} Seite(n) pro Blatt"
|
||||
|
||||
# Initial status notification
|
||||
status_header = (
|
||||
f"🖨️ *Druckauftrag: {doc_title}*\n"
|
||||
f"• *Umfang*: {total_pages} Seite(n) (~{sheets} Blatt Papier)\n"
|
||||
f"• *Modus*: {color_desc} | {nup_desc} | {duplex_desc}\n"
|
||||
)
|
||||
|
||||
if not notifier.message_id and notifier.can_notify():
|
||||
notifier.send_initial(f"{status_header}• *Status*: ⏳ [1/3] Bereite Übertragung vor...")
|
||||
else:
|
||||
notifier.update(f"{status_header}• *Status*: ⏳ [1/3] Bereite Übertragung vor...")
|
||||
|
||||
# 3. Read PDF bytes
|
||||
with open(pdf_path, "rb") as f:
|
||||
pdf_data = f.read()
|
||||
|
||||
# 4. Connect to printer and send job
|
||||
notifier.update(f"{status_header}• *Status*: 📡 [2/3] Sende an Brother HL-L8240CDW...")
|
||||
printer = BrotherPrinter(host=args.host, port=args.port)
|
||||
|
||||
try:
|
||||
res = asyncio.run(
|
||||
printer.send_print_job(
|
||||
pdf_bytes=pdf_data,
|
||||
job_name=doc_title,
|
||||
color_mode=args.color,
|
||||
nup=args.nup,
|
||||
duplex=args.duplex,
|
||||
copies=args.copies,
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
err_msg = f"{status_header}• *Status*: ❌ Verbindungsfehler zum Drucker: `{e}`"
|
||||
print(err_msg)
|
||||
notifier.update(err_msg)
|
||||
sys.exit(1)
|
||||
|
||||
# Check IPP status code
|
||||
status_code = res.get("status-code", -1)
|
||||
if status_code != 0:
|
||||
err_msg = f"{status_header}• *Status*: ❌ Drucker meldet Fehler-Code `{status_code}`."
|
||||
print(err_msg)
|
||||
notifier.update(err_msg)
|
||||
sys.exit(1)
|
||||
|
||||
# Extract Job ID
|
||||
jobs = res.get("jobs", [])
|
||||
job_id = jobs[0].get("job-id", "unbekannt") if jobs else "unbekannt"
|
||||
print(f"Print job queued. Job ID: {job_id}")
|
||||
|
||||
notifier.update(f"{status_header}• *Status*: 🖨️ [3/3] Auftrag übertragen (Job #{job_id}). Druckvorgang läuft...")
|
||||
|
||||
# 5. Monitor Job Progress (up to 30 seconds)
|
||||
if isinstance(job_id, int):
|
||||
for _ in range(15):
|
||||
time.sleep(2)
|
||||
try:
|
||||
attr = asyncio.run(printer.get_job_attributes(job_id))
|
||||
state = attr.get("job-state")
|
||||
# 9 = completed, 7 = canceled, 8 = aborted
|
||||
if state == 9:
|
||||
break
|
||||
elif state in (7, 8):
|
||||
notifier.update(f"{status_header}• *Status*: ⚠️ Auftrag #{job_id} wurde am Drucker abgebrochen.")
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Final success update
|
||||
final_msg = (
|
||||
f"{status_header}"
|
||||
f"• *Status*: ✅ **Erfolgreich gedruckt!**\n"
|
||||
f"• *Job-ID*: `#{job_id}`\n"
|
||||
f"• *Ausgabe*: Liegt im Ausgabefach bereit."
|
||||
)
|
||||
print("Druck erfolgreich abgeschlossen.")
|
||||
notifier.update(final_msg)
|
||||
|
||||
# Cleanup temp files
|
||||
for t in temp_files:
|
||||
try:
|
||||
os.remove(t)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main Entry Point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Brother HL-L8240CDW IPP Print Utility for OpenClaw")
|
||||
parser.add_argument("--host", default=DEFAULT_PRINTER_HOST, help="Printer IP or Hostname")
|
||||
parser.add_argument("--port", type=int, default=DEFAULT_PRINTER_PORT, help="IPP Port (default 631)")
|
||||
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
# Subcommand: status
|
||||
status_parser = subparsers.add_parser("status", help="Get printer status and toner levels")
|
||||
status_parser.add_argument("--json", action="store_true", help="Output as JSON")
|
||||
status_parser.set_defaults(func=handle_status)
|
||||
|
||||
# Subcommand: validate
|
||||
val_parser = subparsers.add_parser("validate", help="Dry-run validate print parameters")
|
||||
val_parser.add_argument("--color", choices=["monochrome", "color"], default="monochrome")
|
||||
val_parser.add_argument("--nup", type=int, choices=[1, 2, 4], default=1)
|
||||
val_parser.add_argument("--duplex", choices=["two-sided-long-edge", "two-sided-short-edge", "one-sided"], default="two-sided-long-edge")
|
||||
val_parser.add_argument("--copies", type=int, default=1)
|
||||
val_parser.set_defaults(func=handle_validate)
|
||||
|
||||
# Subcommand: print
|
||||
print_parser = subparsers.add_parser("print", help="Print a document")
|
||||
print_parser.add_argument("--file", help="Path to PDF file")
|
||||
print_parser.add_argument("--url", help="URL of PDF document")
|
||||
print_parser.add_argument("--text", help="Raw text to convert and print")
|
||||
print_parser.add_argument("--title", help="Job or document title")
|
||||
print_parser.add_argument("--color", choices=["monochrome", "color"], default="monochrome", help="Color mode (default: monochrome)")
|
||||
print_parser.add_argument("--nup", type=int, choices=[1, 2, 4], default=1, help="Pages per sheet (1, 2 or 4)")
|
||||
print_parser.add_argument("--duplex", choices=["two-sided-long-edge", "two-sided-short-edge", "one-sided"], default="two-sided-long-edge", help="Duplex mode (default: two-sided-long-edge)")
|
||||
print_parser.add_argument("--copies", type=int, default=1, help="Number of copies")
|
||||
print_parser.add_argument("--max-pages", type=int, default=30, help="Safety page limit (default 30)")
|
||||
print_parser.add_argument("--force", action="store_true", help="Bypass max pages guardrail")
|
||||
print_parser.add_argument("--chat-id", help="Telegram Chat ID for live updates")
|
||||
print_parser.add_argument("--thread-id", type=int, help="Telegram Thread / Topic ID (for forum supergroups)")
|
||||
print_parser.add_argument("--message-id", help="Telegram Message ID to edit (optional, created if omitted)")
|
||||
print_parser.set_defaults(func=handle_print)
|
||||
|
||||
args = parser.parse_args()
|
||||
args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,100 @@
|
||||
---
|
||||
name: chart-to-chat
|
||||
description: Generiert hochwertige Diagramme und Charts (Balken, Linien, Verlauf, Donut/Kuchen, KPI-Karten, Tabellen) aus strukturierten Chat-Daten und sendet sie direkt als Bild (PNG) nach Telegram. Basiert auf dbt-charts (lokales Rendering via dct) mit QuickChart.io-Fallback.
|
||||
---
|
||||
|
||||
# 📊 Chart-to-Chat: Diagramm- und Chart-Generator
|
||||
|
||||
Dieser Skill transformiert tabellarische Daten, Verläufe, Statistiken oder Kennzahlen direkt in fertige, publikationsreife **PNG-Grafiken**, die nativ per Telegram-Bildnachricht ausgeliefert werden.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Wann diesen Skill verwenden?
|
||||
|
||||
* Immer wenn der Nutzer nach einer Visualisierung fragt:
|
||||
* *„Zeig mir das als Diagramm / Chart / Balkendiagramm“*
|
||||
* *„Plotte mir den Verlauf von X über die letzten 7 Tage“*
|
||||
* *„Visualisiere meine Ausgaben / mein Training / meine Schritte“*
|
||||
* *„Erstelle eine Grafik / Kurve dazu“*
|
||||
* Bei Vergleichsdaten oder Verteilungen (z. B. Anteile, Kategorien, Monatsumsätze).
|
||||
* Für KPI-Zusammenfassungen mit Hauptwert und Delta.
|
||||
|
||||
---
|
||||
|
||||
## 📈 Welcher Chart-Typ passt wann?
|
||||
|
||||
| Chart-Typ (`--type`) | Bester Anwendungsfall | Beispiel |
|
||||
|---|---|---|
|
||||
| `bar` (Default) | Diskrete Kategorien, Rankings, Monatsvergleiche | Schritte pro Tag, Stunden pro Projekt |
|
||||
| `hbar` | Viele Kategorien mit langen Namen | Ranking der Supermärkte nach Ersparnis |
|
||||
| `line` | Zeitreihen, kontinuierliche Verläufe, Messwerte | Herzfrequenz, Temperaturverlauf, Gewicht, Watt |
|
||||
| `area` | Kumulierte Volumina oder Trends über Zeit | Datenverbrauch, kumulierter Regen, Trainingsvolumen |
|
||||
| `donut` / `pie` | Prozentuale Anteile an einem Ganzen (100 %) | Ausgaben nach Kategorie, Makronährstoff-Verteilung |
|
||||
| `kpi` | Einzelne prominente Kennzahl mit Kontext/Delta | Aktueller FTP, Monatsumsatz, Gesamtschritte |
|
||||
| `table` | Strukturierte Datentabelle mit formatierten Spalten | Vergleichstabelle von Produkten oder Tarifen |
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Ausführung
|
||||
|
||||
Führe das Skript `chart.py` via `uv run` aus (Cluster-Pfad: `/home/node/mop-skills/mop-skills/skills/chart-to-chat/scripts/chart.py`):
|
||||
|
||||
### 1. Daten direkt als JSON-String übergeben
|
||||
|
||||
```bash
|
||||
uv run /home/node/mop-skills/mop-skills/skills/chart-to-chat/scripts/chart.py \
|
||||
--title "Wöchentliche Laufdistanz" \
|
||||
--type bar \
|
||||
--data '[{"tag": "Mo", "km": 8.5}, {"tag": "Mi", "km": 12.0}, {"tag": "Fr", "km": 6.2}, {"tag": "So", "km": 16.5}]'
|
||||
```
|
||||
|
||||
### 2. Daten per Pipe (Standard-Eingabe) übergeben
|
||||
|
||||
Besonders empfohlen bei größeren Datenmengen oder um Shell-Escaping-Probleme zu vermeiden:
|
||||
|
||||
```bash
|
||||
cat << 'EOF' | uv run /home/node/mop-skills/mop-skills/skills/chart-to-chat/scripts/chart.py --title "Ruhepuls Trend" --type line --data -
|
||||
[
|
||||
{"datum": "12.09.", "puls": 54},
|
||||
{"datum": "13.09.", "puls": 52},
|
||||
{"datum": "14.09.", "puls": 55},
|
||||
{"datum": "15.09.", "puls": 53},
|
||||
{"datum": "16.09.", "puls": 51}
|
||||
]
|
||||
EOF
|
||||
```
|
||||
|
||||
### 3. Donut-Chart für Anteile / Verteilungen
|
||||
|
||||
```bash
|
||||
uv run /home/node/mop-skills/mop-skills/skills/chart-to-chat/scripts/chart.py \
|
||||
--title "Ausgaben September" \
|
||||
--type donut \
|
||||
--data '[{"kategorie": "Miete", "euro": 950}, {"kategorie": "Lebensmittel", "euro": 420}, {"kategorie": "Sport", "euro": 110}]'
|
||||
```
|
||||
|
||||
### 4. Optionen des CLI-Tools
|
||||
|
||||
- `--title "<Text>"`: Überschrift des Diagramms.
|
||||
- `--notes "<Text>"`: Optionale Unterzeile / Erläuterung.
|
||||
- `--type bar|hbar|line|area|donut|kpi|table`: Visualisierungstyp.
|
||||
- `--data "<JSON>"` oder `--data -` (Stdin): Eingabedaten.
|
||||
- `--x <Spaltenname>` / `--y <Spaltenname>`: Manuelle Angabe der Achsenspalten (wird ansonsten automatisch erkannt).
|
||||
- `--engine auto|dct|quickchart`: `auto` nutzt primär das lokale `dbt-charts` und fällt bei Bedarf transparent auf `QuickChart.io` zurück.
|
||||
|
||||
---
|
||||
|
||||
## 📱 Auslieferung an Telegram
|
||||
|
||||
Das Skript gibt als letzte Zeile den Pfad der erzeugten PNG-Datei im Format `MEDIA:<pfad>` aus, z. B.:
|
||||
```text
|
||||
MEDIA:/tmp/charts/chart_a1b2c3d4.png
|
||||
```
|
||||
|
||||
Schließe diese Zeile einfach in deine finale Antwort an den Nutzer ein. OpenClaw versendet das Diagramm dadurch automatisch als Bild im Chat:
|
||||
|
||||
```markdown
|
||||
Hier ist die Übersicht deiner Laufdistanzen für diese Woche:
|
||||
|
||||
MEDIA:/tmp/charts/chart_a1b2c3d4.png
|
||||
```
|
||||
@@ -0,0 +1,456 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
chart.py — Universal Chart & Graph Generator for OpenClaw / Telegram
|
||||
|
||||
Generates clean, publication-grade chart images from JSON/CSV data using:
|
||||
1. Primary: dbt-charts (`dct render`) — local, offline, Vega-Lite/resvg based.
|
||||
2. Fallback: QuickChart.io — fast HTTP API (Chart.js), zero local dependencies.
|
||||
|
||||
Outputs `MEDIA:<path>` on stdout for native Telegram image delivery.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
|
||||
|
||||
KNOWN_DCT_PATHS = [
|
||||
Path("/home/node/.openclaw/.local/bin/dct"),
|
||||
Path("/home/openclaw/.local/bin/dct"),
|
||||
Path("/home/node/.openclaw/.local/share/uv/tools/dbt-charts/bin/dct"),
|
||||
Path("/home/openclaw/.local/share/uv/tools/dbt-charts/bin/dct"),
|
||||
Path.home() / ".local/bin/dct",
|
||||
]
|
||||
|
||||
|
||||
def find_dct_executable() -> Optional[str]:
|
||||
"""Locate the dct executable in PATH or well-known install locations."""
|
||||
# 1. System PATH
|
||||
found = shutil.which("dct")
|
||||
if found:
|
||||
return found
|
||||
|
||||
# 2. Well-known absolute container/local paths
|
||||
for candidate in KNOWN_DCT_PATHS:
|
||||
if candidate.is_file() and os.access(candidate, os.X_OK):
|
||||
return str(candidate)
|
||||
|
||||
# 3. Check if uv tool run dct works
|
||||
uv_path = shutil.which("uv") or "/home/node/.openclaw/bin/uv"
|
||||
if os.path.exists(uv_path) and os.access(uv_path, os.X_OK):
|
||||
try:
|
||||
res = subprocess.run(
|
||||
[uv_path, "tool", "run", "dct", "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if res.returncode == 0:
|
||||
return "uv_tool_run"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def parse_input_data(raw_data: str) -> Tuple[List[str], List[List[Any]], Optional[str], Optional[str]]:
|
||||
"""
|
||||
Parse input string (JSON or CSV) into (columns, rows, suggested_x, suggested_y).
|
||||
Supports:
|
||||
- List of dicts: [{"label": "A", "val": 10}, {"label": "B", "val": 20}]
|
||||
- Dict of values: {"A": 10, "B": 20}
|
||||
- Explicit table: {"columns": ["k", "v"], "values": [["A", 10], ["B", 20]]}
|
||||
- Single KPI: {"value": 1284, "label": "Revenue", "delta": "+12%"}
|
||||
"""
|
||||
raw_data = raw_data.strip()
|
||||
if not raw_data:
|
||||
raise ValueError("Input data is empty.")
|
||||
|
||||
# Try JSON
|
||||
parsed = json.loads(raw_data)
|
||||
|
||||
# Case 1: Dict with explicit columns & values
|
||||
if isinstance(parsed, dict) and "columns" in parsed and ("values" in parsed or "rows" in parsed):
|
||||
cols = [str(c) for c in parsed["columns"]]
|
||||
rows = parsed.get("values") or parsed.get("rows") or []
|
||||
x_col = cols[0] if len(cols) > 0 else None
|
||||
y_col = cols[1] if len(cols) > 1 else None
|
||||
return cols, rows, x_col, y_col
|
||||
|
||||
# Case 2: Dict of key-value pairs (e.g. {"Mo": 10, "Di": 15})
|
||||
if isinstance(parsed, dict) and not any(k in parsed for k in ["columns", "values", "rows", "type", "chart"]):
|
||||
cols = ["category", "value"]
|
||||
rows = [[k, v] for k, v in parsed.items()]
|
||||
return cols, rows, "category", "value"
|
||||
|
||||
# Case 3: List of dicts (most common agent format)
|
||||
if isinstance(parsed, list):
|
||||
if not parsed:
|
||||
raise ValueError("Data list is empty.")
|
||||
if isinstance(parsed[0], dict):
|
||||
# Collect unique keys maintaining insertion order
|
||||
cols: List[str] = []
|
||||
for item in parsed:
|
||||
for k in item.keys():
|
||||
if k not in cols:
|
||||
cols.append(k)
|
||||
|
||||
rows: List[List[Any]] = []
|
||||
for item in parsed:
|
||||
rows.append([item.get(c) for c in cols])
|
||||
|
||||
# Infer x (category/date) and y (measure)
|
||||
x_col = None
|
||||
y_col = None
|
||||
for c in cols:
|
||||
low = c.lower()
|
||||
if low in ["x", "label", "name", "date", "datum", "month", "monat", "day", "tag", "category", "kategorie", "typ", "woche", "week"]:
|
||||
x_col = c
|
||||
break
|
||||
if not x_col and len(cols) > 0:
|
||||
x_col = cols[0]
|
||||
|
||||
for c in cols:
|
||||
if c == x_col:
|
||||
continue
|
||||
low = c.lower()
|
||||
if low in ["y", "value", "wert", "count", "anzahl", "amount", "menge", "total", "summe", "preis", "price", "revenue", "umsatz", "km", "kmh", "watt", "hr", "puls"]:
|
||||
y_col = c
|
||||
break
|
||||
if not y_col and len(cols) > 1:
|
||||
y_col = cols[1]
|
||||
|
||||
return cols, rows, x_col, y_col
|
||||
|
||||
elif isinstance(parsed[0], list):
|
||||
# List of lists without explicit headers
|
||||
cols = [f"col_{i+1}" for i in range(len(parsed[0]))]
|
||||
return cols, parsed, cols[0], cols[1] if len(cols) > 1 else None
|
||||
|
||||
raise ValueError(f"Unrecognized data structure: {type(parsed)}")
|
||||
|
||||
|
||||
def render_dbt_chart(
|
||||
dct_cmd: str,
|
||||
title: str,
|
||||
chart_type: str,
|
||||
columns: List[str],
|
||||
rows: List[List[Any]],
|
||||
x_col: str,
|
||||
y_col: str,
|
||||
output_path: Path,
|
||||
notes: Optional[str] = None,
|
||||
theme: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Render a chart using dbt-charts (dct render)."""
|
||||
scratch_dir = Path(tempfile.mkdtemp(prefix="dct_render_"))
|
||||
try:
|
||||
# 1. dbt_charts.yml project file (mandatory for dct)
|
||||
(scratch_dir / "dbt_charts.yml").write_text("# dbt charts project\nversion: 1\n", encoding="utf-8")
|
||||
|
||||
# Normalize chart type
|
||||
ct = chart_type.lower()
|
||||
if ct in ["hbar", "horizontal_bar"]:
|
||||
ct = "bar"
|
||||
is_horizontal = True
|
||||
else:
|
||||
is_horizontal = False
|
||||
|
||||
if ct in ["donut", "pie"]:
|
||||
dbt_type = "donut"
|
||||
elif ct in ["line", "trend"]:
|
||||
dbt_type = "line"
|
||||
elif ct in ["area"]:
|
||||
dbt_type = "area"
|
||||
elif ct in ["kpi"]:
|
||||
dbt_type = "kpi"
|
||||
elif ct in ["table"]:
|
||||
dbt_type = "table"
|
||||
else:
|
||||
dbt_type = "bar"
|
||||
|
||||
# Build board YAML
|
||||
board: Dict[str, Any] = {
|
||||
"title": title or "Dashboard",
|
||||
"queries": {
|
||||
"dataset": {
|
||||
"columns": columns,
|
||||
"values": rows,
|
||||
}
|
||||
},
|
||||
"charts": {},
|
||||
"rows": ["main_chart"],
|
||||
}
|
||||
|
||||
if notes:
|
||||
board["notes"] = notes
|
||||
|
||||
chart_spec: Dict[str, Any] = {
|
||||
"type": dbt_type,
|
||||
"query": "dataset",
|
||||
}
|
||||
if title:
|
||||
chart_spec["title"] = title
|
||||
|
||||
if dbt_type in ["donut", "pie"]:
|
||||
chart_spec["theta"] = y_col
|
||||
chart_spec["color"] = x_col
|
||||
elif dbt_type == "kpi":
|
||||
chart_spec["value"] = y_col or columns[0]
|
||||
chart_spec["label"] = x_col or "Value"
|
||||
elif dbt_type == "table":
|
||||
# Table automatically uses query columns
|
||||
pass
|
||||
else:
|
||||
# Bar / Line / Area
|
||||
if is_horizontal:
|
||||
chart_spec["x"] = y_col
|
||||
chart_spec["y"] = x_col
|
||||
else:
|
||||
chart_spec["x"] = x_col
|
||||
chart_spec["y"] = y_col
|
||||
|
||||
board["charts"]["main_chart"] = chart_spec
|
||||
|
||||
import yaml # PyYAML is standard in python / uv environments
|
||||
board_file = scratch_dir / "board.yml"
|
||||
board_file.write_text(yaml.safe_dump(board, sort_keys=False), encoding="utf-8")
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Build command
|
||||
if dct_cmd == "uv_tool_run":
|
||||
cmd = ["uv", "tool", "run", "dct", "render", str(board_file), "--format", "png", "--output", str(output_path)]
|
||||
else:
|
||||
cmd = [dct_cmd, "render", str(board_file), "--format", "png", "--output", str(output_path)]
|
||||
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
cwd=str(scratch_dir),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
if proc.returncode == 0 and output_path.exists() and output_path.stat().st_size > 0:
|
||||
return True
|
||||
else:
|
||||
sys.stderr.write(f"[dct render warning] exit={proc.returncode} stderr={proc.stderr.strip()}\n")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"[dct render error] {e}\n")
|
||||
return False
|
||||
finally:
|
||||
shutil.rmtree(scratch_dir, ignore_errors=True)
|
||||
|
||||
|
||||
def render_quickchart(
|
||||
title: str,
|
||||
chart_type: str,
|
||||
columns: List[str],
|
||||
rows: List[List[Any]],
|
||||
x_col: str,
|
||||
y_col: str,
|
||||
output_path: Path,
|
||||
width: int = 800,
|
||||
height: int = 450,
|
||||
) -> bool:
|
||||
"""Render chart via QuickChart.io API as fallback."""
|
||||
try:
|
||||
x_idx = columns.index(x_col)
|
||||
y_idx = columns.index(y_col)
|
||||
|
||||
labels = [str(r[x_idx]) for r in rows]
|
||||
values = []
|
||||
for r in rows:
|
||||
v = r[y_idx]
|
||||
try:
|
||||
values.append(float(v) if v is not None else 0)
|
||||
except (ValueError, TypeError):
|
||||
values.append(0)
|
||||
|
||||
ct = chart_type.lower()
|
||||
if ct in ["donut", "pie"]:
|
||||
qc_type = "doughnut" if ct == "donut" else "pie"
|
||||
bg_colors = [
|
||||
"#3366cc", "#dc3912", "#ff9900", "#109618", "#990099",
|
||||
"#3b3eac", "#0099c6", "#dd4477", "#66aa00", "#b82e2e",
|
||||
]
|
||||
dataset = {
|
||||
"data": values,
|
||||
"backgroundColor": bg_colors[:len(values)],
|
||||
}
|
||||
elif ct in ["line", "trend"]:
|
||||
qc_type = "line"
|
||||
dataset = {
|
||||
"label": y_col,
|
||||
"data": values,
|
||||
"borderColor": "#3e64ff",
|
||||
"backgroundColor": "rgba(62, 100, 255, 0.1)",
|
||||
"fill": False,
|
||||
"tension": 0.2,
|
||||
}
|
||||
elif ct in ["area"]:
|
||||
qc_type = "line"
|
||||
dataset = {
|
||||
"label": y_col,
|
||||
"data": values,
|
||||
"borderColor": "#3e64ff",
|
||||
"backgroundColor": "rgba(62, 100, 255, 0.25)",
|
||||
"fill": True,
|
||||
"tension": 0.2,
|
||||
}
|
||||
else:
|
||||
qc_type = "bar"
|
||||
dataset = {
|
||||
"label": y_col,
|
||||
"data": values,
|
||||
"backgroundColor": "#3e64ff",
|
||||
}
|
||||
|
||||
chart_config = {
|
||||
"type": qc_type,
|
||||
"data": {
|
||||
"labels": labels,
|
||||
"datasets": [dataset],
|
||||
},
|
||||
"options": {
|
||||
"title": {
|
||||
"display": bool(title),
|
||||
"text": title,
|
||||
"fontSize": 18,
|
||||
},
|
||||
"legend": {
|
||||
"display": qc_type in ["pie", "doughnut"],
|
||||
"position": "top",
|
||||
},
|
||||
"plugins": {
|
||||
"datalabels": {
|
||||
"display": False,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
query_params = {
|
||||
"w": str(width),
|
||||
"h": str(height),
|
||||
"devicePixelRatio": "2.0",
|
||||
"c": json.dumps(chart_config),
|
||||
}
|
||||
url = "https://quickchart.io/chart?" + urllib.parse.urlencode(query_params)
|
||||
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "OpenClaw-Chart/1.0"})
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
if resp.status == 200:
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_bytes(resp.read())
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"[quickchart error] {e}\n")
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="OpenClaw Universal Chart Generator")
|
||||
parser.add_argument("--data", help="JSON data string, or '-' for stdin")
|
||||
parser.add_argument("--file", help="Path to JSON or CSV file")
|
||||
parser.add_argument("--type", default="bar", choices=["bar", "hbar", "line", "area", "donut", "pie", "kpi", "table"], help="Chart type")
|
||||
parser.add_argument("--title", default="Übersicht", help="Chart or Dashboard title")
|
||||
parser.add_argument("--notes", help="Subtitle or explanatory notes")
|
||||
parser.add_argument("--x", help="X-axis / category column name")
|
||||
parser.add_argument("--y", help="Y-axis / measure column name")
|
||||
parser.add_argument("--output", help="Target PNG path (default: /tmp/charts/chart_<hash>.png)")
|
||||
parser.add_argument("--engine", default="auto", choices=["auto", "dct", "quickchart"], help="Rendering engine")
|
||||
parser.add_argument("--width", type=int, default=800, help="Width in pixels (QuickChart)")
|
||||
parser.add_argument("--height", type=int, default=450, help="Height in pixels (QuickChart)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Read data
|
||||
raw_data = None
|
||||
if args.data and args.data != "-":
|
||||
raw_data = args.data
|
||||
elif args.file:
|
||||
raw_data = Path(args.file).read_text(encoding="utf-8")
|
||||
elif args.data == "-" or not sys.stdin.isatty():
|
||||
raw_data = sys.stdin.read()
|
||||
|
||||
if not raw_data:
|
||||
parser.error("No input data provided. Use --data, --file, or pipe data via STDIN.")
|
||||
|
||||
try:
|
||||
columns, rows, inferred_x, inferred_y = parse_input_data(raw_data)
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"Error parsing input data: {e}\n")
|
||||
sys.exit(1)
|
||||
|
||||
x_col = args.x or inferred_x or (columns[0] if columns else "x")
|
||||
y_col = args.y or inferred_y or (columns[1] if len(columns) > 1 else columns[0])
|
||||
|
||||
# Destination path
|
||||
if args.output:
|
||||
output_path = Path(args.output)
|
||||
else:
|
||||
content_hash = hashlib.md5(f"{args.title}_{args.type}_{raw_data}".encode("utf-8")).hexdigest()[:8]
|
||||
output_path = Path(f"/tmp/charts/chart_{content_hash}.png")
|
||||
|
||||
rendered = False
|
||||
|
||||
# Attempt 1: dbt Charts (unless engine is explicitly quickchart)
|
||||
if args.engine in ["auto", "dct"]:
|
||||
dct_bin = find_dct_executable()
|
||||
if dct_bin:
|
||||
rendered = render_dbt_chart(
|
||||
dct_cmd=dct_bin,
|
||||
title=args.title,
|
||||
chart_type=args.type,
|
||||
columns=columns,
|
||||
rows=rows,
|
||||
x_col=x_col,
|
||||
y_col=y_col,
|
||||
output_path=output_path,
|
||||
notes=args.notes,
|
||||
)
|
||||
elif args.engine == "dct":
|
||||
sys.stderr.write("Error: dct executable not found and --engine dct was requested.\n")
|
||||
sys.exit(1)
|
||||
|
||||
# Attempt 2: QuickChart Fallback
|
||||
if not rendered and args.engine in ["auto", "quickchart"]:
|
||||
rendered = render_quickchart(
|
||||
title=args.title,
|
||||
chart_type=args.type,
|
||||
columns=columns,
|
||||
rows=rows,
|
||||
x_col=x_col,
|
||||
y_col=y_col,
|
||||
output_path=output_path,
|
||||
width=args.width,
|
||||
height=args.height,
|
||||
)
|
||||
|
||||
if not rendered:
|
||||
sys.stderr.write("Error: Chart rendering failed on all available engines.\n")
|
||||
sys.exit(1)
|
||||
|
||||
# Deliver MEDIA line for Telegram/OpenClaw
|
||||
print(f"MEDIA:{output_path.resolve()}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,133 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Unit and integration tests for chart.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
# Add scripts directory to path
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
|
||||
from chart import parse_input_data
|
||||
|
||||
|
||||
class TestChartParser(unittest.TestCase):
|
||||
def test_list_of_dicts(self):
|
||||
raw = json.dumps([
|
||||
{"day": "Mo", "temp": 18},
|
||||
{"day": "Di", "temp": 22},
|
||||
{"day": "Mi", "temp": 19},
|
||||
])
|
||||
cols, rows, x, y = parse_input_data(raw)
|
||||
self.assertEqual(cols, ["day", "temp"])
|
||||
self.assertEqual(len(rows), 3)
|
||||
self.assertEqual(x, "day")
|
||||
self.assertEqual(y, "temp")
|
||||
|
||||
def test_dict_key_value(self):
|
||||
raw = json.dumps({"Bier": 4.5, "Pizza": 12.0, "Kaffee": 3.2})
|
||||
cols, rows, x, y = parse_input_data(raw)
|
||||
self.assertEqual(cols, ["category", "value"])
|
||||
self.assertEqual(len(rows), 3)
|
||||
self.assertEqual(x, "category")
|
||||
self.assertEqual(y, "value")
|
||||
|
||||
def test_explicit_columns_values(self):
|
||||
raw = json.dumps({
|
||||
"columns": ["Monat", "Umsatz"],
|
||||
"values": [["Jan", 100], ["Feb", 150]]
|
||||
})
|
||||
cols, rows, x, y = parse_input_data(raw)
|
||||
self.assertEqual(cols, ["Monat", "Umsatz"])
|
||||
self.assertEqual(len(rows), 2)
|
||||
self.assertEqual(x, "Monat")
|
||||
self.assertEqual(y, "Umsatz")
|
||||
|
||||
|
||||
class TestChartCLI(unittest.TestCase):
|
||||
def run_cli(self, args, stdin_input=None):
|
||||
cmd = [sys.executable, str(SCRIPTS_DIR / "chart.py")] + args
|
||||
res = subprocess.run(
|
||||
cmd,
|
||||
input=stdin_input,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
return res
|
||||
|
||||
def test_bar_chart_generation(self):
|
||||
data = json.dumps([
|
||||
{"label": "Projekt A", "stunden": 14},
|
||||
{"label": "Projekt B", "stunden": 28},
|
||||
{"label": "Projekt C", "stunden": 8},
|
||||
])
|
||||
out_path = Path("/tmp/charts/test_bar.png")
|
||||
if out_path.exists():
|
||||
out_path.unlink()
|
||||
|
||||
res = self.run_cli(["--type", "bar", "--title", "Projektstunden", "--data", data, "--output", str(out_path)])
|
||||
self.assertEqual(res.returncode, 0, f"Stderr: {res.stderr}")
|
||||
self.assertTrue(res.stdout.strip().startswith("MEDIA:"))
|
||||
self.assertTrue(out_path.exists())
|
||||
self.assertGreater(out_path.stat().st_size, 1000)
|
||||
|
||||
def test_line_chart_stdin(self):
|
||||
data = json.dumps([
|
||||
{"datum": "2026-09-10", "puls": 62},
|
||||
{"datum": "2026-09-11", "puls": 58},
|
||||
{"datum": "2026-09-12", "puls": 65},
|
||||
{"datum": "2026-09-13", "puls": 59},
|
||||
])
|
||||
out_path = Path("/tmp/charts/test_line.png")
|
||||
if out_path.exists():
|
||||
out_path.unlink()
|
||||
|
||||
res = self.run_cli(["--type", "line", "--title", "Ruhepuls Verlauf", "--data", "-", "--output", str(out_path)], stdin_input=data)
|
||||
self.assertEqual(res.returncode, 0, f"Stderr: {res.stderr}")
|
||||
self.assertTrue(res.stdout.strip().startswith("MEDIA:"))
|
||||
self.assertTrue(out_path.exists())
|
||||
self.assertGreater(out_path.stat().st_size, 1000)
|
||||
|
||||
def test_donut_chart(self):
|
||||
data = json.dumps([
|
||||
{"bereich": "Wohnen", "kosten": 950},
|
||||
{"bereich": "Essen", "kosten": 450},
|
||||
{"bereich": "Mobilität", "kosten": 120},
|
||||
{"bereich": "Freizeit", "kosten": 200},
|
||||
])
|
||||
out_path = Path("/tmp/charts/test_donut.png")
|
||||
if out_path.exists():
|
||||
out_path.unlink()
|
||||
|
||||
res = self.run_cli(["--type", "donut", "--title", "Ausgaben", "--data", data, "--output", str(out_path)])
|
||||
self.assertEqual(res.returncode, 0, f"Stderr: {res.stderr}")
|
||||
self.assertTrue(res.stdout.strip().startswith("MEDIA:"))
|
||||
self.assertTrue(out_path.exists())
|
||||
self.assertGreater(out_path.stat().st_size, 1000)
|
||||
|
||||
def test_quickchart_fallback_engine(self):
|
||||
data = json.dumps([
|
||||
{"fach": "Mathe", "note": 1.7},
|
||||
{"fach": "Physik", "note": 2.0},
|
||||
{"fach": "Informatik", "note": 1.0},
|
||||
])
|
||||
out_path = Path("/tmp/charts/test_quickchart.png")
|
||||
if out_path.exists():
|
||||
out_path.unlink()
|
||||
|
||||
res = self.run_cli(["--engine", "quickchart", "--type", "bar", "--title", "Notenspiegel", "--data", data, "--output", str(out_path)])
|
||||
self.assertEqual(res.returncode, 0, f"Stderr: {res.stderr}")
|
||||
self.assertTrue(res.stdout.strip().startswith("MEDIA:"))
|
||||
self.assertTrue(out_path.exists())
|
||||
self.assertGreater(out_path.stat().st_size, 1000)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,150 @@
|
||||
---
|
||||
name: create-website
|
||||
description: Build and host fancy, production-grade, interactive single-page websites, SPAs, and HTML showcases using modern Zero-Build technologies (Tailwind CSS, Alpine.js, Lucide Icons). Use when the user asks to create, build, design, or show a website, landing page, interactive calculator, showcase, recipe app, dashboard, or web tool.
|
||||
---
|
||||
|
||||
# Create Website & Interactive Single-Page Applications 🌐✨
|
||||
|
||||
This skill empowers OpenClaw to act as a **Creative Web Director and Senior Frontend Engineer**. It generates visually stunning, responsive, fully interactive single-page applications (SPAs) contained within a single HTML file—without requiring complex build steps or Node.js compilers.
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Core Philosophy: The Zero-Build Reactive Engine
|
||||
|
||||
1. **Self-Contained Single-File**: HTML markup, Tailwind CSS utility styling, Alpine.js reactivity, and Lucide icons reside in a single standalone file.
|
||||
2. **Zero-Build Latency**: Runs directly in the browser via fast CDNs. Zero compile time, 100% reliable execution across desktop, tablet, and mobile (iOS Safari, Telegram in-app browser).
|
||||
3. **Anti-AI-Slop Aesthetics**: Reject generic AI defaults (no plain Inter font, no purple-to-blue gradients, no boring centered 3-card monotony). Every site must have an intentional aesthetic direction with strong typography, depth, and micro-interactions.
|
||||
|
||||
---
|
||||
|
||||
## 🎨 1. Choose a Visual Archetype
|
||||
|
||||
Before writing code, commit strictly to one of the 4 design archetypes (detailed in [references/archetypes.md](references/archetypes.md)):
|
||||
|
||||
* **Dark Obsidian & Cyber-Luxury**: Deep dark canvas (`#090A0F`), glassmorphism cards (`backdrop-blur-xl border border-white/10`), electric cyan/violet/emerald glows. Fonts: *Syne* + *Plus Jakarta Sans*. (Ideal for tech showcases, tools, AI, fitness trackers).
|
||||
* **Editorial Warmth & Swiss Typographic**: Linen/ivory background (`#FDFBF7`), hairline borders (`border-stone-200`), warm terracotta/forest green accents. Fonts: *Fraunces* (Serif) + *Instrument Sans*. (Ideal for research dossiers, recipes, long-form stories).
|
||||
* **Neo-Brutalism & High-Energy**: High contrast, thick black borders (`border-2 border-black`), offset hard drop shadows (`shadow-[4px_4px_0px_0px_#000]`), vivid canary yellow and lime accents. Fonts: *Space Grotesk* + *JetBrains Mono*. (Ideal for events, party invites, challenges, games).
|
||||
* **Modern Clean SaaS & Dynamic Dashboard**: Crisp slate canvas, automatic dark/light mode toggle via Alpine, KPI cards, filterable tables. Fonts: *Outfit* + *Plus Jakarta Sans*. (Ideal for data apps, comparisons, calculators).
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 2. The Standard CDN Skeleton
|
||||
|
||||
Use this bulletproof template structure for all generated websites:
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="de" x-data="{ darkMode: localStorage.getItem('theme') === 'dark' }" :class="{ 'dark': darkMode }">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Titel der Website</title>
|
||||
|
||||
<!-- 1. Google Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700&family=Syne:wght@600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- 2. Tailwind CSS v3 Play CDN -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
heading: ['Syne', 'sans-serif'],
|
||||
sans: ['Plus Jakarta Sans', 'sans-serif'],
|
||||
mono: ['JetBrains Mono', 'monospace'],
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- 3. Alpine.js for snappy reactivity -->
|
||||
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
|
||||
<!-- 4. Lucide Icons CDN -->
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
|
||||
<!-- 5. Optional Confetti for Micro-Delight -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/canvas-confetti@1.9.2/dist/confetti.browser.min.js"></script>
|
||||
</head>
|
||||
<body class="bg-[#090A0F] text-zinc-100 font-sans min-h-screen flex flex-col antialiased selection:bg-cyan-500/30 selection:text-cyan-200">
|
||||
|
||||
<!-- Main Application Content (Tabs, Filters, Interactive Cards, Calculators) -->
|
||||
<main class="flex-1 max-w-6xl w-full mx-auto px-4 py-8 md:py-16">
|
||||
<!-- Component Code Here -->
|
||||
</main>
|
||||
|
||||
<!-- 6. Lucide Icons Initialization (Always at the end of body) -->
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
lucide.createIcons();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚡ 3. Add Interactivity with Alpine.js
|
||||
|
||||
Review [references/alpine-patterns.md](references/alpine-patterns.md) for pre-built patterns:
|
||||
* **Dynamic Tabs**: Seamless view switching without page reloads.
|
||||
* **Instant Search & Filter**: Real-time filtering of cards/recipes/items based on text input or category pills.
|
||||
* **Reactive Calculators / Sliders**: Live computation (portions, prices, nutrition, splits) with instant UI updates.
|
||||
* **Modal Dialogs & Drawers**: Smooth transitions with backdrop blur and escape key handling.
|
||||
* **Celebration Effects**: Triggering confetti on successful calculations or milestones (`confetti({ particleCount: 80, spread: 70 })`).
|
||||
|
||||
---
|
||||
|
||||
## 🚀 4. Publishing & Sharing Workflow
|
||||
|
||||
Once the website HTML has been generated and saved locally (e.g. to `/tmp/websites/<slug>.html`), publish it to public hosting:
|
||||
|
||||
### Step 1: Run the Publisher Script
|
||||
```bash
|
||||
uv run /home/node/mop-skills/mop-skills/skills/create-website/scripts/publish_website.py \
|
||||
--file /tmp/websites/<slug>.html \
|
||||
--category showcase \
|
||||
--slug <slug>
|
||||
```
|
||||
|
||||
This validates the HTML and uploads it to MinIO, returning the public HTTPS URL:
|
||||
`https://hosting.haumdaucher.de/showcase/{YYYYMMDD}-{slug}.html`
|
||||
|
||||
### Step 2: Present to User in Telegram
|
||||
Always deliver both the clickable link and the local file for offline access:
|
||||
```markdown
|
||||
🚀 **Deine Website ist live!**
|
||||
🔗 https://hosting.haumdaucher.de/showcase/20260928-ninja-creami-calc.html
|
||||
|
||||
*(Die Datei ist zum lokalen Öffnen ebenfalls beigefügt)*
|
||||
MEDIA:/tmp/websites/<slug>.html
|
||||
```
|
||||
|
||||
### Step 3: Offer QR Code
|
||||
Immediately trigger `ask_user` to offer generating a scannable QR code:
|
||||
```json
|
||||
{
|
||||
"question": "Möchtest du für diesen Link einen QR-Code generieren?",
|
||||
"options": ["Ja, QR-Code erstellen", "Nein, danke"]
|
||||
}
|
||||
```
|
||||
If approved, execute the `qr-code` skill:
|
||||
```bash
|
||||
uv run /home/node/mop-skills/mop-skills/skills/qr-code/scripts/generate_qr.py "https://hosting.haumdaucher.de/showcase/20260928-ninja-creami-calc.html"
|
||||
```
|
||||
and send via `MEDIA:<path>`.
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ Quality Gate & Anti-Slop Checklist
|
||||
Always check the site against [references/anti-slop-checklist.md](references/anti-slop-checklist.md) before publishing:
|
||||
1. Are font pairings distinct and imported via Google Fonts?
|
||||
2. Are all buttons responsive with hover and active states (`active:scale-95`)?
|
||||
3. Does the layout scale gracefully on small mobile screens (`grid-cols-1 md:grid-cols-2 lg:grid-cols-3`)?
|
||||
4. Are Lucide icons properly rendered via `lucide.createIcons()`?
|
||||
@@ -0,0 +1,210 @@
|
||||
# Alpine.js Core Patterns for Single-Page Applications ⚡
|
||||
|
||||
These zero-build Alpine.js patterns provide reactive state management directly in HTML. Use these snippets to make your single-page websites snappy, interactive, and responsive.
|
||||
|
||||
---
|
||||
|
||||
## 1. Dark/Light Mode Switcher with Persistence
|
||||
Place this in `<html x-data="{ darkMode: localStorage.getItem('theme') === 'dark' }" :class="{ 'dark': darkMode }">`:
|
||||
|
||||
```html
|
||||
<button
|
||||
@click="darkMode = !darkMode; localStorage.setItem('theme', darkMode ? 'dark' : 'light')"
|
||||
class="p-2.5 rounded-xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-all cursor-pointer"
|
||||
title="Toggle Theme">
|
||||
<i x-show="!darkMode" data-lucide="moon" class="w-5 h-5"></i>
|
||||
<i x-show="darkMode" data-lucide="sun" class="w-5 h-5"></i>
|
||||
</button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Dynamic Tab Navigation
|
||||
Switch between multiple views without page reloads:
|
||||
|
||||
```html
|
||||
<div x-data="{ activeTab: 'overview' }" class="w-full">
|
||||
<!-- Tab Bar -->
|
||||
<div class="flex gap-2 p-1.5 bg-zinc-100 dark:bg-zinc-900 rounded-xl max-w-md mx-auto mb-8 border border-zinc-200 dark:border-zinc-800">
|
||||
<button
|
||||
@click="activeTab = 'overview'"
|
||||
:class="activeTab === 'overview' ? 'bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white shadow-sm' : 'text-zinc-500 hover:text-zinc-900 dark:hover:text-white'"
|
||||
class="flex-1 py-2 px-4 rounded-lg font-medium text-sm transition-all flex items-center justify-center gap-2">
|
||||
<i data-lucide="layout-grid" class="w-4 h-4"></i> Overview
|
||||
</button>
|
||||
<button
|
||||
@click="activeTab = 'calculator'"
|
||||
:class="activeTab === 'calculator' ? 'bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white shadow-sm' : 'text-zinc-500 hover:text-zinc-900 dark:hover:text-white'"
|
||||
class="flex-1 py-2 px-4 rounded-lg font-medium text-sm transition-all flex items-center justify-center gap-2">
|
||||
<i data-lucide="calculator" class="w-4 h-4"></i> Calculator
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab Contents with Transitions -->
|
||||
<div x-show="activeTab === 'overview'" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0 translate-y-2" x-transition:enter-end="opacity-100 translate-y-0">
|
||||
<!-- Overview View Content -->
|
||||
</div>
|
||||
|
||||
<div x-show="activeTab === 'calculator'" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0 translate-y-2" x-transition:enter-end="opacity-100 translate-y-0">
|
||||
<!-- Calculator View Content -->
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Instant Search & Category Filter
|
||||
Real-time filtering of cards or list items:
|
||||
|
||||
```html
|
||||
<div x-data="{
|
||||
search: '',
|
||||
activeCategory: 'all',
|
||||
items: [
|
||||
{ title: 'Pistazien Gelato', category: 'creami', protein: '28g', calories: 340, desc: 'Reichhaltiges Gelato mit echter sizilianischer Pistazienpaste.' },
|
||||
{ title: 'Protein Erdbeer Sorbet', category: 'protein', protein: '35g', calories: 210, desc: 'Frisches Erdbeersorbet mit Clear Whey Isolat.' },
|
||||
{ title: 'Mango Maracuja Slush', category: 'fruit', protein: '2g', calories: 140, desc: 'Perfekte Sommererfrischung mit Mango-Chunks.' }
|
||||
],
|
||||
get filteredItems() {
|
||||
return this.items.filter(item => {
|
||||
const matchesSearch = item.title.toLowerCase().includes(this.search.toLowerCase()) || item.desc.toLowerCase().includes(this.search.toLowerCase());
|
||||
const matchesCategory = this.activeCategory === 'all' || item.category === this.activeCategory;
|
||||
return matchesSearch && matchesCategory;
|
||||
});
|
||||
}
|
||||
}">
|
||||
<!-- Search & Pills -->
|
||||
<div class="flex flex-col sm:flex-row gap-4 mb-8">
|
||||
<div class="relative flex-1">
|
||||
<i data-lucide="search" class="w-4 h-4 absolute left-3.5 top-3.5 text-zinc-400"></i>
|
||||
<input
|
||||
x-model="search"
|
||||
type="text"
|
||||
placeholder="Rezepte durchsuchen..."
|
||||
class="w-full pl-10 pr-4 py-2.5 rounded-xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-cyan-500 text-sm">
|
||||
</div>
|
||||
<div class="flex gap-2 overflow-x-auto pb-1">
|
||||
<template x-for="cat in ['all', 'creami', 'protein', 'fruit']" :key="cat">
|
||||
<button
|
||||
@click="activeCategory = cat"
|
||||
:class="activeCategory === cat ? 'bg-cyan-500 text-white font-semibold' : 'bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-200'"
|
||||
class="px-4 py-2 rounded-xl text-xs uppercase tracking-wider capitalize whitespace-nowrap transition-all cursor-pointer"
|
||||
x-text="cat === 'all' ? 'Alle Rezepte' : cat">
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cards Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<template x-for="item in filteredItems" :key="item.title">
|
||||
<div class="p-6 rounded-2xl bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 hover:border-cyan-500/50 shadow-sm hover:shadow-xl transition-all">
|
||||
<h3 class="text-lg font-bold text-zinc-900 dark:text-white mb-2" x-text="item.title"></h3>
|
||||
<p class="text-sm text-zinc-500 dark:text-zinc-400 mb-4" x-text="item.desc"></p>
|
||||
<div class="flex justify-between items-center text-xs font-semibold text-cyan-600 dark:text-cyan-400">
|
||||
<span x-text="item.protein + ' Protein'"></span>
|
||||
<span x-text="item.calories + ' kcal'"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Live Reactive Slider & Calculator
|
||||
Instant calculations with automatic UI updates:
|
||||
|
||||
```html
|
||||
<div x-data="{
|
||||
pints: 1,
|
||||
baseMilk: 300,
|
||||
baseWhey: 35,
|
||||
basePistachio: 30,
|
||||
get milk() { return this.baseMilk * this.pints; },
|
||||
get whey() { return this.baseWhey * this.pints; },
|
||||
get pistachio() { return this.basePistachio * this.pints; },
|
||||
get proteinTotal() { return Math.round(this.whey * 0.8 + (this.milk * 0.034)); },
|
||||
celebrate() {
|
||||
if (typeof confetti === 'function') {
|
||||
confetti({ particleCount: 75, spread: 60, origin: { y: 0.8 } });
|
||||
}
|
||||
}
|
||||
}" class="p-6 rounded-2xl bg-zinc-900 border border-white/10 text-white">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h3 class="text-xl font-bold font-heading">Portions-Rechner</h3>
|
||||
<span class="px-3 py-1 bg-cyan-500/20 text-cyan-400 rounded-full text-xs font-semibold" x-text="pints + (pints === 1 ? ' Pint' : ' Pints')"></span>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label class="block text-xs uppercase tracking-wider text-zinc-400 mb-2">Anzahl Ninja CREAMi Deluxe Pints (680ml)</label>
|
||||
<input type="range" min="1" max="4" step="1" x-model.number="pints" class="w-full accent-cyan-400 cursor-pointer">
|
||||
</div>
|
||||
|
||||
<!-- Computed Ingredients -->
|
||||
<div class="space-y-3 mb-6">
|
||||
<div class="flex justify-between py-2 border-b border-white/5 text-sm">
|
||||
<span class="text-zinc-400">Milch 1.5% oder Hafermilch</span>
|
||||
<span class="font-bold text-white" x-text="milk + ' ml'"></span>
|
||||
</div>
|
||||
<div class="flex justify-between py-2 border-b border-white/5 text-sm">
|
||||
<span class="text-zinc-400">Proteinpulver (Whey/Casein)</span>
|
||||
<span class="font-bold text-cyan-400" x-text="whey + ' g'"></span>
|
||||
</div>
|
||||
<div class="flex justify-between py-2 border-b border-white/5 text-sm">
|
||||
<span class="text-zinc-400">Pistazienmus (100%)</span>
|
||||
<span class="font-bold text-emerald-400" x-text="pistachio + ' g'"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button @click="celebrate()" class="w-full py-3 bg-gradient-to-r from-cyan-500 to-blue-600 rounded-xl font-bold text-sm hover:brightness-110 active:scale-95 transition-all">
|
||||
Zutaten vorbereitet! 🎉
|
||||
</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Modal Dialog with Escape & Backdrop Blur
|
||||
Accessible modal overlay:
|
||||
|
||||
```html
|
||||
<div x-data="{ open: false }">
|
||||
<button @click="open = true" class="px-4 py-2 bg-zinc-900 text-white rounded-xl text-sm font-medium">
|
||||
Details öffnen
|
||||
</button>
|
||||
|
||||
<div
|
||||
x-show="open"
|
||||
@keydown.escape.window="open = false"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
style="display: none;">
|
||||
|
||||
<div
|
||||
@click.outside="open = false"
|
||||
class="bg-white dark:bg-zinc-900 rounded-2xl max-w-lg w-full p-6 border border-zinc-200 dark:border-zinc-800 shadow-2xl"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100">
|
||||
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-bold text-zinc-900 dark:text-white">Modal Titel</h3>
|
||||
<button @click="open = false" class="text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200">
|
||||
<i data-lucide="x" class="w-5 h-5"></i>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-sm text-zinc-600 dark:text-zinc-400 mb-6">
|
||||
Hier befindet sich detaillierter Inhalt mit Reaktivität oder Formularen.
|
||||
</p>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button @click="open = false" class="px-4 py-2 text-sm text-zinc-500 hover:text-zinc-800 dark:hover:text-white">Schließen</button>
|
||||
<button @click="open = false" class="px-4 py-2 text-sm font-medium bg-cyan-500 text-white rounded-xl">Bestätigen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
@@ -0,0 +1,38 @@
|
||||
# Anti-AI-Slop Quality Checklist 🛡️
|
||||
|
||||
Before finalizing and publishing any website or HTML artifact, verify every item on this checklist. This ensures the output feels designed by a human senior product designer rather than a generic LLM.
|
||||
|
||||
---
|
||||
|
||||
## ❌ Forbidden Trappings of "AI Slop"
|
||||
1. **Never use generic default fonts**:
|
||||
* 🚫 No raw Inter, Roboto, Arial, or unstyled system-ui.
|
||||
* ✅ Always import characterful fonts (e.g. *Syne*, *Cabinet Grotesk*, *Fraunces*, *Space Grotesk*, *Plus Jakarta Sans*).
|
||||
2. **Never use the generic purple-to-indigo gradient**:
|
||||
* 🚫 `bg-gradient-to-r from-purple-600 to-indigo-600` on white background.
|
||||
* ✅ Use intentional palettes: deep obsidian with electric cyan accents, warm ivory with terracotta, neo-brutalist yellow with black borders, or slate with emerald glow.
|
||||
3. **Never generate static, boring centered 3-card rows**:
|
||||
* 🚫 Three identical white boxes with light gray text centered on a plain gray canvas.
|
||||
* ✅ Vary card sizes, emphasize a primary card with a border glow or badge, use grid spans (`md:col-span-2`), or add asymmetry.
|
||||
4. **Never forget interactive micro-interactions**:
|
||||
* 🚫 Dead buttons that don't react to hover or click.
|
||||
* ✅ Always add `hover:-translate-y-0.5 hover:shadow-lg transition-all duration-200` and `active:scale-95`.
|
||||
5. **Never rely on broken external images**:
|
||||
* 🚫 Using fake URLs like `https://example.com/image.jpg` or placeholder services that fail.
|
||||
* ✅ Use SVG patterns, Lucide icons, gradient mesh backgrounds, or reliable Unsplash source links with explicit fallback styling.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Mandatory Pre-Flight Verification
|
||||
|
||||
| Check | Requirement | Verified? |
|
||||
|---|---|:---:|
|
||||
| **Viewport & Encoding** | `<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">` | [ ] |
|
||||
| **Tailwind CDN** | `<script src="https://cdn.tailwindcss.com"></script>` included in `<head>` | [ ] |
|
||||
| **Alpine.js CDN** | `<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>` in `<head>` | [ ] |
|
||||
| **Lucide Icons** | `<script src="https://unpkg.com/lucide@latest"></script>` + `lucide.createIcons()` at bottom of `<body>` | [ ] |
|
||||
| **Google Fonts** | Preconnect + Google Font `<link>` + custom `<style>` setting `font-family` | [ ] |
|
||||
| **Touch Targets** | Buttons and clickable elements have at least `py-2.5 px-4` and `cursor-pointer` | [ ] |
|
||||
| **Dark Mode Consistency** | If dark mode is supported, all cards, borders, text, and inputs have `dark:` variants | [ ] |
|
||||
| **Mobile Responsiveness** | Layout uses `grid-cols-1 md:grid-cols-2 lg:grid-cols-3` and responsive paddings (`p-4 md:p-8`) | [ ] |
|
||||
| **Content Richness** | Real, realistic domain content (no repeated "Lorem Ipsum" filler) | [ ] |
|
||||
@@ -0,0 +1,122 @@
|
||||
# Design Archetypes & Visual Systems 🎨
|
||||
|
||||
This document defines the 4 core design archetypes used by the `create-website` skill. Every generated website or single-page application must commit fully to one of these visual directions to ensure high aesthetic distinction and eliminate generic AI aesthetics.
|
||||
|
||||
---
|
||||
|
||||
## 1. Dark Obsidian & Cyber-Luxury 🌌
|
||||
*Best for: Tech products, developer tools, AI showcases, crypto/finance, dark-mode dashboards, interactive apps.*
|
||||
|
||||
### Google Fonts Embed
|
||||
```html
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700&family=Syne:wght@600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body { font-family: 'Plus Jakarta Sans', sans-serif; }
|
||||
h1, h2, h3, .font-heading { font-family: 'Syne', sans-serif; }
|
||||
code, pre, .font-mono { font-family: 'JetBrains Mono', monospace; }
|
||||
</style>
|
||||
```
|
||||
|
||||
### Color Palette & Classes
|
||||
* **Canvas Background**: `bg-[#090A0F]` with subtle radial gradients:
|
||||
`bg-[radial-gradient(ellipse_80%_80%_at_50%_-20%,rgba(120,119,198,0.15),rgba(255,255,255,0))]`
|
||||
* **Card Surface (Glassmorphism)**: `bg-white/[0.03] backdrop-blur-xl border border-white/10 hover:border-white/20 transition-all`
|
||||
* **Secondary Surface**: `bg-[#12151E] border border-white/5`
|
||||
* **Text Hierarchy**: Headings `text-white font-bold tracking-tight`, Body `text-zinc-400`, Subtle `text-zinc-500`
|
||||
* **Accent Highlights**:
|
||||
* Electric Cyan: `text-cyan-400 bg-cyan-500/10 border-cyan-500/20 shadow-[0_0_20px_rgba(6,182,212,0.15)]`
|
||||
* Neon Violet: `text-purple-400 bg-purple-500/10 border-purple-500/20`
|
||||
* Emerald Glow: `text-emerald-400 bg-emerald-500/10 border-emerald-500/20`
|
||||
* **Buttons**:
|
||||
* Primary: `bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-white font-medium px-5 py-2.5 rounded-xl shadow-lg shadow-cyan-500/20 active:scale-95 transition-all`
|
||||
* Secondary: `bg-white/5 hover:bg-white/10 text-white border border-white/10 px-5 py-2.5 rounded-xl active:scale-95 transition-all`
|
||||
|
||||
---
|
||||
|
||||
## 2. Editorial Warmth & Swiss Typographic 📰
|
||||
*Best for: In-depth research dossiers, long-form journalism, recipe collections, personal manifestos, architectural notes, knowledge collections.*
|
||||
|
||||
### Google Fonts Embed
|
||||
```html
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..700;1,9..144,300..700&family=Instrument+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body { font-family: 'Instrument Sans', sans-serif; }
|
||||
h1, h2, h3, .font-heading { font-family: 'Fraunces', serif; }
|
||||
</style>
|
||||
```
|
||||
|
||||
### Color Palette & Classes
|
||||
* **Canvas Background**: Warm Ivory `bg-[#FDFBF7]` or Linen `bg-[#F7F4EE]`
|
||||
* **Card Surface**: Paper White `bg-white border border-stone-200/80 shadow-sm rounded-2xl`
|
||||
* **Text Hierarchy**: Headings `text-stone-900 font-semibold tracking-tight`, Body `text-stone-600 leading-relaxed`, Captions `text-stone-500 text-sm`
|
||||
* **Accent Highlights**:
|
||||
* Heritage Rust / Terracotta: `text-[#C2410C] bg-[#FFF7ED] border-[#FDBA74]`
|
||||
* Deep Forest: `text-[#166534] bg-[#F0FDF4] border-[#BBF7D0]`
|
||||
* Warm Amber: `text-[#B45309] bg-[#FEF3C7] border-[#FDE68A]`
|
||||
* **Styling Rules**:
|
||||
* Generous margins and breathing room (`py-16 md:py-24 max-w-4xl mx-auto`).
|
||||
* Elegant divider rules (`border-t border-stone-200`).
|
||||
* Dropcaps or serif quotes with subtle italic styling (`italic text-stone-700`).
|
||||
|
||||
---
|
||||
|
||||
## 3. Neo-Brutalism & High-Energy ⚡
|
||||
*Best for: Interactive party invites, festival schedules, fitness challenges, hackathon pitches, playful calculators, game companions.*
|
||||
|
||||
### Google Fonts Embed
|
||||
```html
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;700&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body { font-family: 'Space Grotesk', sans-serif; }
|
||||
code, pre, .font-mono { font-family: 'JetBrains Mono', monospace; }
|
||||
</style>
|
||||
```
|
||||
|
||||
### Color Palette & Classes
|
||||
* **Canvas Background**: High-contrast Pale Pastel `bg-[#FAF8F5]` or Off-Yellow `bg-[#FEFCE8]`
|
||||
* **Borders & Shadows**:
|
||||
* Signature Shadow: `border-2 border-black shadow-[4px_4px_0px_0px_#000] hover:shadow-[2px_2px_0px_0px_#000] hover:translate-x-[2px] hover:translate-y-[2px] transition-all`
|
||||
* Card: `bg-white border-2 border-black shadow-[5px_5px_0px_0px_#000] rounded-xl p-6`
|
||||
* **Color Accents**:
|
||||
* Electric Canary: `bg-[#FDE047]`
|
||||
* Acid Lime: `bg-[#A3E635]`
|
||||
* Bright Coral: `bg-[#FB7185]`
|
||||
* Cyan Pill: `bg-[#38BDF8]`
|
||||
* **Interactive Elements**:
|
||||
* Badges: `inline-block px-3 py-1 text-xs font-bold uppercase tracking-wider bg-black text-white rounded-md`
|
||||
* Buttons: `bg-[#FDE047] hover:bg-[#FACC15] text-black font-bold px-6 py-3 border-2 border-black shadow-[4px_4px_0px_0px_#000] active:translate-x-[4px] active:translate-y-[4px] active:shadow-none transition-all rounded-xl cursor-pointer`
|
||||
|
||||
---
|
||||
|
||||
## 4. Modern Clean SaaS & Dynamic Dashboard 📊
|
||||
*Best for: Multi-tab trackers, data analytics, KPI summaries, filterable catalogues, interactive calculators, recipe managers.*
|
||||
|
||||
### Google Fonts Embed
|
||||
```html
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=Plus+Jakarta+Sans:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body { font-family: 'Plus Jakarta Sans', sans-serif; }
|
||||
h1, h2, h3, .font-heading { font-family: 'Outfit', sans-serif; }
|
||||
</style>
|
||||
```
|
||||
|
||||
### Color Palette & Classes
|
||||
* **Canvas Background**: Adaptive Light/Dark:
|
||||
`bg-slate-50 text-slate-900 dark:bg-slate-950 dark:text-slate-100 transition-colors duration-200`
|
||||
* **Card Surface**:
|
||||
`bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-2xl shadow-sm hover:shadow-md transition-all`
|
||||
* **KPI Metrics**:
|
||||
* Value: `text-3xl md:text-4xl font-bold font-heading tracking-tight text-slate-900 dark:text-white`
|
||||
* Label: `text-xs font-medium uppercase tracking-wider text-slate-500 dark:text-slate-400 mt-1`
|
||||
* **Interactive Controls**:
|
||||
* Inputs: `bg-slate-100 dark:bg-slate-800 border-none rounded-xl px-4 py-2 text-sm focus:ring-2 focus:ring-indigo-500`
|
||||
* Active Tabs: `bg-indigo-600 text-white rounded-lg px-4 py-2 font-medium shadow-sm`
|
||||
* Inactive Tabs: `text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white px-4 py-2 rounded-lg`
|
||||
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# dependencies = [
|
||||
# "boto3>=1.34.0",
|
||||
# ]
|
||||
# ///
|
||||
"""
|
||||
Publish single-page websites to hosting.haumdaucher.de with validation and clean URL reporting.
|
||||
Integrates with the existing 'hosting' skill publisher.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
def validate_html(content: str) -> list[str]:
|
||||
"""Basic pre-flight checks to ensure the generated website isn't malformed."""
|
||||
warnings = []
|
||||
content_lower = content.lower()
|
||||
|
||||
if "<!doctype html>" not in content_lower:
|
||||
warnings.append("Missing <!DOCTYPE html> declaration.")
|
||||
if "viewport" not in content_lower:
|
||||
warnings.append("Missing responsive viewport meta tag.")
|
||||
if "cdn.tailwindcss.com" not in content_lower and "tailwindcss" not in content_lower:
|
||||
warnings.append("Tailwind CSS CDN not detected in HTML.")
|
||||
if "alpine" not in content_lower:
|
||||
warnings.append("Alpine.js script not detected (interactivity might be missing).")
|
||||
if "lucide" in content_lower and "lucide.createicons" not in content_lower:
|
||||
warnings.append("Lucide icons included, but 'lucide.createIcons()' initialization is missing.")
|
||||
|
||||
return warnings
|
||||
|
||||
def resolve_hosting_script() -> Path:
|
||||
"""Finds the publish_file.py script relative to this script or in standard locations."""
|
||||
current_dir = Path(__file__).resolve().parent
|
||||
# Standard relative location in mop-skills
|
||||
sibling_hosting = current_dir.parent.parent / "hosting" / "scripts" / "publish_file.py"
|
||||
if sibling_hosting.exists():
|
||||
return sibling_hosting
|
||||
|
||||
# Fallback in container
|
||||
pod_hosting = Path("/home/node/mop-skills/mop-skills/skills/hosting/scripts/publish_file.py")
|
||||
if pod_hosting.exists():
|
||||
return pod_hosting
|
||||
|
||||
raise FileNotFoundError(f"Cannot find publish_file.py in {sibling_hosting} or {pod_hosting}")
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Publish websites to hosting.haumdaucher.de")
|
||||
parser.add_argument("--file", "-f", help="Local HTML file to upload")
|
||||
parser.add_argument("--content", "-c", help="Inline HTML content string")
|
||||
parser.add_argument("--slug", "-s", required=True, help="Slug identifier for URL (e.g. creami-calculator)")
|
||||
parser.add_argument("--category", default="showcase", help="Category folder (default: showcase)")
|
||||
parser.add_argument("--skip-validation", action="store_true", help="Skip HTML quality checks")
|
||||
parser.add_argument("--url-only", action="store_true", help="Print only public URL")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.file and args.content is None:
|
||||
parser.error("You must provide either --file or --content.")
|
||||
|
||||
# Read content for validation
|
||||
html_content = ""
|
||||
if args.file:
|
||||
file_path = Path(args.file)
|
||||
if not file_path.exists():
|
||||
sys.exit(f"Error: File not found: {args.file}")
|
||||
html_content = file_path.read_text(encoding="utf-8", errors="replace")
|
||||
else:
|
||||
html_content = args.content
|
||||
|
||||
# Quality validation
|
||||
if not args.skip_validation:
|
||||
warnings = validate_html(html_content)
|
||||
if warnings:
|
||||
print("⚠️ Pre-flight HTML Warnings:", file=sys.stderr)
|
||||
for w in warnings:
|
||||
print(f" - {w}", file=sys.stderr)
|
||||
|
||||
# Delegate to hosting/scripts/publish_file.py
|
||||
publisher_script = resolve_hosting_script()
|
||||
cmd = [
|
||||
sys.executable,
|
||||
str(publisher_script),
|
||||
"--category", args.category,
|
||||
"--slug", args.slug,
|
||||
"--ext", "html",
|
||||
]
|
||||
|
||||
if args.file:
|
||||
cmd.extend(["--file", str(args.file)])
|
||||
else:
|
||||
cmd.extend(["--content", args.content])
|
||||
|
||||
if args.url_only:
|
||||
cmd.append("--url-only")
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
sys.stderr.write(result.stderr)
|
||||
sys.exit(result.returncode)
|
||||
|
||||
print(result.stdout.strip())
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,45 @@
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
# Add scripts directory to path
|
||||
script_dir = Path(__file__).resolve().parent.parent / "scripts"
|
||||
sys.path.insert(0, str(script_dir))
|
||||
|
||||
from publish_website import validate_html, resolve_hosting_script
|
||||
|
||||
class TestPublishWebsite(unittest.TestCase):
|
||||
def test_validate_html_valid(self):
|
||||
valid_html = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
</head>
|
||||
<body>
|
||||
<i data-lucide="sparkles"></i>
|
||||
<script>lucide.createIcons();</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
warnings = validate_html(valid_html)
|
||||
self.assertEqual(warnings, [])
|
||||
|
||||
def test_validate_html_warnings(self):
|
||||
broken_html = "<html><body>Hello</body></html>"
|
||||
warnings = validate_html(broken_html)
|
||||
self.assertIn("Missing <!DOCTYPE html> declaration.", warnings)
|
||||
self.assertIn("Missing responsive viewport meta tag.", warnings)
|
||||
self.assertIn("Tailwind CSS CDN not detected in HTML.", warnings)
|
||||
self.assertIn("Alpine.js script not detected (interactivity might be missing).", warnings)
|
||||
|
||||
def test_resolve_hosting_script(self):
|
||||
hosting_script = resolve_hosting_script()
|
||||
self.assertTrue(hosting_script.exists())
|
||||
self.assertEqual(hosting_script.name, "publish_file.py")
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
name: "datastore-nutrition-logging"
|
||||
description: "Log nutrition intake via datastore CLI; verify kind and repair mis-parsed events."
|
||||
---
|
||||
|
||||
# Datastore Nutrition Logging
|
||||
|
||||
Log consumed food to the datastore CLI and repair events that landed under the wrong kind.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Log an item: run
|
||||
`uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/datastore.py add nutrition.intake --subject <person> --actor agent:koch --payload '{"item": "...", "qty": 250, "unit": "g", "protein_g": 7.5, "carbs_g": 87.5, "fat_g": 27.5, "kcal": 625.0}' --estimated`
|
||||
Pass `nutrition.intake` as the positional argument and `--estimated` as a bare flag with no value. Done when the response contains `feedback.day_total_formatted`.
|
||||
2. Verify the kind: run
|
||||
`... datastore.py query "SELECT id, kind, start, payload FROM events WHERE subject='<person>' AND start >= '<YYYY-MM-DD>'"`.
|
||||
Done when the new event shows `kind = "nutrition.intake"`. If it shows another kind (e.g. `"true"`), a flag value was parsed as the positional kind.
|
||||
3. Repair a mis-kinded event: delete its line from `/home/node/.openclaw/wiki/main/data/events/<wrong-kind>/YYYY-MM.jsonl`, remove the now-empty `<wrong-kind>/` directory, then re-run step 1. Done when step 2 passes.
|
||||
4. Refresh the read-only views: run
|
||||
`PYTHONPATH=/home/node/mop-skills/mop-skills/skills/datastore/scripts uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/render_views.py`.
|
||||
Done when it prints the rendered nutrition-log paths.
|
||||
5. Report the day total from `feedback.day_total_formatted`; never compute macros by hand.
|
||||
@@ -0,0 +1,161 @@
|
||||
---
|
||||
name: datastore
|
||||
description: Structured personal data layer for OpenClaw powered by in-memory DuckDB and JSONL in git. Provides deterministic math, macro calculations, baby sleep interval tracking, and spatial radius search. Use whenever logging food, baby sleep, checking daily caloric balances, or querying places.
|
||||
---
|
||||
|
||||
# 📦 Datastore: Structured Personal Data Layer
|
||||
|
||||
The `datastore` skill is the source of truth for all structured, repetitive, and mathematical data in OpenClaw. It stores data in git-versioned JSONL files in the Knowledge Wiki (`knowledge-wiki/data/`) and executes all calculations, aggregations, validations, and geospatial queries via in-memory **DuckDB**.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 When to Use
|
||||
|
||||
1. **Personal Nutrition (`nutrition.intake`):**
|
||||
- Logging meals, snacks, drinks, and ingredients.
|
||||
- Computes macros and calories deterministically (zero LLM math).
|
||||
- If a `food_id` from the food catalog is provided, macros are multiplied automatically from packaging reference values (`per_100 * qty / 100`).
|
||||
2. **Baby Sleep (`sleep.session`):**
|
||||
- Starting open sleep sessions when Mila falls asleep (`end=None`).
|
||||
- Closing sleep sessions when Mila wakes up (`datastore_stop_event`).
|
||||
- Logging completed nap or night intervals retroactively.
|
||||
- Querying average night durations, wake windows, and daily sleep totals.
|
||||
3. **Daily Caloric Balances:**
|
||||
- Summarizing daily protein, carbs, calories, and deficit against Garmin expenditures.
|
||||
4. **Points of Interest (`places`):**
|
||||
- Searching for beer gardens, playgrounds, stores, or farm stands within a given radius (e.g. 500m or 1km around Grabengasse 7) using native DuckDB spatial distance.
|
||||
5. **Catalogs & Entities (`foods`, `recipes`, `places`, `people`):**
|
||||
- Upserting and reading reference data.
|
||||
6. **Baby Growth & WHO Curves (`growth.measurement`):**
|
||||
- Logging weight (`weight_kg`) and height/length (`height_cm`).
|
||||
- Deterministic WHO child growth standards evaluation (P3, P50 Median, P97) based on exact age in months.
|
||||
|
||||
---
|
||||
|
||||
## ⛔ Core Guardrails & Protocol (MANDATORY)
|
||||
|
||||
- **NEVER compute arithmetic sums by hand:** Always let DuckDB calculate daily totals, durations, and macronutrient sums via `datastore_add_event`, `datastore_stop_event`, or `datastore_get_summary`.
|
||||
- **NEVER write structured log tables directly into Markdown files:** Data goes into the datastore. Markdown view pages in `knowledge-wiki/entities/` are read-only views generated from the datastore.
|
||||
- **Always provide feedback from the datastore response:** When logging an item, echo the DuckDB-calculated running day total (e.g. *"Tag bisher: 85.5 g Eiweiß · 970 kcal"*) provided in the tool response.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ MCP Tools
|
||||
|
||||
### 1. `datastore_add_event`
|
||||
Logs a point or interval event.
|
||||
- **Parameters:**
|
||||
- `kind` (string, required): e.g. `'nutrition.intake'`, `'sleep.session'`, `'feeding.bottle'`.
|
||||
- `subject` (string, required): `'moritz'`, `'mila'`, or `'amelie'`.
|
||||
- `actor` (string, required): e.g. `'agent:koch'`, `'agent:hebamme'`, `'moritz'`.
|
||||
- `payload` (object, required):
|
||||
- For `nutrition.intake`: `{"item": "...", "qty": 200, "unit": "g"|"ml", "protein_g": ..., "kcal": ...}` (or `{"food_id": "oatly-haferdrink", "qty": 200, "unit": "ml"}`).
|
||||
- For `sleep.session`: `{"type": "nap"|"night", "location": "bett"|"auto"|"kinderwagen"}`.
|
||||
- `start` (string, optional): ISO 8601 timestamp (defaults to current time).
|
||||
- `since` (string, optional): Relative start offset backwards from now (e.g. `'5m'`, `'15m'`, `'1h'`). Use for "schläft seit X Min" to avoid LLM mental math!
|
||||
- `end` (string, optional): ISO 8601 timestamp. Leave empty for open sleep intervals!
|
||||
- `meal` (string, optional): e.g. `'Frühstück'`, `'Mittagessen'`, `'Snack'`.
|
||||
- `estimated` (boolean, optional): Set to `true` if portions or nutrients are estimated.
|
||||
- `note` (string, optional): Context or notes.
|
||||
|
||||
### 2. `datastore_stop_event`
|
||||
Closes the currently active open interval event (e.g. when Mila wakes up).
|
||||
- **Parameters:**
|
||||
- `kind` (string, required): `'sleep.session'`.
|
||||
- `subject` (string, required): `'mila'`.
|
||||
- `end_time` (string, optional): ISO timestamp (e.g. `'2026-10-11T11:40:00+02:00'`). Defaults to current time if omitted.
|
||||
- `since` (string, optional): Relative wake time offset backwards from now (e.g. `'10m'`, `'15m'`).
|
||||
- `event_id` (string, optional): Specific open event ID to close.
|
||||
|
||||
### 3. `datastore_get_summary`
|
||||
Retrieves daily nutrition, sleep, and active status totals computed by DuckDB.
|
||||
- **Parameters:**
|
||||
- `subject` (string, required): `'moritz'` or `'mila'`.
|
||||
- `date` (string, optional): `YYYY-MM-DD` (defaults to today).
|
||||
|
||||
### 4. `datastore_query`
|
||||
Executes a read-only DuckDB SQL query for custom analysis.
|
||||
- **Parameters:**
|
||||
- `sql` (string, required): e.g. `"SELECT * FROM v_sleep_daily WHERE subject = 'mila' LIMIT 14"`.
|
||||
|
||||
### 5. `datastore_find_places_near`
|
||||
Searches for points of interest within a radius in meters.
|
||||
- **Parameters:**
|
||||
- `lat` (number, required): e.g. `49.0205` (Grabengasse 7).
|
||||
- `lon` (number, required): e.g. `12.0975`.
|
||||
- `radius_m` (number, optional, default: 1000).
|
||||
- `category` (string, optional): e.g. `'biergarten'`, `'spielplatz'`.
|
||||
|
||||
### 6. `datastore_put_entity`
|
||||
Upserts a reference entity into `foods`, `recipes`, `places`, or `people`.
|
||||
- **Parameters:**
|
||||
- `collection` (string, required): `'foods'`, `'recipes'`, `'places'`, or `'people'`.
|
||||
- `record` (object, required): Full entity record with an `'id'` field.
|
||||
|
||||
---
|
||||
|
||||
## 💻 CLI Usage
|
||||
|
||||
From the shell or scripts:
|
||||
```bash
|
||||
# Add food
|
||||
uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/datastore.py add nutrition.intake \
|
||||
--subject moritz \
|
||||
--actor agent:koch \
|
||||
--meal Frühstück \
|
||||
--payload '{"item": "Haferflocken mit Skyr", "qty": 250, "unit": "g", "protein_g": 30.0, "kcal": 350.0}'
|
||||
|
||||
# Start Mila's nap (open interval)
|
||||
uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/datastore.py add sleep.session \
|
||||
--subject mila \
|
||||
--actor agent:hebamme \
|
||||
--payload '{"type": "nap", "location": "kinderwagen"}'
|
||||
|
||||
# Start Mila's nap with relative time offset (e.g. asleep since 15 minutes)
|
||||
uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/datastore.py add sleep.session \
|
||||
--subject mila \
|
||||
--actor agent:hebamme \
|
||||
--since "15m" \
|
||||
--payload '{"type": "nap", "location": "bett"}'
|
||||
|
||||
# Stop Mila's nap (closes open interval at current time)
|
||||
uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/datastore.py stop sleep.session \
|
||||
--subject mila
|
||||
|
||||
# Stop Mila's nap retroactively with specific wake time
|
||||
uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/datastore.py stop sleep.session \
|
||||
--subject mila \
|
||||
--end "2026-10-11T11:40:00+02:00"
|
||||
|
||||
# Stop Mila's nap retroactively with relative wake time (e.g. woke up 15 min ago)
|
||||
uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/datastore.py stop sleep.session \
|
||||
--subject mila \
|
||||
--since "15m"
|
||||
|
||||
# Query daily summary
|
||||
uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/datastore.py summary --subject moritz
|
||||
|
||||
# Radius search for beer gardens within 500m of Grabengasse 7
|
||||
uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/datastore.py places \
|
||||
--lat 49.0205 --lon 12.0975 --radius 500 --category biergarten
|
||||
|
||||
# Nightly Garmin mirror sync
|
||||
uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/garmin_sync.py --days 2
|
||||
|
||||
# Log Mila's weight and height measurement
|
||||
uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/datastore.py add growth.measurement \
|
||||
--subject mila \
|
||||
--actor agent:hebamme \
|
||||
--payload '{"weight_kg": 7.4, "height_cm": 67.5}'
|
||||
|
||||
# Retroactively log measurement with date
|
||||
uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/datastore.py add growth.measurement \
|
||||
--subject mila \
|
||||
--date 2026-06-15 \
|
||||
--payload '{"weight_kg": 5.8, "height_cm": 60.0}'
|
||||
|
||||
# Render Markdown views & HTML dashboards
|
||||
PYTHONPATH=/home/node/mop-skills/mop-skills/skills/datastore/scripts \
|
||||
uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/render_views.py
|
||||
```
|
||||
|
||||
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 593 B |
|
After Width: | Height: | Size: 593 B |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 219 KiB |
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"id": "/family/mila.html",
|
||||
"name": "Mila — Schlaf & Entwicklung",
|
||||
"short_name": "Mila",
|
||||
"description": "Schlaf-, Trink- und Windeltagebuch für Mila Graf",
|
||||
"start_url": "/family/mila.html",
|
||||
"scope": "/family/",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait-primary",
|
||||
"background_color": "#090A0F",
|
||||
"theme_color": "#090A0F",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/mila-icon-192-v3.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/mila-icon-192-v3.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icons/mila-icon-512-v3.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/mila-icon-512-v3.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icons/mila-apple-touch-icon-v3.png",
|
||||
"sizes": "180x180",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/mila-icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"id": "/family/moritz.html",
|
||||
"name": "Moritz Health",
|
||||
"short_name": "Health",
|
||||
"description": "Persönliches Dashboard für Kalorienbilanz, Ernährung und Garmin-Fitness von Moritz.",
|
||||
"start_url": "/family/moritz.html",
|
||||
"scope": "/family/",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait-primary",
|
||||
"background_color": "#f8fafc",
|
||||
"theme_color": "#ffffff",
|
||||
"icons": [
|
||||
{
|
||||
"src": "data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'><rect width='512' height='512' rx='128' fill='%2310b981'/><path d='M128 256h64l32-96 64 192 32-96h64' fill='none' stroke='white' stroke-width='40' stroke-linecap='round' stroke-linejoin='round'/></svg>",
|
||||
"sizes": "192x192 512x512",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Minimal service worker for Family PWAs (Mila & Moritz) with instant cache-bypassing navigation
|
||||
const CACHE_NAME = 'family-pwa-v3';
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) =>
|
||||
Promise.all(
|
||||
keys.map((key) => {
|
||||
if (key !== CACHE_NAME) return caches.delete(key);
|
||||
})
|
||||
)
|
||||
).then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
if (event.request.method !== 'GET') return;
|
||||
|
||||
// For HTML documents/navigations: ALWAYS bypass HTTP cache to get latest live server rendering
|
||||
if (event.request.destination === 'document' || event.request.mode === 'navigate') {
|
||||
event.respondWith(
|
||||
fetch(event.request, { cache: 'no-cache' }).catch(() => caches.match(event.request))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Assets (images, fonts, scripts): network-first with offline cache fallback
|
||||
event.respondWith(
|
||||
fetch(event.request).catch(() => caches.match(event.request))
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# dependencies = [
|
||||
# "boto3>=1.34.0",
|
||||
# ]
|
||||
# ///
|
||||
"""
|
||||
audit_hosting_boundaries.py — Automated Continuous Leak Prevention Guard (ADR 0005).
|
||||
|
||||
Verifies that no personal, familial, or physiological data files are exposed at the
|
||||
unauthenticated root or public folders of MinIO bucket 'hosting'.
|
||||
Enforces:
|
||||
1. All dashboards for Mila, Moritz, family nutrition, and baby sleep must strictly reside under 'family/'.
|
||||
2. Root and public directories must only contain public assets (/recipes/*, /showcase/*).
|
||||
3. Any violating key found at root or public prefixes is detected and optionally quarantined/purged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_ENDPOINT = os.environ.get("MINIO_ENDPOINT", "http://minio.minio.svc.cluster.local:9000")
|
||||
DEFAULT_ACCESS_KEY = os.environ.get("MINIO_ACCESS_KEY", "openclaw")
|
||||
DEFAULT_SECRET_KEY = os.environ.get("MINIO_SECRET_KEY", "5uu23t69xC2Oigf8Bky0KTlI")
|
||||
DEFAULT_BUCKET = os.environ.get("MINIO_BUCKET", "hosting")
|
||||
|
||||
SENSITIVE_PATTERNS = [
|
||||
"mila",
|
||||
"moritz",
|
||||
"nutrition",
|
||||
"sleep",
|
||||
"schlaf",
|
||||
"health",
|
||||
"diaper",
|
||||
"windel",
|
||||
"fläschchen",
|
||||
"flaeschchen",
|
||||
"garmin",
|
||||
"zwift",
|
||||
"incyclist",
|
||||
]
|
||||
|
||||
|
||||
def get_s3_client():
|
||||
import boto3
|
||||
from botocore.client import Config
|
||||
|
||||
return boto3.client(
|
||||
"s3",
|
||||
endpoint_url=DEFAULT_ENDPOINT,
|
||||
aws_access_key_id=DEFAULT_ACCESS_KEY,
|
||||
aws_secret_access_key=DEFAULT_SECRET_KEY,
|
||||
config=Config(signature_version="s3v4", connect_timeout=5, retries={"max_attempts": 2}),
|
||||
region_name="us-east-1",
|
||||
)
|
||||
|
||||
|
||||
def audit_hosting_bucket(clean: bool = False) -> list[str]:
|
||||
"""Scans MinIO bucket for sensitive personal files outside family/."""
|
||||
s3 = get_s3_client()
|
||||
violations: list[str] = []
|
||||
|
||||
paginator = s3.get_paginator("list_objects_v2")
|
||||
for page in paginator.paginate(Bucket=DEFAULT_BUCKET):
|
||||
for obj in page.get("Contents", []):
|
||||
key = obj["Key"]
|
||||
# Whitelisted protected prefixes
|
||||
if key.startswith("family/") or key.startswith("private/"):
|
||||
continue
|
||||
|
||||
# Check if key matches sensitive personal data
|
||||
key_lower = key.lower()
|
||||
if any(term in key_lower for term in SENSITIVE_PATTERNS):
|
||||
violations.append(key)
|
||||
|
||||
if violations:
|
||||
print(f"🚨 SECURITY VIOLATION: Found {len(violations)} sensitive personal file(s) outside /family/ in bucket '{DEFAULT_BUCKET}':", file=sys.stderr)
|
||||
for v in violations:
|
||||
print(f" - {v}", file=sys.stderr)
|
||||
if clean:
|
||||
try:
|
||||
s3.delete_object(Bucket=DEFAULT_BUCKET, Key=v)
|
||||
print(f" [CLEANED] Deleted '{v}' from public area.", file=sys.stderr)
|
||||
except Exception as ex:
|
||||
print(f" [ERROR] Failed to delete '{v}': {ex}", file=sys.stderr)
|
||||
else:
|
||||
print(f"✅ S3 Hosting Audit PASSED: Zero sensitive personal files found in public areas of bucket '{DEFAULT_BUCKET}'.")
|
||||
|
||||
return violations
|
||||
|
||||
|
||||
def audit_local_public_vault(vault_dir: Path, clean: bool = False) -> list[Path]:
|
||||
"""Scans local knowledge-wiki/public directory for sensitive files outside family/."""
|
||||
violations: list[Path] = []
|
||||
public_dir = vault_dir / "public"
|
||||
if not public_dir.is_dir():
|
||||
return violations
|
||||
|
||||
for f in public_dir.iterdir():
|
||||
if f.is_file():
|
||||
f_lower = f.name.lower()
|
||||
if any(term in f_lower for term in SENSITIVE_PATTERNS):
|
||||
violations.append(f)
|
||||
if clean:
|
||||
try:
|
||||
f.unlink()
|
||||
print(f" [CLEANED] Deleted local unauthenticated file: {f}")
|
||||
except Exception as ex:
|
||||
print(f" [ERROR] Failed to delete {f}: {ex}")
|
||||
|
||||
if violations and not clean:
|
||||
print(f"🚨 Local Vault Public Warning: Found {len(violations)} personal file(s) in {public_dir} root.", file=sys.stderr)
|
||||
elif not violations:
|
||||
print(f"✅ Local Vault Public Audit PASSED: No sensitive files at public root.")
|
||||
|
||||
return violations
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Audit and enforce ADR 0005 hosting boundary segregation")
|
||||
parser.add_argument("--clean", action="store_true", help="Automatically delete any violating files found")
|
||||
parser.add_argument("--vault", help="Path to knowledge-wiki directory")
|
||||
args = parser.parse_args()
|
||||
|
||||
vault_path = Path(args.vault).resolve() if args.vault else Path(__file__).resolve().parents[4] / "knowledge-wiki"
|
||||
|
||||
s3_violations = []
|
||||
try:
|
||||
s3_violations = audit_hosting_bucket(clean=args.clean)
|
||||
except Exception as ex:
|
||||
print(f"Warning: could not connect to S3/MinIO for audit: {ex}", file=sys.stderr)
|
||||
|
||||
local_violations = audit_local_public_vault(vault_path, clean=args.clean)
|
||||
|
||||
if s3_violations or local_violations:
|
||||
if not args.clean:
|
||||
sys.exit(1)
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,546 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# dependencies = [
|
||||
# "garminconnect>=0.2.14",
|
||||
# "pytz>=2024.1",
|
||||
# ]
|
||||
# ///
|
||||
"""
|
||||
Garmin Sync: Deterministic mirror of Garmin Connect data into git-backed JSONL.
|
||||
|
||||
Mirrors daily summaries, sleep data, and activities into:
|
||||
- data/garmin/daily/<YYYY-MM>.jsonl
|
||||
- data/garmin/sleep/<YYYY-MM>.jsonl
|
||||
- data/garmin/activities/<YYYY-MM>.jsonl
|
||||
|
||||
Adheres to ADR 0003 (Git as single source of truth) and ADR 0004 (Structured Datastore).
|
||||
Zero LLM math: records are stored as raw JSONL for deterministic DuckDB ingestion.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
try:
|
||||
import pytz
|
||||
except ImportError:
|
||||
pytz = None
|
||||
|
||||
logger = logging.getLogger("garmin_sync")
|
||||
|
||||
|
||||
def get_berlin_yesterday() -> str:
|
||||
"""Returns yesterday's date in Europe/Berlin wall-clock time."""
|
||||
if pytz:
|
||||
tz = pytz.timezone("Europe/Berlin")
|
||||
now = datetime.datetime.now(tz)
|
||||
else:
|
||||
# Fallback +2h offset for Berlin CEST
|
||||
tz_offset = datetime.timezone(datetime.timedelta(hours=2))
|
||||
now = datetime.datetime.now(tz_offset)
|
||||
yesterday = now - datetime.timedelta(days=1)
|
||||
return yesterday.strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def find_default_data_dir() -> Path:
|
||||
"""Finds the knowledge-wiki/data directory."""
|
||||
env_dir = os.environ.get("DATASTORE_DATA_DIR")
|
||||
if env_dir:
|
||||
p = Path(env_dir)
|
||||
if p.is_dir():
|
||||
return p
|
||||
|
||||
candidates = [
|
||||
Path("/home/node/.openclaw/wiki/main/data"),
|
||||
Path(__file__).resolve().parents[4] / "knowledge-wiki" / "data",
|
||||
Path.cwd() / "knowledge-wiki" / "data",
|
||||
Path.cwd() / "data",
|
||||
]
|
||||
for c in candidates:
|
||||
if c.is_dir():
|
||||
return c
|
||||
# Fallback to local data dir inside parent
|
||||
return Path.cwd() / "data"
|
||||
|
||||
|
||||
def get_token_dir(custom_path: Optional[str] = None) -> Path:
|
||||
"""Discovers token directory according to garmin-auth conventions."""
|
||||
if custom_path:
|
||||
return Path(custom_path)
|
||||
if os.environ.get("GARMINTOKENS"):
|
||||
return Path(os.path.expanduser(os.environ["GARMINTOKENS"]))
|
||||
if Path("/home/node/.openclaw/garmin_tokens").is_dir():
|
||||
return Path("/home/node/.openclaw/garmin_tokens")
|
||||
return Path(os.path.expanduser("~/.garminconnect"))
|
||||
|
||||
|
||||
class GarminSyncClient:
|
||||
"""Garmin API client with token re-use and optional mock mode."""
|
||||
|
||||
def __init__(self, token_dir: Path, mock: bool = False):
|
||||
self.token_dir = token_dir
|
||||
self.mock = mock
|
||||
self.client: Any = None
|
||||
|
||||
if not self.mock:
|
||||
self._init_live_client()
|
||||
|
||||
def _init_live_client(self):
|
||||
token_file = self.token_dir / "garmin_tokens.json"
|
||||
if not token_file.is_file():
|
||||
logger.error(f"No Garmin tokens found at {token_file}")
|
||||
raise FileNotFoundError(f"Missing garmin_tokens.json in {self.token_dir}")
|
||||
|
||||
try:
|
||||
from garminconnect import Garmin
|
||||
|
||||
self.client = Garmin()
|
||||
self.client.login(str(self.token_dir))
|
||||
name = self.client.get_full_name()
|
||||
logger.info(f"Connected to Garmin as {name}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to authenticate with Garmin Connect: {e}")
|
||||
raise PermissionError(f"Garmin auth failed: {e}") from e
|
||||
|
||||
def get_daily_summary(self, date_str: str) -> Dict[str, Any]:
|
||||
if self.mock:
|
||||
return {
|
||||
"calendarDate": date_str,
|
||||
"totalKilocalories": 2850,
|
||||
"activeKilocalories": 610,
|
||||
"bmrKilocalories": 2240,
|
||||
"totalSteps": 9840,
|
||||
"totalDistanceMeters": 7450.0,
|
||||
"restingHeartRate": 52,
|
||||
"minHeartRate": 48,
|
||||
"maxHeartRate": 164,
|
||||
"floorsAscended": 12,
|
||||
"source": "garmin_sync_mock",
|
||||
}
|
||||
return self.client.get_user_summary(date_str)
|
||||
|
||||
def get_sleep_data(self, date_str: str) -> Dict[str, Any]:
|
||||
if self.mock:
|
||||
return {
|
||||
"dailySleepDTO": {
|
||||
"id": 12345678,
|
||||
"calendarDate": date_str,
|
||||
"sleepTimeSeconds": 28800,
|
||||
"napTimeSeconds": 0,
|
||||
"deepSleepSeconds": 7200,
|
||||
"lightSleepSeconds": 14400,
|
||||
"remSleepSeconds": 5400,
|
||||
"awakeSleepSeconds": 1800,
|
||||
"sleepScores": {"overall": {"value": 85, "qualifierKey": "EXCELLENT"}},
|
||||
},
|
||||
"source": "garmin_sync_mock",
|
||||
}
|
||||
return self.client.get_sleep_data(date_str)
|
||||
|
||||
def get_activities(self, date_str: str) -> List[Dict[str, Any]]:
|
||||
if self.mock:
|
||||
return [
|
||||
{
|
||||
"activityId": 987654321,
|
||||
"activityName": "Indoor Cycling (Tacx Flux 2)",
|
||||
"startTimeLocal": f"{date_str} 18:30:00",
|
||||
"startTimeGMT": f"{date_str} 16:30:00",
|
||||
"activityType": {"typeId": 2, "typeKey": "cycling"},
|
||||
"distance": 25400.0,
|
||||
"duration": 3600.0,
|
||||
"elapsedDuration": 3600.0,
|
||||
"calories": 580.0,
|
||||
"averageSpeed": 7.05,
|
||||
"maxSpeed": 11.2,
|
||||
"averageHR": 142,
|
||||
"maxHR": 168,
|
||||
"averagePower": 210,
|
||||
"maxPower": 285,
|
||||
"source": "garmin_sync_mock",
|
||||
}
|
||||
]
|
||||
return self.client.get_activities_by_date(date_str, date_str)
|
||||
|
||||
|
||||
class GarminMirrorWriter:
|
||||
"""Manages month-partitioned JSONL files with idempotent upserts."""
|
||||
|
||||
def __init__(self, data_dir: Path):
|
||||
self.data_dir = data_dir
|
||||
self.garmin_dir = data_dir / "garmin"
|
||||
self.daily_dir = self.garmin_dir / "daily"
|
||||
self.sleep_dir = self.garmin_dir / "sleep"
|
||||
self.unified_sleep_dir = data_dir / "sleep"
|
||||
self.activities_dir = self.garmin_dir / "activities"
|
||||
|
||||
for d in [self.daily_dir, self.sleep_dir, self.unified_sleep_dir, self.activities_dir]:
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _read_existing(self, file_path: Path) -> List[Dict[str, Any]]:
|
||||
if not file_path.is_file():
|
||||
return []
|
||||
items = []
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line:
|
||||
try:
|
||||
items.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return items
|
||||
|
||||
def _write_atomic(self, file_path: Path, items: List[Dict[str, Any]]):
|
||||
tmp_path = file_path.with_suffix(".tmp")
|
||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||
for item in items:
|
||||
f.write(json.dumps(item, ensure_ascii=False) + "\n")
|
||||
tmp_path.replace(file_path)
|
||||
|
||||
def upsert_daily_summary(self, summary: Dict[str, Any]):
|
||||
cal_date = summary.get("calendarDate")
|
||||
if not cal_date:
|
||||
logger.warning("Daily summary missing calendarDate, skipping.")
|
||||
return
|
||||
|
||||
month = cal_date[:7] # YYYY-MM
|
||||
target_file = self.daily_dir / f"{month}.jsonl"
|
||||
|
||||
existing = self._read_existing(target_file)
|
||||
updated = False
|
||||
new_items = []
|
||||
for item in existing:
|
||||
if item.get("calendarDate") == cal_date:
|
||||
new_items.append(summary)
|
||||
updated = True
|
||||
else:
|
||||
new_items.append(item)
|
||||
|
||||
if not updated:
|
||||
new_items.append(summary)
|
||||
|
||||
new_items.sort(key=lambda x: str(x.get("calendarDate", "")))
|
||||
self._write_atomic(target_file, new_items)
|
||||
logger.info(f"Upserted daily summary for {cal_date} into {target_file.name}")
|
||||
|
||||
def upsert_sleep_data(self, sleep_data: Dict[str, Any]):
|
||||
cal_date = None
|
||||
dto = sleep_data.get("dailySleepDTO") if isinstance(sleep_data.get("dailySleepDTO"), dict) else {}
|
||||
if dto:
|
||||
cal_date = dto.get("calendarDate")
|
||||
if not cal_date:
|
||||
cal_date = sleep_data.get("calendarDate")
|
||||
|
||||
if not cal_date:
|
||||
logger.warning("Sleep data missing calendarDate, skipping.")
|
||||
return
|
||||
|
||||
month = cal_date[:7]
|
||||
target_file = self.sleep_dir / f"{month}.jsonl"
|
||||
|
||||
existing = self._read_existing(target_file)
|
||||
updated = False
|
||||
new_items = []
|
||||
for item in existing:
|
||||
item_date = None
|
||||
if "dailySleepDTO" in item and isinstance(item["dailySleepDTO"], dict):
|
||||
item_date = item["dailySleepDTO"].get("calendarDate")
|
||||
if not item_date:
|
||||
item_date = item.get("calendarDate")
|
||||
|
||||
if item_date == cal_date:
|
||||
new_items.append(sleep_data)
|
||||
updated = True
|
||||
else:
|
||||
new_items.append(item)
|
||||
|
||||
if not updated:
|
||||
new_items.append(sleep_data)
|
||||
|
||||
def _sort_key(x):
|
||||
d = (x.get("dailySleepDTO") or {}).get("calendarDate") if isinstance(x.get("dailySleepDTO"), dict) else None
|
||||
return str(d or x.get("calendarDate") or "")
|
||||
|
||||
new_items.sort(key=_sort_key)
|
||||
self._write_atomic(target_file, new_items)
|
||||
|
||||
# Also upsert into unified data/sleep/<YYYY-MM>.jsonl
|
||||
start_gmt = dto.get("sleepStartTimestampGMT")
|
||||
end_gmt = dto.get("sleepEndTimestampGMT")
|
||||
if start_gmt:
|
||||
start_dt = datetime.datetime.fromtimestamp(start_gmt / 1000, datetime.timezone.utc)
|
||||
start_iso = start_dt.isoformat()
|
||||
else:
|
||||
start_iso = f"{cal_date}T22:30:00+02:00"
|
||||
|
||||
if end_gmt:
|
||||
end_dt = datetime.datetime.fromtimestamp(end_gmt / 1000, datetime.timezone.utc)
|
||||
end_iso = end_dt.isoformat()
|
||||
else:
|
||||
end_iso = f"{cal_date}T06:30:00+02:00"
|
||||
|
||||
nap_sec = dto.get("napTimeSeconds") or 0
|
||||
sleep_sec = dto.get("sleepTimeSeconds") or 0
|
||||
sleep_type = "nap" if (nap_sec > 0 and sleep_sec == 0) else "night"
|
||||
|
||||
unified_rec = {
|
||||
"id": str(dto.get("id") or (int(start_gmt) if start_gmt else cal_date)),
|
||||
"subject": "moritz",
|
||||
"calendarDate": cal_date,
|
||||
"sleepType": sleep_type,
|
||||
"location": "bett",
|
||||
"isOpen": False,
|
||||
"startIso": start_iso,
|
||||
"endIso": end_iso,
|
||||
"sleepStartTimestampGMT": dto.get("sleepStartTimestampGMT"),
|
||||
"sleepEndTimestampGMT": dto.get("sleepEndTimestampGMT"),
|
||||
"sleepStartTimestampLocal": dto.get("sleepStartTimestampLocal"),
|
||||
"sleepEndTimestampLocal": dto.get("sleepEndTimestampLocal"),
|
||||
"sleepTimeSeconds": dto.get("sleepTimeSeconds"),
|
||||
"napTimeSeconds": nap_sec,
|
||||
"deepSleepSeconds": dto.get("deepSleepSeconds"),
|
||||
"lightSleepSeconds": dto.get("lightSleepSeconds"),
|
||||
"remSleepSeconds": dto.get("remSleepSeconds"),
|
||||
"awakeSleepSeconds": dto.get("awakeSleepSeconds"),
|
||||
"awakeCount": dto.get("awakeCount"),
|
||||
"avgSleepStress": dto.get("avgSleepStress"),
|
||||
"avgHeartRate": dto.get("avgHeartRate"),
|
||||
"averageSpO2Value": dto.get("averageSpO2Value"),
|
||||
"lowestSpO2Value": dto.get("lowestSpO2Value"),
|
||||
"highestSpO2Value": dto.get("highestSpO2Value"),
|
||||
"averageRespirationValue": dto.get("averageRespirationValue"),
|
||||
"sleepScores": dto.get("sleepScores"),
|
||||
"avgOvernightHrv": sleep_data.get("avgOvernightHrv"),
|
||||
"hrvStatus": sleep_data.get("hrvStatus"),
|
||||
"bodyBatteryChange": sleep_data.get("bodyBatteryChange"),
|
||||
"restingHeartRate": sleep_data.get("restingHeartRate"),
|
||||
"restlessMomentsCount": sleep_data.get("restlessMomentsCount"),
|
||||
"note": None,
|
||||
"actor": "garmin_sync",
|
||||
"created_at": start_iso,
|
||||
"updated_at": None,
|
||||
"deleted_at": None,
|
||||
}
|
||||
|
||||
unified_file = self.unified_sleep_dir / f"{month}.jsonl"
|
||||
existing_unified = self._read_existing(unified_file)
|
||||
updated_u = False
|
||||
new_unified = []
|
||||
for item in existing_unified:
|
||||
if item.get("subject") == "moritz" and item.get("calendarDate") == cal_date:
|
||||
new_unified.append(unified_rec)
|
||||
updated_u = True
|
||||
else:
|
||||
new_unified.append(item)
|
||||
|
||||
if not updated_u:
|
||||
new_unified.append(unified_rec)
|
||||
|
||||
new_unified.sort(key=lambda x: (str(x.get("calendarDate", "")), str(x.get("startIso", "")), str(x.get("id", ""))))
|
||||
self._write_atomic(unified_file, new_unified)
|
||||
logger.info(f"Upserted sleep data for {cal_date} into {target_file.name} and {unified_file.name}")
|
||||
|
||||
def upsert_activities(self, activities: List[Dict[str, Any]]):
|
||||
if not activities:
|
||||
return
|
||||
|
||||
# Group activities by month
|
||||
by_month: Dict[str, List[Dict[str, Any]]] = {}
|
||||
for act in activities:
|
||||
start_local = act.get("startTimeLocal", "")
|
||||
if len(start_local) >= 7:
|
||||
month = start_local[:7]
|
||||
else:
|
||||
month = datetime.date.today().strftime("%Y-%m")
|
||||
by_month.setdefault(month, []).append(act)
|
||||
|
||||
for month, act_list in by_month.items():
|
||||
target_file = self.activities_dir / f"{month}.jsonl"
|
||||
existing = self._read_existing(target_file)
|
||||
id_map = {item.get("activityId"): item for item in existing if item.get("activityId")}
|
||||
|
||||
for act in act_list:
|
||||
act_id = act.get("activityId")
|
||||
if act_id:
|
||||
id_map[act_id] = act
|
||||
|
||||
all_acts = list(id_map.values())
|
||||
all_acts.sort(key=lambda x: str(x.get("startTimeLocal", "")))
|
||||
self._write_atomic(target_file, all_acts)
|
||||
logger.info(f"Upserted {len(act_list)} activities for month {month} into {target_file.name}")
|
||||
|
||||
|
||||
def sync_date_range(
|
||||
client: GarminSyncClient,
|
||||
writer: Optional[GarminMirrorWriter],
|
||||
dates: List[str],
|
||||
backend_url: Optional[str] = None,
|
||||
):
|
||||
target_backend = None
|
||||
if backend_url is not None:
|
||||
target_backend = backend_url
|
||||
elif os.environ.get("DUCKDB_FORCE_LOCAL") != "1":
|
||||
env_backend = os.environ.get(
|
||||
"DUCKDB_BACKEND_URL", "http://duckdb-backend.family.svc.cluster.local:8000/duckdb-api/v1"
|
||||
).rstrip("/")
|
||||
try:
|
||||
base = env_backend.replace("/duckdb-api/v1", "")
|
||||
test_url = f"{base}/healthz"
|
||||
req = urllib.request.Request(test_url, method="GET")
|
||||
with urllib.request.urlopen(req, timeout=1.0) as r:
|
||||
if r.status == 200:
|
||||
target_backend = env_backend
|
||||
logger.info(f"GarminSync using single-writer backend at {target_backend}")
|
||||
except Exception:
|
||||
target_backend = None
|
||||
|
||||
for d in dates:
|
||||
logger.info(f"--- Syncing Garmin data for {d} ---")
|
||||
summary = None
|
||||
sleep = None
|
||||
activities = None
|
||||
try:
|
||||
summary = client.get_daily_summary(d)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not fetch daily summary for {d}: {e}")
|
||||
|
||||
try:
|
||||
sleep = client.get_sleep_data(d)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not fetch sleep data for {d}: {e}")
|
||||
|
||||
try:
|
||||
activities = client.get_activities(d)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not fetch activities for {d}: {e}")
|
||||
|
||||
synced_via_backend = False
|
||||
if target_backend:
|
||||
try:
|
||||
sync_payload = {
|
||||
"date": d,
|
||||
"daily": [summary] if summary else [],
|
||||
"sleep": [sleep] if sleep else [],
|
||||
"activities": activities or [],
|
||||
"daily_summary": summary,
|
||||
"sleep_data": sleep,
|
||||
}
|
||||
sync_url = f"{target_backend}/garmin/sync"
|
||||
req = urllib.request.Request(
|
||||
sync_url,
|
||||
data=json.dumps(sync_payload).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
resp_body = {}
|
||||
try:
|
||||
resp_body = json.loads(resp.read().decode("utf-8"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
expected_records = (1 if summary else 0) + (1 if sleep else 0) + (len(activities) if activities else 0)
|
||||
synced_records = (
|
||||
resp_body.get("synced_daily", 0)
|
||||
+ resp_body.get("synced_sleep", 0)
|
||||
+ resp_body.get("synced_activities", 0)
|
||||
)
|
||||
|
||||
if resp.status == 200 and (expected_records == 0 or synced_records > 0):
|
||||
logger.info(f"Successfully posted Garmin sync for {d} to backend: {resp_body}")
|
||||
synced_via_backend = True
|
||||
else:
|
||||
logger.warning(
|
||||
f"Backend returned HTTP {resp.status} for {d} but synced {synced_records} records "
|
||||
f"(expected {expected_records}): {resp_body}. Falling back to local writer."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to sync via backend {target_backend} for {d}: {e}. Falling back to local writer if available.")
|
||||
|
||||
if not synced_via_backend and writer:
|
||||
if summary:
|
||||
writer.upsert_daily_summary(summary)
|
||||
if sleep:
|
||||
writer.upsert_sleep_data(sleep)
|
||||
if activities:
|
||||
writer.upsert_activities(activities)
|
||||
|
||||
|
||||
def generate_date_list(start_str: str, end_str: str) -> List[str]:
|
||||
d_start = datetime.datetime.strptime(start_str, "%Y-%m-%d").date()
|
||||
d_end = datetime.datetime.strptime(end_str, "%Y-%m-%d").date()
|
||||
if d_start > d_end:
|
||||
d_start, d_end = d_end, d_start
|
||||
|
||||
result = []
|
||||
curr = d_start
|
||||
while curr <= d_end:
|
||||
result.append(curr.strftime("%Y-%m-%d"))
|
||||
curr += datetime.timedelta(days=1)
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Deterministic Garmin Connect data mirror into JSONL")
|
||||
parser.add_argument("--date", help="Specific date to sync (YYYY-MM-DD). Defaults to yesterday.")
|
||||
parser.add_argument("--days", type=int, help="Sync last N days up to yesterday.")
|
||||
parser.add_argument("--start", help="Start date (YYYY-MM-DD)")
|
||||
parser.add_argument("--end", help="End date (YYYY-MM-DD)")
|
||||
parser.add_argument("--data-dir", help="Path to knowledge-wiki/data directory")
|
||||
parser.add_argument("--token-dir", help="Path to Garmin token directory")
|
||||
parser.add_argument("--mock", action="store_true", help="Run with mock data (for testing/offline)")
|
||||
parser.add_argument("--verbose", action="store_true", help="Enable verbose debug logging")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG if args.verbose else logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
)
|
||||
|
||||
data_dir = Path(args.data_dir) if args.data_dir else find_default_data_dir()
|
||||
token_dir = get_token_dir(args.token_dir)
|
||||
|
||||
# Determine date list
|
||||
if args.start and args.end:
|
||||
dates = generate_date_list(args.start, args.end)
|
||||
elif args.days:
|
||||
yesterday_str = get_berlin_yesterday()
|
||||
d_yesterday = datetime.datetime.strptime(yesterday_str, "%Y-%m-%d").date()
|
||||
d_start = d_yesterday - datetime.timedelta(days=args.days - 1)
|
||||
dates = generate_date_list(d_start.strftime("%Y-%m-%d"), yesterday_str)
|
||||
elif args.date:
|
||||
dates = [args.date]
|
||||
else:
|
||||
dates = [get_berlin_yesterday()]
|
||||
|
||||
logger.info(f"Starting Garmin sync for {len(dates)} day(s): {dates[0]} to {dates[-1]}")
|
||||
logger.info(f"Data directory: {data_dir}")
|
||||
|
||||
try:
|
||||
client = GarminSyncClient(token_dir, mock=args.mock)
|
||||
except PermissionError:
|
||||
logger.error("Authentication error. Please run 'garmin-auth' to refresh credentials.")
|
||||
sys.exit(2)
|
||||
except FileNotFoundError as e:
|
||||
logger.error(str(e))
|
||||
sys.exit(2)
|
||||
|
||||
writer = GarminMirrorWriter(data_dir)
|
||||
sync_date_range(client, writer, dates)
|
||||
|
||||
logger.info("Garmin sync completed successfully.")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,534 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# dependencies = [
|
||||
# "duckdb>=1.1.0",
|
||||
# "python-ulid>=3.0.0",
|
||||
# "pytz>=2024.1",
|
||||
# ]
|
||||
# ///
|
||||
"""
|
||||
migrate_legacy.py — Migrates legacy Markdown logs to DuckDB-backed JSONL datastore.
|
||||
Parses:
|
||||
- standard-foods.md -> data/entities/foods.jsonl
|
||||
- entities/nutrition-log/moritz/*.md -> data/events/nutrition/*.jsonl
|
||||
- entities/people/mila-schlafprotokoll.md -> data/events/sleep/*.jsonl & feeding/*.jsonl
|
||||
Computes exact DuckDB sums, compares with old handwritten sums, and generates audit report.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from zoneinfo import ZoneInfo
|
||||
def generate_ulid() -> str:
|
||||
try:
|
||||
from ulid import ULID
|
||||
return str(ULID())
|
||||
except TypeError:
|
||||
import ulid
|
||||
return str(ulid.new())
|
||||
except ImportError:
|
||||
import uuid
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
import duckdb
|
||||
|
||||
BERLIN_TZ = ZoneInfo("Europe/Berlin")
|
||||
WIKI_DIR = Path("/Users/moritz/src/infrapuzzle/knowledge-wiki")
|
||||
DATA_DIR = WIKI_DIR / "data"
|
||||
|
||||
|
||||
def parse_number(s: str) -> float:
|
||||
"""Parses German float string like '~11,3' or '2.364' into float."""
|
||||
if not s or s.strip() in ["-", "–", ""]:
|
||||
return 0.0
|
||||
cleaned = s.replace("~", "").replace("g", "").replace("ml", "").replace("kcal", "").strip()
|
||||
# If number has both dot and comma (e.g. 1.234,56 or 2.364)
|
||||
if "." in cleaned and "," in cleaned:
|
||||
cleaned = cleaned.replace(".", "").replace(",", ".")
|
||||
elif "," in cleaned:
|
||||
cleaned = cleaned.replace(",", ".")
|
||||
elif "." in cleaned:
|
||||
# Check if dot is thousands separator (e.g. 2.364 kcal)
|
||||
parts = cleaned.split(".")
|
||||
if len(parts) == 2 and len(parts[1]) == 3:
|
||||
cleaned = parts[0] + parts[1]
|
||||
m = re.search(r"[-+]?\d*\.?\d+", cleaned)
|
||||
return float(m.group(0)) if m else 0.0
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# 1. Migrate Standard Foods
|
||||
# ==============================================================================
|
||||
def migrate_standard_foods() -> List[Dict[str, Any]]:
|
||||
foods_file = DATA_DIR / "entities" / "foods.jsonl"
|
||||
foods_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
foods = [
|
||||
{
|
||||
"id": "oatly-haferdrink",
|
||||
"name": "Oatly Haferdrink",
|
||||
"brand": "Oatly AB",
|
||||
"category": "milch-alternative",
|
||||
"per_100": {
|
||||
"kcal": 61.0,
|
||||
"protein_g": 1.1,
|
||||
"carbs_g": 7.1,
|
||||
"sugar_g": 3.4,
|
||||
"fat_g": 3.0,
|
||||
"fiber_g": 0.8,
|
||||
"salt_g": 0.10,
|
||||
},
|
||||
"density_g_per_ml": 1.03,
|
||||
"ingredients_text": "Wasser, HAFER 10%, Rapsöl, Säureregulator (Dikaliumphosphat), Mineralien (Calciumcarbonat, Kaliumjodid), Salz, Vitamine (D2, Riboflavin, B12).",
|
||||
"barcode": "7394376616037",
|
||||
"source": "package_photo",
|
||||
"description": "Standard-Hafermilch Grabengasse 7. Silberner Karton mit Vitaminen & Mineralien.",
|
||||
},
|
||||
{
|
||||
"id": "fruechte-mix-tk",
|
||||
"name": "Früchte Mix (TK)",
|
||||
"brand": None,
|
||||
"category": "obst",
|
||||
"per_100": {
|
||||
"kcal": 45.0,
|
||||
"protein_g": 1.0,
|
||||
"carbs_g": 6.5,
|
||||
"sugar_g": 3.4,
|
||||
"fat_g": 0.3,
|
||||
"fiber_g": 3.4,
|
||||
"salt_g": 0.0,
|
||||
},
|
||||
"density_g_per_ml": 1.0,
|
||||
"ingredients_text": "Gefrorene Beeren-Mischung (TK)",
|
||||
"barcode": None,
|
||||
"source": "package_photo",
|
||||
"description": "Standard gefrorener Beerenmix für Porridge und Overnight Oats.",
|
||||
},
|
||||
{
|
||||
"id": "muller-protein-porridge-mix",
|
||||
"name": "Müller Protein-Porridge Batch-Mix",
|
||||
"brand": "Eigenmischung",
|
||||
"category": "muesli",
|
||||
"per_100": {
|
||||
"kcal": 381.0,
|
||||
"protein_g": 23.3,
|
||||
"carbs_g": 46.6,
|
||||
"sugar_g": 2.9,
|
||||
"fat_g": 8.5,
|
||||
"fiber_g": 9.8,
|
||||
"salt_g": 0.15,
|
||||
},
|
||||
"density_g_per_ml": 1.0,
|
||||
"ingredients_text": "Müller Porridge Basic 500g + Müller Eiweißpulver 100g + Chiasamen 50g",
|
||||
"barcode": None,
|
||||
"source": "recipe_batch",
|
||||
"description": "Eigener Batch-Mix für Morgenporridge (Müller Porridge Basic + Eiweißpulver + Chiasamen).",
|
||||
},
|
||||
]
|
||||
|
||||
with open(foods_file, "w", encoding="utf-8") as f:
|
||||
for item in foods:
|
||||
f.write(json.dumps(item, ensure_ascii=False) + "\n")
|
||||
|
||||
return foods
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# 2. Migrate Nutrition Logs
|
||||
# ==============================================================================
|
||||
def migrate_nutrition_logs() -> Tuple[List[Dict[str, Any]], Dict[str, Dict[str, Any]]]:
|
||||
nutrition_dir = WIKI_DIR / "entities" / "nutrition-log" / "moritz"
|
||||
events_dir = DATA_DIR / "events" / "nutrition"
|
||||
events_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
all_events: List[Dict[str, Any]] = []
|
||||
stated_sums: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
for md_file in sorted(nutrition_dir.glob("2026-*.md")):
|
||||
month_str = md_file.stem
|
||||
file_events: List[Dict[str, Any]] = []
|
||||
|
||||
current_date_str = None
|
||||
current_day_name = None
|
||||
|
||||
with open(md_file, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line_str = line.strip()
|
||||
|
||||
# Match date header: ## YYYY-MM-DD Wochentag
|
||||
m_date = re.match(r"^##\s+(\d{4}-\d{2}-\d{2})(?:\s+(\w+))?", line_str)
|
||||
if m_date:
|
||||
current_date_str = m_date.group(1)
|
||||
current_day_name = m_date.group(2) or ""
|
||||
continue
|
||||
|
||||
# Match table row
|
||||
if current_date_str and line_str.startswith("|") and not line_str.startswith("| Zeit") and not line_str.startswith("|---"):
|
||||
cols = [c.strip() for c in line_str.strip("|").split("|")]
|
||||
if len(cols) >= 10:
|
||||
zeit_raw = cols[0]
|
||||
item_raw = cols[1]
|
||||
menge_raw = cols[2]
|
||||
eiweiss_raw = cols[3]
|
||||
kh_raw = cols[4]
|
||||
zucker_raw = cols[5]
|
||||
fett_raw = cols[6]
|
||||
ballast_raw = cols[7]
|
||||
salz_raw = cols[8]
|
||||
kcal_raw = cols[9]
|
||||
|
||||
# Parse time (default 12:00 if unknown)
|
||||
m_time = re.search(r"(\d{1,2}):(\d{2})", zeit_raw)
|
||||
if m_time:
|
||||
hour = int(m_time.group(1))
|
||||
minute = int(m_time.group(2))
|
||||
else:
|
||||
hour, minute = 12, 0
|
||||
|
||||
event_dt = datetime.datetime.strptime(current_date_str, "%Y-%m-%d").replace(
|
||||
hour=hour, minute=minute, tzinfo=BERLIN_TZ
|
||||
)
|
||||
|
||||
# Determine food_id if matched
|
||||
food_id = None
|
||||
if "oatly" in item_raw.lower() or "haferdrink" in item_raw.lower():
|
||||
food_id = "oatly-haferdrink"
|
||||
elif "früchte mix" in item_raw.lower() or "beeren" in item_raw.lower():
|
||||
food_id = "fruechte-mix-tk"
|
||||
elif "porridge-mischung" in item_raw.lower() or "müller protein-porridge" in item_raw.lower():
|
||||
food_id = "muller-protein-porridge-mix"
|
||||
|
||||
# Parse quantity and unit
|
||||
m_qty = re.search(r"(\d+(?:[.,]\d+)?)\s*(g|ml|portion|kugel)?", menge_raw)
|
||||
qty = float(m_qty.group(1).replace(",", ".")) if m_qty else parse_number(menge_raw)
|
||||
unit = m_qty.group(2) if m_qty and m_qty.group(2) else "g"
|
||||
|
||||
is_estimated = "~" in zeit_raw or "~" in item_raw or "~" in menge_raw or "~" in kcal_raw
|
||||
|
||||
record = {
|
||||
"id": generate_ulid(),
|
||||
"kind": "nutrition.intake",
|
||||
"subject": "moritz",
|
||||
"actor": "agent:koch",
|
||||
"start": event_dt.isoformat(),
|
||||
"end": None,
|
||||
"estimated": is_estimated,
|
||||
"meal": None,
|
||||
"note": None,
|
||||
"payload": {
|
||||
"item": item_raw,
|
||||
"food_id": food_id,
|
||||
"qty": qty,
|
||||
"unit": unit,
|
||||
"protein_g": parse_number(eiweiss_raw),
|
||||
"carbs_g": parse_number(kh_raw),
|
||||
"sugar_g": parse_number(zucker_raw),
|
||||
"fat_g": parse_number(fett_raw),
|
||||
"fiber_g": parse_number(ballast_raw),
|
||||
"salt_g": parse_number(salz_raw),
|
||||
"kcal": parse_number(kcal_raw),
|
||||
},
|
||||
"source_ref": f"{md_file.name}:{zeit_raw}",
|
||||
"created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
"updated_at": None,
|
||||
"deleted_at": None,
|
||||
}
|
||||
file_events.append(record)
|
||||
all_events.append(record)
|
||||
|
||||
# Match stated sums: **Summe heute:** ...
|
||||
if current_date_str and "**Summe heute:**" in line_str:
|
||||
sum_text = line_str.split("**Summe heute:**")[1].strip()
|
||||
stated_sums[current_date_str] = {
|
||||
"text": sum_text,
|
||||
"protein_g": parse_number(re.search(r"([\d,]+)\s*g\s*Eiweiß", sum_text).group(1)) if re.search(r"([\d,]+)\s*g\s*Eiweiß", sum_text) else 0.0,
|
||||
"carbs_g": parse_number(re.search(r"([\d,]+)\s*g\s*KH", sum_text).group(1)) if re.search(r"([\d,]+)\s*g\s*KH", sum_text) else 0.0,
|
||||
"kcal": parse_number(re.search(r"([\d,.]+)\s*kcal", sum_text).group(1)) if re.search(r"([\d,.]+)\s*kcal", sum_text) else 0.0,
|
||||
}
|
||||
|
||||
# Write partitioned JSONL
|
||||
file_events.sort(key=lambda x: (x["start"], x["id"]))
|
||||
out_jsonl = events_dir / f"{month_str}.jsonl"
|
||||
with open(out_jsonl, "w", encoding="utf-8") as out_f:
|
||||
for ev in file_events:
|
||||
out_f.write(json.dumps(ev, ensure_ascii=False) + "\n")
|
||||
|
||||
return all_events, stated_sums
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# 3. Migrate Mila Sleep & Feeding Logs
|
||||
# ==============================================================================
|
||||
def migrate_mila_sleep_and_feeding() -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
|
||||
md_file = WIKI_DIR / "entities" / "people" / "mila-schlafprotokoll.md"
|
||||
sleep_events: List[Dict[str, Any]] = []
|
||||
feeding_events: List[Dict[str, Any]] = []
|
||||
|
||||
if not md_file.is_file():
|
||||
return sleep_events, feeding_events
|
||||
|
||||
current_date = None
|
||||
processed_nights = set()
|
||||
|
||||
with open(md_file, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line_str = line.strip()
|
||||
|
||||
# Date header: ## Dienstag, 29.09.2026 or ## 2026-09-29
|
||||
m_date = re.search(r"(\d{2})\.(\d{2})\.(\d{4})", line_str)
|
||||
if line_str.startswith("##") and m_date:
|
||||
day, month, year = int(m_date.group(1)), int(m_date.group(2)), int(m_date.group(3))
|
||||
current_date = datetime.date(year, month, day)
|
||||
continue
|
||||
|
||||
if not current_date or not line_str.startswith("-"):
|
||||
continue
|
||||
|
||||
# Case A: Feeding log (e.g. - 08:15 – Fläschchen (80 ml übrig/ungetrunken))
|
||||
if "fläschchen" in line_str.lower():
|
||||
m_time = re.search(r"(\d{1,2}):(\d{2})", line_str)
|
||||
h, m = (int(m_time.group(1)), int(m_time.group(2))) if m_time else (8, 0)
|
||||
feed_dt = datetime.datetime(current_date.year, current_date.month, current_date.day, h, m, tzinfo=BERLIN_TZ)
|
||||
|
||||
m_ml = re.search(r"(\d+)\s*ml", line_str)
|
||||
left_ml = float(m_ml.group(1)) if m_ml else 0.0
|
||||
|
||||
record = {
|
||||
"id": generate_ulid(),
|
||||
"kind": "feeding.bottle",
|
||||
"subject": "mila",
|
||||
"actor": "agent:hebamme",
|
||||
"start": feed_dt.isoformat(),
|
||||
"end": None,
|
||||
"estimated": False,
|
||||
"meal": None,
|
||||
"note": line_str.lstrip("- ").strip(),
|
||||
"payload": {
|
||||
"offered_ml": None,
|
||||
"consumed_ml": None,
|
||||
"left_ml": left_ml,
|
||||
"milk_type": "pre",
|
||||
},
|
||||
"source_ref": f"mila-schlafprotokoll.md:{feed_dt.strftime('%Y-%m-%d %H:%M')}",
|
||||
"created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
"updated_at": None,
|
||||
"deleted_at": None,
|
||||
}
|
||||
feeding_events.append(record)
|
||||
continue
|
||||
|
||||
# Case B: Sleep intervals
|
||||
# Examples:
|
||||
# - 09:51 – 10:38 | Schlaf am Morgen (46 min: 10 min wach, 6 Leicht-, 30 Tiefschlaf; Einschlafzeit 5 min)
|
||||
# - 18:57 – 06:50 | Nacht (11 h 53 min)
|
||||
# - 20:10 – | Nacht (läuft)
|
||||
# - 07:00 – aufgewacht (Nacht-Ende); Nacht: 19:13 – 07:00 (11 h 47 min)
|
||||
|
||||
# Check for "aufgewacht (Nacht-Ende)" duplicate
|
||||
if "aufgewacht (nacht-ende)" in line_str.lower() or "nacht-ende" in line_str.lower():
|
||||
m_span = re.search(r"(\d{1,2}):(\d{2})\s*–\s*(\d{1,2}):(\d{2})", line_str)
|
||||
if m_span:
|
||||
s_h, s_m, e_h, e_m = int(m_span.group(1)), int(m_span.group(2)), int(m_span.group(3)), int(m_span.group(4))
|
||||
night_key = f"{s_h:02d}:{s_m:02d}-{e_h:02d}:{e_m:02d}"
|
||||
if night_key in processed_nights:
|
||||
# Already logged from the evening line, skip duplicate!
|
||||
continue
|
||||
else:
|
||||
processed_nights.add(night_key)
|
||||
# The start was yesterday evening
|
||||
start_dt = datetime.datetime(current_date.year, current_date.month, current_date.day, s_h, s_m, tzinfo=BERLIN_TZ) - datetime.timedelta(days=1)
|
||||
end_dt = datetime.datetime(current_date.year, current_date.month, current_date.day, e_h, e_m, tzinfo=BERLIN_TZ)
|
||||
record = {
|
||||
"id": generate_ulid(),
|
||||
"kind": "sleep.session",
|
||||
"subject": "mila",
|
||||
"actor": "agent:hebamme",
|
||||
"start": start_dt.isoformat(),
|
||||
"end": end_dt.isoformat(),
|
||||
"estimated": False,
|
||||
"meal": None,
|
||||
"note": line_str.lstrip("- ").strip(),
|
||||
"payload": {
|
||||
"type": "night",
|
||||
"location": "bett",
|
||||
},
|
||||
"source_ref": f"mila-schlafprotokoll.md:{current_date}",
|
||||
"created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
"updated_at": None,
|
||||
"deleted_at": None,
|
||||
}
|
||||
sleep_events.append(record)
|
||||
continue
|
||||
|
||||
# Standard interval: start – end | label
|
||||
m_int = re.search(r"^-\s*(\d{1,2}):(\d{2})\s*–\s*(\d{1,2})?:?(\d{2})?\s*\|?(.*)", line_str)
|
||||
if m_int:
|
||||
s_h = int(m_int.group(1))
|
||||
s_m = int(m_int.group(2))
|
||||
e_h_str = m_int.group(3)
|
||||
e_m_str = m_int.group(4)
|
||||
desc = m_int.group(5).strip() if m_int.group(5) else ""
|
||||
|
||||
start_dt = datetime.datetime(current_date.year, current_date.month, current_date.day, s_h, s_m, tzinfo=BERLIN_TZ)
|
||||
|
||||
# Open sleep interval (e.g. 20:10 – | Nacht (läuft))
|
||||
if not e_h_str or not e_m_str:
|
||||
end_dt = None
|
||||
sleep_type = "night" if s_h >= 18 or s_h < 6 else "nap"
|
||||
else:
|
||||
e_h = int(e_h_str)
|
||||
e_m = int(e_m_str)
|
||||
# If end hour < start hour, crosses midnight!
|
||||
if e_h < s_h:
|
||||
end_dt = datetime.datetime(current_date.year, current_date.month, current_date.day, e_h, e_m, tzinfo=BERLIN_TZ) + datetime.timedelta(days=1)
|
||||
else:
|
||||
end_dt = datetime.datetime(current_date.year, current_date.month, current_date.day, e_h, e_m, tzinfo=BERLIN_TZ)
|
||||
|
||||
sleep_type = "night" if "nacht" in desc.lower() or s_h >= 18 else "nap"
|
||||
|
||||
# Deduplication key for night sleeps
|
||||
night_key = f"{s_h:02d}:{s_m:02d}-{e_h:02d}:{e_m:02d}"
|
||||
if sleep_type == "night":
|
||||
processed_nights.add(night_key)
|
||||
|
||||
# Location detection
|
||||
loc = "bett"
|
||||
if "auto" in desc.lower():
|
||||
loc = "auto"
|
||||
elif "kinderwagen" in desc.lower():
|
||||
loc = "kinderwagen"
|
||||
elif "outdoor" in desc.lower() or "decke" in desc.lower():
|
||||
loc = "outdoor"
|
||||
|
||||
record = {
|
||||
"id": generate_ulid(),
|
||||
"kind": "sleep.session",
|
||||
"subject": "mila",
|
||||
"actor": "agent:hebamme",
|
||||
"start": start_dt.isoformat(),
|
||||
"end": end_dt.isoformat() if end_dt else None,
|
||||
"estimated": False,
|
||||
"meal": None,
|
||||
"note": desc or None,
|
||||
"payload": {
|
||||
"type": sleep_type,
|
||||
"location": loc,
|
||||
},
|
||||
"source_ref": f"mila-schlafprotokoll.md:{current_date}",
|
||||
"created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
"updated_at": None,
|
||||
"deleted_at": None,
|
||||
}
|
||||
sleep_events.append(record)
|
||||
|
||||
# Write partition files
|
||||
sleep_dir = DATA_DIR / "events" / "sleep"
|
||||
sleep_dir.mkdir(parents=True, exist_ok=True)
|
||||
feeding_dir = DATA_DIR / "events" / "feeding"
|
||||
feeding_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Group sleep by month
|
||||
sleep_by_month: Dict[str, List[Dict[str, Any]]] = {}
|
||||
for ev in sleep_events:
|
||||
m_str = ev["start"][:7] # YYYY-MM
|
||||
sleep_by_month.setdefault(m_str, []).append(ev)
|
||||
|
||||
for m_str, m_events in sleep_by_month.items():
|
||||
m_events.sort(key=lambda x: (x["start"], x["id"]))
|
||||
with open(sleep_dir / f"{m_str}.jsonl", "w", encoding="utf-8") as f:
|
||||
for ev in m_events:
|
||||
f.write(json.dumps(ev, ensure_ascii=False) + "\n")
|
||||
|
||||
# Group feeding by month
|
||||
feed_by_month: Dict[str, List[Dict[str, Any]]] = {}
|
||||
for ev in feeding_events:
|
||||
m_str = ev["start"][:7]
|
||||
feed_by_month.setdefault(m_str, []).append(ev)
|
||||
|
||||
for m_str, m_events in feed_by_month.items():
|
||||
m_events.sort(key=lambda x: (x["start"], x["id"]))
|
||||
with open(feeding_dir / f"{m_str}.jsonl", "w", encoding="utf-8") as f:
|
||||
for ev in m_events:
|
||||
f.write(json.dumps(ev, ensure_ascii=False) + "\n")
|
||||
|
||||
return sleep_events, feeding_events
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# 4. Generate Audit Report
|
||||
# ==============================================================================
|
||||
def generate_audit_report(stated_sums: Dict[str, Dict[str, Any]]) -> str:
|
||||
con = duckdb.connect(":memory:")
|
||||
# Initialize schema
|
||||
schema_dir = DATA_DIR / "_schema"
|
||||
for sql_f in ["envelope.sql", "entities.sql", "views.sql"]:
|
||||
con.execute((schema_dir / sql_f).read_text(encoding="utf-8"))
|
||||
|
||||
# Load foods
|
||||
foods_file = DATA_DIR / "entities" / "foods.jsonl"
|
||||
if foods_file.is_file():
|
||||
con.execute(f"INSERT INTO foods SELECT * FROM read_json_auto('{foods_file}')")
|
||||
|
||||
# Load all nutrition events
|
||||
nut_files = str(DATA_DIR / "events" / "nutrition" / "*.jsonl")
|
||||
con.execute(f"INSERT INTO events SELECT * FROM read_json_auto('{nut_files}')")
|
||||
|
||||
# Query daily sums
|
||||
db_sums = con.execute("SELECT log_date, total_protein_g, total_carbs_g, total_kcal, item_count FROM v_nutrition_daily ORDER BY log_date ASC").fetchall()
|
||||
|
||||
lines = []
|
||||
lines.append("# 📊 Datastore Migration: Arithmetic Discrepancy Audit Report\n")
|
||||
lines.append(f"**Generated:** {datetime.datetime.now(BERLIN_TZ).strftime('%Y-%m-%d %H:%M:%S')} ")
|
||||
lines.append("**Source of Truth:** DuckDB engine over `data/events/nutrition/*.jsonl` \n")
|
||||
lines.append("This audit compares the old handwritten Markdown `**Summe heute:**` against the mathematically exact DuckDB column summations.\n")
|
||||
lines.append("| Date | Items | Hand Stated Protein | DuckDB Exact Protein | Δ Protein | Hand Stated kcal | DuckDB Exact kcal | Δ kcal |")
|
||||
lines.append("| :--- | :---: | :---: | :---: | :---: | :---: | :---: | :---: |")
|
||||
|
||||
for row in db_sums:
|
||||
d_str = str(row[0])
|
||||
p_exact = row[1]
|
||||
k_exact = int(row[3])
|
||||
count = row[4]
|
||||
|
||||
stated = stated_sums.get(d_str, {})
|
||||
p_hand = stated.get("protein_g", 0.0)
|
||||
k_hand = int(stated.get("kcal", 0))
|
||||
|
||||
delta_p = round(p_exact - p_hand, 1)
|
||||
delta_k = k_exact - k_hand
|
||||
|
||||
p_diff_str = f"**{delta_p:+.1f} g**" if abs(delta_p) > 0.1 else "✅ 0.0"
|
||||
k_diff_str = f"**{delta_k:+d} kcal**" if abs(delta_k) > 2 else "✅ 0"
|
||||
|
||||
lines.append(f"| {d_str} | {count} | {p_hand:.1f} g | {p_exact:.1f} g | {p_diff_str} | {k_hand} | {k_exact} | {k_diff_str} |")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("1. Migrating Standard Foods...")
|
||||
foods = migrate_standard_foods()
|
||||
print(f" -> Migrated {len(foods)} food reference items.")
|
||||
|
||||
print("2. Migrating Nutrition Logs...")
|
||||
nut_events, stated_sums = migrate_nutrition_logs()
|
||||
print(f" -> Migrated {len(nut_events)} nutrition events across {len(stated_sums)} days.")
|
||||
|
||||
print("3. Migrating Mila Sleep & Feeding Logs...")
|
||||
sleep_events, feed_events = migrate_mila_sleep_and_feeding()
|
||||
print(f" -> Migrated {len(sleep_events)} sleep events and {len(feed_events)} bottle feeding events.")
|
||||
|
||||
print("4. Generating Discrepancy Audit Report...")
|
||||
report = generate_audit_report(stated_sums)
|
||||
report_file = WIKI_DIR / "reports" / "datastore-migration-audit.md"
|
||||
report_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
report_file.write_text(report, encoding="utf-8")
|
||||
print(f" -> Wrote audit report to {report_file}")
|
||||
print("\n--- Summary ---")
|
||||
print(report)
|
||||
@@ -0,0 +1,203 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# dependencies = [
|
||||
# "duckdb>=1.1.0",
|
||||
# "pytz>=2024.1",
|
||||
# "python-ulid>=3.0.0",
|
||||
# ]
|
||||
# ///
|
||||
"""
|
||||
morning_briefing.py — Generates the daily morning Hebamme sleep roundup for Mila,
|
||||
computes 7d vs 30d trends, renders a visual trend chart via chart-to-chat,
|
||||
and returns the message with MEDIA:<path> and the permanent website URL.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
if str(SCRIPT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SCRIPT_DIR))
|
||||
|
||||
from datastore import DatastoreEngine, find_data_dir
|
||||
|
||||
BERLIN_TZ = ZoneInfo("Europe/Berlin")
|
||||
MILA_BIRTHDATE = datetime.date(2026, 3, 14)
|
||||
|
||||
WEEKDAYS_SHORT = {
|
||||
0: "Mo",
|
||||
1: "Di",
|
||||
2: "Mi",
|
||||
3: "Do",
|
||||
4: "Fr",
|
||||
5: "Sa",
|
||||
6: "So",
|
||||
}
|
||||
|
||||
|
||||
def find_chart_script() -> Optional[Path]:
|
||||
candidates = [
|
||||
SCRIPT_DIR.parent.parent / "chart-to-chat" / "scripts" / "chart.py",
|
||||
Path("/home/node/mop-skills/mop-skills/skills/chart-to-chat/scripts/chart.py"),
|
||||
Path("/Users/moritz/src/mop-skills/skills/chart-to-chat/scripts/chart.py"),
|
||||
]
|
||||
for c in candidates:
|
||||
if c.is_file():
|
||||
return c
|
||||
return None
|
||||
|
||||
|
||||
def generate_morning_briefing(force: bool = False) -> Dict[str, Any]:
|
||||
data_dir = find_data_dir()
|
||||
state_file = data_dir / ".mila_morning_state.json"
|
||||
now_berlin = datetime.datetime.now(BERLIN_TZ)
|
||||
today = now_berlin.date()
|
||||
today_str = today.strftime("%Y-%m-%d")
|
||||
|
||||
# State check: only brief once per day unless forced
|
||||
if not force and state_file.is_file():
|
||||
try:
|
||||
state = json.loads(state_file.read_text(encoding="utf-8"))
|
||||
if state.get("last_briefed_date") == today_str:
|
||||
return {
|
||||
"already_briefed": True,
|
||||
"date": today_str,
|
||||
"message": None,
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
engine = DatastoreEngine(data_dir)
|
||||
con = engine.con
|
||||
|
||||
yesterday = today - datetime.timedelta(days=1)
|
||||
yesterday_str = yesterday.strftime("%Y-%m-%d")
|
||||
|
||||
# 1. Yesterday's sleep
|
||||
y_agg = con.execute(
|
||||
f"""
|
||||
SELECT total_sleep_hours, night_sleep_minutes, nap_count, nap_sleep_minutes
|
||||
FROM v_sleep_daily
|
||||
WHERE subject = 'mila' AND log_date = '{yesterday_str}'
|
||||
"""
|
||||
).fetchone()
|
||||
|
||||
y_tot = y_agg[0] if y_agg and y_agg[0] is not None else 0.0
|
||||
y_night_min = y_agg[1] if y_agg and y_agg[1] is not None else 0
|
||||
y_night_h, y_night_m = divmod(y_night_min, 60)
|
||||
y_naps = y_agg[2] if y_agg and y_agg[2] is not None else 0
|
||||
y_naps_min = y_agg[3] if y_agg and y_agg[3] is not None else 0
|
||||
y_naps_h, y_naps_m = divmod(y_naps_min, 60)
|
||||
|
||||
# 2. 7-Day & 30-Day Averages
|
||||
avg_7 = con.execute(
|
||||
"""
|
||||
SELECT ROUND(AVG(total_sleep_hours), 2), ROUND(AVG(night_sleep_minutes)/60.0, 2), ROUND(AVG(nap_count), 1)
|
||||
FROM (SELECT total_sleep_hours, night_sleep_minutes, nap_count FROM v_sleep_daily WHERE subject = 'mila' ORDER BY log_date DESC LIMIT 7)
|
||||
"""
|
||||
).fetchone()
|
||||
avg_7_tot = avg_7[0] if avg_7 and avg_7[0] is not None else y_tot
|
||||
avg_7_night = avg_7[1] if avg_7 and avg_7[1] is not None else 0.0
|
||||
avg_7_naps = avg_7[2] if avg_7 and avg_7[2] is not None else 0.0
|
||||
|
||||
avg_30 = con.execute(
|
||||
"""
|
||||
SELECT ROUND(AVG(total_sleep_hours), 2)
|
||||
FROM (SELECT total_sleep_hours FROM v_sleep_daily WHERE subject = 'mila' ORDER BY log_date DESC LIMIT 30)
|
||||
"""
|
||||
).fetchone()
|
||||
avg_30_tot = avg_30[0] if avg_30 and avg_30[0] is not None else avg_7_tot
|
||||
|
||||
# 3. 7-Day History Data for Chart
|
||||
recent_7_rows = con.execute(
|
||||
"""
|
||||
SELECT log_date, ROUND(total_sleep_hours, 1)
|
||||
FROM v_sleep_daily
|
||||
WHERE subject = 'mila'
|
||||
ORDER BY log_date ASC
|
||||
LIMIT 7
|
||||
"""
|
||||
).fetchall()
|
||||
|
||||
chart_media_tag = ""
|
||||
chart_script = find_chart_script()
|
||||
if chart_script and len(recent_7_rows) >= 3:
|
||||
chart_data = []
|
||||
for r in recent_7_rows:
|
||||
d_obj = datetime.date.fromisoformat(str(r[0]))
|
||||
label = f"{WEEKDAYS_SHORT[d_obj.weekday()]} {d_obj.strftime('%d.%m.')}"
|
||||
chart_data.append({"day": label, "hours": float(r[1])})
|
||||
|
||||
try:
|
||||
chart_cmd = [
|
||||
sys.executable,
|
||||
str(chart_script),
|
||||
"--title", "Mila Schlaf letzte 7 Tage (Stunden)",
|
||||
"--type", "bar",
|
||||
"--data", json.dumps(chart_data),
|
||||
]
|
||||
res = subprocess.run(chart_cmd, capture_output=True, text=True, timeout=15)
|
||||
for line in res.stdout.splitlines():
|
||||
if line.startswith("MEDIA:"):
|
||||
chart_media_tag = "\n\n" + line.strip()
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 4. Mila's exact age & pediatric reflection
|
||||
days_old = (today - MILA_BIRTHDATE).days
|
||||
months_old = days_old // 30.4375
|
||||
|
||||
trend_reflection = (
|
||||
f"Mila schläft im 7-Tage-Schnitt **{avg_7_tot:.1f} h** (30-Tage-Basis: **{avg_30_tot:.1f} h**). "
|
||||
f"Mit rund {int(months_old)} Monaten liegt das wunderbar im altersgerechten Entwicklungsfenster (12–15 Std/24h). "
|
||||
f"Die Hauptschlafphase nachts konsolidiert sich stabil (Ø {avg_7_night:.1f} h), während tagsüber typischerweise 2–3 Schläfchen den Rhythmus tragen."
|
||||
)
|
||||
|
||||
briefing_text = (
|
||||
f"Guten Morgen! ☀️ Hier ist euer morgendlicher Schlafrückblick für Mila:\n\n"
|
||||
f"**Gestern ({yesterday.strftime('%d.%m.')}):**\n"
|
||||
f"• 24h Gesamtschlaf: **{y_tot:.1f} h**\n"
|
||||
f"• Nachtschlaf: **{y_night_h} h {y_night_m:02d} min**\n"
|
||||
f"• Tagesschläfchen: **{y_naps} Naps** ({y_naps_h} h {y_naps_m:02d} min)\n\n"
|
||||
f"**Entwicklung & Trend:**\n"
|
||||
f"• {trend_reflection}\n\n"
|
||||
f"🔗 **Live-Dashboard:** https://family.haumdaucher.de/tracking/mila/\n"
|
||||
f"{chart_media_tag}"
|
||||
)
|
||||
|
||||
# Save state
|
||||
try:
|
||||
state_file.write_text(json.dumps({"last_briefed_date": today_str, "updated_at": now_berlin.isoformat()}), encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"already_briefed": False,
|
||||
"date": today_str,
|
||||
"message": briefing_text,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Mila Morning Sleep Briefing")
|
||||
parser.add_argument("--force", action="store_true", help="Force briefing even if already generated today")
|
||||
args = parser.parse_args()
|
||||
|
||||
result = generate_morning_briefing(force=args.force)
|
||||
if result.get("already_briefed"):
|
||||
print(f"Already briefed for today ({result.get('date')}). Use --force to override.")
|
||||
else:
|
||||
print(result.get("message"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
who_growth.py — WHO Child Growth Standards (Girls 0–24 Months)
|
||||
Deterministic percentile and z-score calculations via LMS method
|
||||
and reference curve data for Mila's growth tracking.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
# WHO Child Growth Standards (Girls: Birth to 24 Months)
|
||||
# Source: World Health Organization (WHO) Multi-centre Growth Reference Study (MGRS)
|
||||
# https://www.who.int/tools/child-growth-standards/standards
|
||||
WHO_GIRLS_WEIGHT = [
|
||||
{"month": 0, "L": 0.3809, "M": 3.2322, "S": 0.14171, "p3": 2.4, "p50": 3.2, "p97": 4.2},
|
||||
{"month": 1, "L": 0.1714, "M": 4.1873, "S": 0.13724, "p3": 3.2, "p50": 4.2, "p97": 5.4},
|
||||
{"month": 2, "L": 0.0962, "M": 5.1282, "S": 0.13000, "p3": 4.0, "p50": 5.1, "p97": 6.5},
|
||||
{"month": 3, "L": 0.0402, "M": 5.8458, "S": 0.12619, "p3": 4.6, "p50": 5.8, "p97": 7.4},
|
||||
{"month": 4, "L": -0.0050, "M": 6.4237, "S": 0.12402, "p3": 5.1, "p50": 6.4, "p97": 8.1},
|
||||
{"month": 5, "L": -0.0430, "M": 6.8985, "S": 0.12274, "p3": 5.5, "p50": 6.9, "p97": 8.7},
|
||||
{"month": 6, "L": -0.0756, "M": 7.2970, "S": 0.12204, "p3": 5.8, "p50": 7.3, "p97": 9.2},
|
||||
{"month": 7, "L": -0.1039, "M": 7.6422, "S": 0.12178, "p3": 6.1, "p50": 7.6, "p97": 9.6},
|
||||
{"month": 8, "L": -0.1288, "M": 7.9487, "S": 0.12181, "p3": 6.3, "p50": 7.9, "p97": 10.0},
|
||||
{"month": 9, "L": -0.1507, "M": 8.2254, "S": 0.12199, "p3": 6.6, "p50": 8.2, "p97": 10.4},
|
||||
{"month": 10, "L": -0.1700, "M": 8.4800, "S": 0.12223, "p3": 6.8, "p50": 8.5, "p97": 10.7},
|
||||
{"month": 11, "L": -0.1872, "M": 8.7192, "S": 0.12247, "p3": 7.0, "p50": 8.7, "p97": 11.0},
|
||||
{"month": 12, "L": -0.2024, "M": 8.9481, "S": 0.12268, "p3": 7.1, "p50": 8.9, "p97": 11.3},
|
||||
{"month": 13, "L": -0.2158, "M": 9.1699, "S": 0.12283, "p3": 7.3, "p50": 9.2, "p97": 11.6},
|
||||
{"month": 14, "L": -0.2278, "M": 9.3870, "S": 0.12294, "p3": 7.5, "p50": 9.4, "p97": 11.9},
|
||||
{"month": 15, "L": -0.2384, "M": 9.6008, "S": 0.12299, "p3": 7.7, "p50": 9.6, "p97": 12.2},
|
||||
{"month": 16, "L": -0.2478, "M": 9.8124, "S": 0.12303, "p3": 7.8, "p50": 9.8, "p97": 12.5},
|
||||
{"month": 17, "L": -0.2562, "M": 10.0226, "S": 0.12306, "p3": 8.0, "p50": 10.0, "p97": 12.7},
|
||||
{"month": 18, "L": -0.2637, "M": 10.2315, "S": 0.12309, "p3": 8.2, "p50": 10.2, "p97": 13.0},
|
||||
{"month": 19, "L": -0.2703, "M": 10.4393, "S": 0.12315, "p3": 8.3, "p50": 10.4, "p97": 13.3},
|
||||
{"month": 20, "L": -0.2762, "M": 10.6464, "S": 0.12323, "p3": 8.5, "p50": 10.6, "p97": 13.5},
|
||||
{"month": 21, "L": -0.2815, "M": 10.8534, "S": 0.12335, "p3": 8.7, "p50": 10.9, "p97": 13.8},
|
||||
{"month": 22, "L": -0.2862, "M": 11.0608, "S": 0.12350, "p3": 8.8, "p50": 11.1, "p97": 14.1},
|
||||
{"month": 23, "L": -0.2903, "M": 11.2688, "S": 0.12369, "p3": 9.0, "p50": 11.3, "p97": 14.3},
|
||||
{"month": 24, "L": -0.2941, "M": 11.4775, "S": 0.12390, "p3": 9.2, "p50": 11.5, "p97": 14.6},
|
||||
]
|
||||
|
||||
WHO_GIRLS_HEIGHT = [
|
||||
{"month": 0, "L": 1.0, "M": 49.1477, "S": 0.03790, "p3": 45.6, "p50": 49.1, "p97": 52.7},
|
||||
{"month": 1, "L": 1.0, "M": 53.6872, "S": 0.03640, "p3": 50.0, "p50": 53.7, "p97": 57.4},
|
||||
{"month": 2, "L": 1.0, "M": 57.0673, "S": 0.03568, "p3": 53.2, "p50": 57.1, "p97": 60.9},
|
||||
{"month": 3, "L": 1.0, "M": 59.8029, "S": 0.03520, "p3": 55.8, "p50": 59.8, "p97": 63.8},
|
||||
{"month": 4, "L": 1.0, "M": 62.0899, "S": 0.03486, "p3": 58.0, "p50": 62.1, "p97": 66.2},
|
||||
{"month": 5, "L": 1.0, "M": 64.0301, "S": 0.03463, "p3": 59.9, "p50": 64.0, "p97": 68.2},
|
||||
{"month": 6, "L": 1.0, "M": 65.7311, "S": 0.03448, "p3": 61.5, "p50": 65.7, "p97": 70.0},
|
||||
{"month": 7, "L": 1.0, "M": 67.2873, "S": 0.03441, "p3": 62.9, "p50": 67.3, "p97": 71.6},
|
||||
{"month": 8, "L": 1.0, "M": 68.7498, "S": 0.03440, "p3": 64.3, "p50": 68.7, "p97": 73.2},
|
||||
{"month": 9, "L": 1.0, "M": 70.1435, "S": 0.03444, "p3": 65.6, "p50": 70.1, "p97": 74.7},
|
||||
{"month": 10, "L": 1.0, "M": 71.4818, "S": 0.03452, "p3": 66.8, "p50": 71.5, "p97": 76.1},
|
||||
{"month": 11, "L": 1.0, "M": 72.7710, "S": 0.03464, "p3": 68.0, "p50": 72.8, "p97": 77.5},
|
||||
{"month": 12, "L": 1.0, "M": 74.0150, "S": 0.03479, "p3": 69.2, "p50": 74.0, "p97": 78.9},
|
||||
{"month": 13, "L": 1.0, "M": 75.2176, "S": 0.03496, "p3": 70.3, "p50": 75.2, "p97": 80.2},
|
||||
{"month": 14, "L": 1.0, "M": 76.3817, "S": 0.03514, "p3": 71.3, "p50": 76.4, "p97": 81.4},
|
||||
{"month": 15, "L": 1.0, "M": 77.5099, "S": 0.03534, "p3": 72.4, "p50": 77.5, "p97": 82.7},
|
||||
{"month": 16, "L": 1.0, "M": 78.6055, "S": 0.03555, "p3": 73.3, "p50": 78.6, "p97": 83.9},
|
||||
{"month": 17, "L": 1.0, "M": 79.6710, "S": 0.03576, "p3": 74.3, "p50": 79.7, "p97": 85.0},
|
||||
{"month": 18, "L": 1.0, "M": 80.7079, "S": 0.03598, "p3": 75.2, "p50": 80.7, "p97": 86.2},
|
||||
{"month": 19, "L": 1.0, "M": 81.7182, "S": 0.03620, "p3": 76.2, "p50": 81.7, "p97": 87.3},
|
||||
{"month": 20, "L": 1.0, "M": 82.7036, "S": 0.03643, "p3": 77.0, "p50": 82.7, "p97": 88.4},
|
||||
{"month": 21, "L": 1.0, "M": 83.6654, "S": 0.03666, "p3": 77.9, "p50": 83.7, "p97": 89.4},
|
||||
{"month": 22, "L": 1.0, "M": 84.6040, "S": 0.03688, "p3": 78.7, "p50": 84.6, "p97": 90.5},
|
||||
{"month": 23, "L": 1.0, "M": 85.5202, "S": 0.03711, "p3": 79.6, "p50": 85.5, "p97": 91.5},
|
||||
{"month": 24, "L": 1.0, "M": 86.4153, "S": 0.03734, "p3": 80.3, "p50": 86.4, "p97": 92.5},
|
||||
]
|
||||
|
||||
|
||||
def _interpolate_lms(table: List[Dict[str, float]], age_months: float) -> Tuple[float, float, float, float, float, float]:
|
||||
"""Linearly interpolates L, M, S, p3, p50, p97 for a given age in months."""
|
||||
m_clamped = max(0.0, min(float(table[-1]["month"]), float(age_months)))
|
||||
m_low = int(math.floor(m_clamped))
|
||||
m_high = int(math.ceil(m_clamped))
|
||||
|
||||
r_low = table[m_low]
|
||||
if m_low == m_high:
|
||||
return r_low["L"], r_low["M"], r_low["S"], r_low["p3"], r_low["p50"], r_low["p97"]
|
||||
|
||||
r_high = table[m_high]
|
||||
frac = m_clamped - m_low
|
||||
|
||||
L = r_low["L"] + frac * (r_high["L"] - r_low["L"])
|
||||
M = r_low["M"] + frac * (r_high["M"] - r_low["M"])
|
||||
S = r_low["S"] + frac * (r_high["S"] - r_low["S"])
|
||||
p3 = r_low["p3"] + frac * (r_high["p3"] - r_low["p3"])
|
||||
p50 = r_low["p50"] + frac * (r_high["p50"] - r_low["p50"])
|
||||
p97 = r_low["p97"] + frac * (r_high["p97"] - r_low["p97"])
|
||||
|
||||
return L, M, S, p3, p50, p97
|
||||
|
||||
|
||||
def calculate_percentile(metric: str, value: float, age_months: float) -> Dict[str, Any]:
|
||||
"""
|
||||
Computes exact WHO z-score and percentile using the LMS formula.
|
||||
metric: 'weight' (in kg) or 'height' (in cm)
|
||||
"""
|
||||
table = WHO_GIRLS_WEIGHT if metric == "weight" else WHO_GIRLS_HEIGHT
|
||||
L, M, S, p3, p50, p97 = _interpolate_lms(table, age_months)
|
||||
|
||||
if value <= 0 or M <= 0 or S <= 0:
|
||||
return {
|
||||
"value": value,
|
||||
"age_months": round(age_months, 2),
|
||||
"z_score": 0.0,
|
||||
"percentile": 50.0,
|
||||
"percentile_str": "P50",
|
||||
"p3": round(p3, 2),
|
||||
"p50": round(p50, 2),
|
||||
"p97": round(p97, 2),
|
||||
"in_normal_range": True,
|
||||
"status": "normal",
|
||||
}
|
||||
|
||||
# Box-Cox power exponential LMS formula
|
||||
if abs(L) > 1e-6:
|
||||
z = ((value / M) ** L - 1.0) / (L * S)
|
||||
else:
|
||||
z = math.log(value / M) / S
|
||||
|
||||
# Cumulative standard normal distribution
|
||||
p = 0.5 * (1.0 + math.erf(z / math.sqrt(2.0))) * 100.0
|
||||
p_clamped = max(0.1, min(99.9, p))
|
||||
|
||||
in_normal = 3.0 <= p_clamped <= 97.0
|
||||
if p_clamped < 3.0:
|
||||
status = "unter P3"
|
||||
elif p_clamped > 97.0:
|
||||
status = "über P97"
|
||||
else:
|
||||
status = "im WHO-Normbereich"
|
||||
|
||||
return {
|
||||
"value": value,
|
||||
"age_months": round(age_months, 2),
|
||||
"z_score": round(z, 2),
|
||||
"percentile": round(p_clamped, 1),
|
||||
"percentile_str": f"P{int(round(p_clamped))}",
|
||||
"p3": round(p3, 2),
|
||||
"p50": round(p50, 2),
|
||||
"p97": round(p97, 2),
|
||||
"in_normal_range": in_normal,
|
||||
"status": status,
|
||||
}
|
||||
|
||||
|
||||
def format_growth_feedback(
|
||||
weight_kg: Optional[float],
|
||||
height_cm: Optional[float],
|
||||
age_months: float,
|
||||
date_str: str,
|
||||
) -> str:
|
||||
"""Formats clear, comforting feedback for CLI and Hebamme agent responses."""
|
||||
parts = []
|
||||
if weight_kg is not None:
|
||||
pw = calculate_percentile("weight", weight_kg, age_months)
|
||||
parts.append(f"{weight_kg:.2f} kg ({pw['percentile_str']} · WHO Median: {pw['p50']:.1f} kg)")
|
||||
|
||||
if height_cm is not None:
|
||||
ph = calculate_percentile("height", height_cm, age_months)
|
||||
parts.append(f"{height_cm:.1f} cm ({ph['percentile_str']} · WHO Median: {ph['p50']:.1f} cm)")
|
||||
|
||||
if not parts:
|
||||
return f"📏 Messung ({date_str}) ohne Werte geloggt."
|
||||
|
||||
details = " · ".join(parts)
|
||||
return f"📏 Wachstum ({date_str}, ~{age_months:.1f} Mon): {details} [Normbereich: P3–P97]"
|
||||
@@ -0,0 +1,899 @@
|
||||
#!/usr/bin/env python3
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from datastore import DatastoreEngine
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_datastore():
|
||||
"""Creates a temporary datastore directory with schemas for testing."""
|
||||
temp_dir = Path(tempfile.mkdtemp())
|
||||
data_dir = temp_dir / "data"
|
||||
data_dir.mkdir(parents=True)
|
||||
|
||||
# Copy schemas from knowledge-wiki/data/_schema if available, or create them
|
||||
schema_src = Path("/Users/moritz/src/infrapuzzle/knowledge-wiki/data/_schema")
|
||||
if schema_src.is_dir():
|
||||
shutil.copytree(schema_src, data_dir / "_schema")
|
||||
|
||||
(data_dir / "_meta.json").write_text("{}")
|
||||
engine = DatastoreEngine(data_dir=data_dir)
|
||||
yield engine
|
||||
shutil.rmtree(temp_dir)
|
||||
|
||||
|
||||
def test_nutrition_explicit_logging(temp_datastore: DatastoreEngine):
|
||||
"""Verifies logging food with explicit macro values and daily aggregation."""
|
||||
res1 = temp_datastore.add_event(
|
||||
kind="nutrition.intake",
|
||||
subject="moritz",
|
||||
actor="agent:koch",
|
||||
start="2026-10-06T08:30:00+02:00",
|
||||
meal="Frühstück",
|
||||
payload={
|
||||
"item": "Protein Porridge",
|
||||
"qty": 300,
|
||||
"unit": "g",
|
||||
"protein_g": 35.5,
|
||||
"carbs_g": 45.0,
|
||||
"sugar_g": 5.0,
|
||||
"fat_g": 8.0,
|
||||
"fiber_g": 6.0,
|
||||
"salt_g": 0.5,
|
||||
"kcal": 420.0,
|
||||
},
|
||||
)
|
||||
assert res1["status"] == "success"
|
||||
assert res1["event"]["id"] is not None
|
||||
|
||||
res2 = temp_datastore.add_event(
|
||||
kind="nutrition.intake",
|
||||
subject="moritz",
|
||||
actor="agent:koch",
|
||||
start="2026-10-06T12:30:00+02:00",
|
||||
meal="Mittagessen",
|
||||
payload={
|
||||
"item": "Hähnchen mit Reis",
|
||||
"qty": 400,
|
||||
"unit": "g",
|
||||
"protein_g": 50.0,
|
||||
"carbs_g": 60.0,
|
||||
"sugar_g": 2.0,
|
||||
"fat_g": 10.0,
|
||||
"fiber_g": 4.0,
|
||||
"salt_g": 1.2,
|
||||
"kcal": 550.0,
|
||||
},
|
||||
)
|
||||
assert res2["status"] == "success"
|
||||
|
||||
summary = temp_datastore.get_summary(subject="moritz", date_str="2026-10-06")
|
||||
nut = summary["nutrition"]
|
||||
assert nut["item_count"] == 2
|
||||
assert nut["total_protein_g"] == 85.5
|
||||
assert nut["total_carbs_g"] == 105.0
|
||||
assert nut["total_kcal"] == 970.0
|
||||
|
||||
|
||||
def test_nutrition_from_food_reference(temp_datastore: DatastoreEngine):
|
||||
"""Verifies that linking a food_id computes macros automatically from foods.per_100."""
|
||||
temp_datastore.put_entity(
|
||||
collection="foods",
|
||||
record={
|
||||
"id": "oatly-haferdrink",
|
||||
"name": "Oatly Haferdrink",
|
||||
"per_100": {
|
||||
"kcal": 61.0,
|
||||
"protein_g": 1.1,
|
||||
"carbs_g": 7.1,
|
||||
"sugar_g": 3.4,
|
||||
"fat_g": 3.0,
|
||||
"fiber_g": 0.8,
|
||||
"salt_g": 0.1,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
# Log 200 ml Oatly Haferdrink
|
||||
res = temp_datastore.add_event(
|
||||
kind="nutrition.intake",
|
||||
subject="moritz",
|
||||
actor="agent:koch",
|
||||
start="2026-10-06T09:00:00+02:00",
|
||||
payload={
|
||||
"item": "Hafermilch im Kaffee",
|
||||
"food_id": "oatly-haferdrink",
|
||||
"qty": 200,
|
||||
"unit": "ml",
|
||||
},
|
||||
)
|
||||
assert res["status"] == "success"
|
||||
|
||||
# Query v_nutrition
|
||||
rows = temp_datastore.query("SELECT protein_g, carbs_g, kcal FROM v_nutrition WHERE food_id = 'oatly-haferdrink'")
|
||||
assert len(rows) == 1
|
||||
assert round(rows[0]["protein_g"], 2) == 2.2
|
||||
assert round(rows[0]["carbs_g"], 2) == 14.2
|
||||
assert round(rows[0]["kcal"], 0) == 122.0
|
||||
|
||||
|
||||
def test_baby_sleep_open_and_close(temp_datastore: DatastoreEngine):
|
||||
"""Verifies open-ended interval sleep logging and stop_event duration calculations."""
|
||||
start_time = "2026-10-06T13:00:00+02:00"
|
||||
end_time = "2026-10-06T14:30:00+02:00"
|
||||
|
||||
# 1. Start open sleep
|
||||
res_start = temp_datastore.add_event(
|
||||
kind="sleep.session",
|
||||
subject="mila",
|
||||
actor="agent:hebamme",
|
||||
start=start_time,
|
||||
payload={"type": "nap", "location": "bett"},
|
||||
)
|
||||
assert res_start["status"] == "success"
|
||||
assert res_start["event"]["end"] is None
|
||||
|
||||
# Verify open status
|
||||
summary = temp_datastore.get_summary(subject="mila", date_str="2026-10-06")
|
||||
assert "open_sleep" in summary
|
||||
assert summary["open_sleep"]["sleep_type"] == "nap"
|
||||
|
||||
# 2. Prevent overlapping second open sleep
|
||||
with pytest.raises(ValueError, match="hat bereits ein aktives Schlaf-Event"):
|
||||
temp_datastore.add_event(
|
||||
kind="sleep.session",
|
||||
subject="mila",
|
||||
actor="agent:hebamme",
|
||||
start="2026-10-06T13:30:00+02:00",
|
||||
payload={"type": "nap"},
|
||||
)
|
||||
|
||||
# 3. Stop sleep
|
||||
res_stop = temp_datastore.stop_event(
|
||||
kind="sleep.session",
|
||||
subject="mila",
|
||||
end_time=end_time,
|
||||
)
|
||||
assert res_stop["status"] == "success"
|
||||
assert res_stop["duration_minutes"] == 90
|
||||
assert res_stop["duration_formatted"] == "1 h 30 min"
|
||||
|
||||
# 4. Verify aggregated summary
|
||||
summary2 = temp_datastore.get_summary(subject="mila", date_str="2026-10-06")
|
||||
slp = summary2["sleep"]
|
||||
assert slp["nap_count"] == 1
|
||||
assert slp["nap_sleep_minutes"] == 90
|
||||
assert slp["total_sleep_hours"] == 1.5
|
||||
|
||||
|
||||
def test_soft_delete_event(temp_datastore: DatastoreEngine):
|
||||
"""Verifies that soft-deleted events are excluded from views."""
|
||||
res = temp_datastore.add_event(
|
||||
kind="nutrition.intake",
|
||||
subject="moritz",
|
||||
actor="agent:koch",
|
||||
start="2026-10-06T07:00:00+02:00",
|
||||
payload={"item": "Snack", "qty": 50, "unit": "g", "protein_g": 5.0, "kcal": 100.0},
|
||||
)
|
||||
event_id = res["event"]["id"]
|
||||
|
||||
# Verify present in summary
|
||||
sum1 = temp_datastore.get_summary(subject="moritz", date_str="2026-10-06")
|
||||
assert sum1["nutrition"]["total_kcal"] == 100.0
|
||||
|
||||
# Delete
|
||||
del_res = temp_datastore.delete_event(event_id)
|
||||
assert del_res["status"] == "success"
|
||||
|
||||
# Verify removed from view
|
||||
sum2 = temp_datastore.get_summary(subject="moritz", date_str="2026-10-06")
|
||||
assert sum2["nutrition"]["total_kcal"] == 0.0
|
||||
|
||||
|
||||
def test_places_spatial_radius_search_both_engines(temp_datastore: DatastoreEngine):
|
||||
"""Verifies distance calculation using both DuckDB spatial extension and pure-SQL fallback macro."""
|
||||
temp_datastore.put_entity(
|
||||
collection="places",
|
||||
record={
|
||||
"id": "spitalgarten-regensburg",
|
||||
"name": "Spitalgarten Regensburg",
|
||||
"category": "biergarten",
|
||||
"lat": 49.0223,
|
||||
"lon": 12.0978,
|
||||
"address": "St.-Katharinen-Platz 1, 93059 Regensburg",
|
||||
"google_maps_url": "https://maps.app.goo.gl/spitalgarten",
|
||||
"tags": ["biergarten", "spielplatz"],
|
||||
},
|
||||
)
|
||||
temp_datastore.put_entity(
|
||||
collection="places",
|
||||
record={
|
||||
"id": "walhalla-donaustauf",
|
||||
"name": "Walhalla",
|
||||
"category": "sightseeing",
|
||||
"lat": 49.0315,
|
||||
"lon": 12.2245,
|
||||
"address": "Walhallastraße 48, 93093 Donaustauf",
|
||||
"google_maps_url": "https://maps.app.goo.gl/walhalla",
|
||||
"tags": ["ausflug", "kultur"],
|
||||
},
|
||||
)
|
||||
|
||||
# 1. Test with spatial extension enabled
|
||||
places_1k = temp_datastore.find_places_near(lat=49.0205, lon=12.0975, radius_m=1000.0)
|
||||
assert len(places_1k) == 1
|
||||
assert places_1k[0]["id"] == "spitalgarten-regensburg"
|
||||
assert 150.0 < places_1k[0]["distance_meters"] < 300.0
|
||||
|
||||
# 2. Test fallback macro by forcing has_spatial_extension = False
|
||||
temp_datastore.has_spatial_extension = False
|
||||
places_fallback_1k = temp_datastore.find_places_near(lat=49.0205, lon=12.0975, radius_m=1000.0)
|
||||
assert len(places_fallback_1k) == 1
|
||||
assert places_fallback_1k[0]["id"] == "spitalgarten-regensburg"
|
||||
assert 150.0 < places_fallback_1k[0]["distance_meters"] < 300.0
|
||||
# Differences between Spheroid (WGS84 ellipsoid) and Haversine (sphere) are < 3m
|
||||
assert abs(places_1k[0]["distance_meters"] - places_fallback_1k[0]["distance_meters"]) < 5.0
|
||||
|
||||
|
||||
def test_garmin_daily_and_daily_balance(temp_datastore: DatastoreEngine):
|
||||
"""Verifies that Garmin daily summaries join with nutrition to calculate caloric deficit/surplus deterministically."""
|
||||
# 1. Log nutrition intake on 2026-10-01
|
||||
temp_datastore.add_event(
|
||||
kind="nutrition.intake",
|
||||
subject="moritz",
|
||||
actor="agent:koch",
|
||||
start="2026-10-01T12:30:00+02:00",
|
||||
payload={"item": "Mittagessen", "qty": 400, "unit": "g", "protein_g": 60.0, "kcal": 800.0},
|
||||
)
|
||||
temp_datastore.add_event(
|
||||
kind="nutrition.intake",
|
||||
subject="moritz",
|
||||
actor="agent:koch",
|
||||
start="2026-10-01T19:30:00+02:00",
|
||||
payload={"item": "Abendessen", "qty": 500, "unit": "g", "protein_g": 80.0, "kcal": 1346.0},
|
||||
)
|
||||
|
||||
# 2. Add Garmin daily summary for 2026-10-01
|
||||
garmin_dir = temp_datastore.data_dir / "garmin" / "daily"
|
||||
garmin_dir.mkdir(parents=True, exist_ok=True)
|
||||
garmin_file = garmin_dir / "2026-10.jsonl"
|
||||
with open(garmin_file, "a", encoding="utf-8") as f:
|
||||
f.write(
|
||||
json.dumps(
|
||||
{
|
||||
"calendarDate": "2026-10-01",
|
||||
"totalKilocalories": 2890,
|
||||
"activeKilocalories": 650,
|
||||
"bmrKilocalories": 2240,
|
||||
"totalSteps": 10500,
|
||||
"restingHeartRate": 51,
|
||||
}
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
|
||||
# 3. Reload data into datastore
|
||||
temp_datastore._load_all_data()
|
||||
|
||||
# 4. Query v_daily_balance
|
||||
res = temp_datastore.query("SELECT * FROM v_daily_balance WHERE log_date = '2026-10-01'")
|
||||
assert len(res) == 1
|
||||
row = res[0]
|
||||
assert row["subject"] == "moritz"
|
||||
assert row["intake_kcal"] == 2146.0 # 800 + 1346
|
||||
assert row["protein_g"] == 140.0 # 60 + 80
|
||||
assert row["expenditure_kcal"] == 2890
|
||||
assert row["active_kcal"] == 650
|
||||
assert row["rmr_kcal"] == 2240
|
||||
assert row["steps"] == 10500
|
||||
assert row["balance_kcal"] == -744.0 # 2146 - 2890
|
||||
assert row["balance_status"] == "Defizit"
|
||||
|
||||
|
||||
def test_add_event_with_since(temp_datastore: DatastoreEngine):
|
||||
"""Verifies that relative offsets like '15m' calculate start time deterministically in Berlin timezone."""
|
||||
from zoneinfo import ZoneInfo
|
||||
berlin_tz = ZoneInfo("Europe/Berlin")
|
||||
|
||||
before = datetime.datetime.now(berlin_tz)
|
||||
res = temp_datastore.add_event(
|
||||
kind="sleep.session",
|
||||
subject="mila",
|
||||
actor="agent:hebamme",
|
||||
since="15m",
|
||||
payload={"type": "nap", "location": "bett"},
|
||||
)
|
||||
after = datetime.datetime.now(berlin_tz)
|
||||
|
||||
assert res["status"] == "success"
|
||||
event = res["event"]
|
||||
assert event["end"] is None
|
||||
|
||||
start_dt = datetime.datetime.fromisoformat(event["start"])
|
||||
assert start_dt.tzinfo is not None
|
||||
# Verify Berlin timezone offset (+02:00 in CEST or +01:00 in CET)
|
||||
assert start_dt.utcoffset() == before.utcoffset()
|
||||
|
||||
# The logged start must be ~15 minutes before the test run
|
||||
expected_approx = before - datetime.timedelta(minutes=15)
|
||||
assert abs((start_dt - expected_approx).total_seconds()) < 5
|
||||
|
||||
# Test error if both start and since are provided
|
||||
with pytest.raises(ValueError, match="Cannot specify both 'since' and 'start'"):
|
||||
temp_datastore.add_event(
|
||||
kind="nutrition.intake",
|
||||
subject="moritz",
|
||||
actor="agent:koch",
|
||||
start="2026-10-06T12:00:00+02:00",
|
||||
since="10m",
|
||||
payload={"item": "Snack", "qty": 10},
|
||||
)
|
||||
|
||||
# Test invalid since format
|
||||
with pytest.raises(ValueError, match="Unrecognized duration format"):
|
||||
temp_datastore.add_event(
|
||||
kind="nutrition.intake",
|
||||
subject="moritz",
|
||||
actor="agent:koch",
|
||||
since="invalid_format",
|
||||
payload={"item": "Snack", "qty": 10},
|
||||
)
|
||||
|
||||
|
||||
def test_sleep_over_midnight_attribution(temp_datastore: DatastoreEngine):
|
||||
"""Verifies that overnight sleep (Wed evening -> Thu morning) stays attributed to Wednesday."""
|
||||
# Sleep starts Wednesday 19:15
|
||||
res_start = temp_datastore.add_event(
|
||||
kind="sleep.session",
|
||||
subject="mila",
|
||||
actor="agent:hebamme",
|
||||
start="2026-10-07T19:15:00+02:00",
|
||||
payload={"type": "night", "location": "bett"},
|
||||
)
|
||||
assert res_start["status"] == "success"
|
||||
|
||||
# Sleep ends Thursday 07:00
|
||||
res_stop = temp_datastore.stop_event(
|
||||
kind="sleep.session",
|
||||
subject="mila",
|
||||
end_time="2026-10-08T07:00:00+02:00",
|
||||
)
|
||||
assert res_stop["status"] == "success"
|
||||
# Duration: 11 hours 45 min = 705 minutes
|
||||
assert res_stop["duration_minutes"] == 705
|
||||
|
||||
# Check v_sleep view: log_date must be the day it STARTED (Wednesday 2026-10-07)
|
||||
rows = temp_datastore.query("SELECT log_date, duration_minutes, sleep_type FROM v_sleep WHERE subject = 'mila'")
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["log_date"] == "2026-10-07"
|
||||
assert rows[0]["sleep_type"] == "night"
|
||||
assert rows[0]["duration_minutes"] == 705
|
||||
|
||||
# Check daily summary for Wednesday: must contain the full 11.75 hours night sleep
|
||||
wed_summary = temp_datastore.get_summary(subject="mila", date_str="2026-10-07")
|
||||
assert "sleep" in wed_summary
|
||||
assert wed_summary["sleep"]["night_sleep_minutes"] == 705
|
||||
assert wed_summary["sleep"]["total_sleep_hours"] == 11.75
|
||||
|
||||
# Check daily summary for Thursday: must NOT contain Wednesday night's sleep
|
||||
thu_summary = temp_datastore.get_summary(subject="mila", date_str="2026-10-08")
|
||||
assert "sleep" not in thu_summary or thu_summary["sleep"] is None
|
||||
|
||||
|
||||
def test_duplicate_suppression_nutrition(temp_datastore: DatastoreEngine):
|
||||
"""Verifies that duplicate intake events within 15 minutes are suppressed idempotently."""
|
||||
start_time = "2026-10-09T08:00:00+02:00"
|
||||
payload = {
|
||||
"item": "Protein Porridge",
|
||||
"qty": 300,
|
||||
"unit": "g",
|
||||
"protein_g": 35.0,
|
||||
"kcal": 400.0,
|
||||
}
|
||||
|
||||
# 1. First logging
|
||||
res1 = temp_datastore.add_event(
|
||||
kind="nutrition.intake",
|
||||
subject="moritz",
|
||||
actor="agent:koch",
|
||||
start=start_time,
|
||||
payload=payload,
|
||||
)
|
||||
assert res1["status"] == "success"
|
||||
assert res1.get("duplicate") is not True
|
||||
first_id = res1["event"]["id"]
|
||||
|
||||
# 2. Duplicate logging 5 minutes later
|
||||
res2 = temp_datastore.add_event(
|
||||
kind="nutrition.intake",
|
||||
subject="moritz",
|
||||
actor="agent:koch",
|
||||
start="2026-10-09T08:05:00+02:00",
|
||||
payload=payload,
|
||||
)
|
||||
assert res2["status"] == "success"
|
||||
assert res2.get("duplicate") is True
|
||||
assert res2["event"]["id"] == first_id
|
||||
|
||||
# Verify DuckDB summary: only 1 item counted
|
||||
sum1 = temp_datastore.get_summary(subject="moritz", date_str="2026-10-09")
|
||||
assert sum1["nutrition"]["item_count"] == 1
|
||||
assert sum1["nutrition"]["total_kcal"] == 400.0
|
||||
|
||||
# 3. Explicit bypass with allow_duplicate=True
|
||||
res3 = temp_datastore.add_event(
|
||||
kind="nutrition.intake",
|
||||
subject="moritz",
|
||||
actor="agent:koch",
|
||||
start="2026-10-09T08:06:00+02:00",
|
||||
payload=payload,
|
||||
allow_duplicate=True,
|
||||
)
|
||||
assert res3["status"] == "success"
|
||||
assert res3.get("duplicate") is not True
|
||||
assert res3["event"]["id"] != first_id
|
||||
|
||||
# Verify DuckDB summary: 2 items counted
|
||||
sum2 = temp_datastore.get_summary(subject="moritz", date_str="2026-10-09")
|
||||
assert sum2["nutrition"]["item_count"] == 2
|
||||
assert sum2["nutrition"]["total_kcal"] == 800.0
|
||||
|
||||
# 4. Different item within 15 minutes is NOT suppressed
|
||||
res4 = temp_datastore.add_event(
|
||||
kind="nutrition.intake",
|
||||
subject="moritz",
|
||||
actor="agent:koch",
|
||||
start="2026-10-09T08:07:00+02:00",
|
||||
payload={"item": "Apfel", "qty": 150, "kcal": 80.0},
|
||||
)
|
||||
assert res4.get("duplicate") is not True
|
||||
|
||||
# 5. Same item outside 15 minutes window is NOT suppressed
|
||||
res5 = temp_datastore.add_event(
|
||||
kind="nutrition.intake",
|
||||
subject="moritz",
|
||||
actor="agent:koch",
|
||||
start="2026-10-09T09:30:00+02:00",
|
||||
payload=payload,
|
||||
)
|
||||
assert res5.get("duplicate") is not True
|
||||
|
||||
|
||||
def test_duplicate_suppression_sleep(temp_datastore: DatastoreEngine):
|
||||
"""Verifies that starting the same sleep session within 15 minutes returns existing open session."""
|
||||
res1 = temp_datastore.add_event(
|
||||
kind="sleep.session",
|
||||
subject="mila",
|
||||
actor="agent:hebamme",
|
||||
start="2026-10-09T13:00:00+02:00",
|
||||
payload={"type": "nap", "location": "bett"},
|
||||
)
|
||||
assert res1["status"] == "success"
|
||||
first_id = res1["event"]["id"]
|
||||
|
||||
# Repeat 3 minutes later: should return existing open session without throwing ValueError
|
||||
res2 = temp_datastore.add_event(
|
||||
kind="sleep.session",
|
||||
subject="mila",
|
||||
actor="agent:hebamme",
|
||||
start="2026-10-09T13:03:00+02:00",
|
||||
payload={"type": "nap", "location": "bett"},
|
||||
)
|
||||
assert res2["status"] == "success"
|
||||
assert res2.get("duplicate") is True
|
||||
assert res2["event"]["id"] == first_id
|
||||
|
||||
# Stop session
|
||||
temp_datastore.stop_event(
|
||||
kind="sleep.session",
|
||||
subject="mila",
|
||||
end_time="2026-10-09T14:00:00+02:00",
|
||||
)
|
||||
|
||||
|
||||
def test_cli_kind_flag_and_positional(temp_datastore: DatastoreEngine):
|
||||
"""Verifies CLI accepts both positional and --kind flag for add and stop commands."""
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
script_path = Path(__file__).resolve().parent.parent / "scripts" / "datastore.py"
|
||||
data_dir = str(temp_datastore.data_dir)
|
||||
|
||||
# 1. Add using --kind flag
|
||||
proc_add = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(script_path),
|
||||
"add",
|
||||
"--kind",
|
||||
"nutrition.intake",
|
||||
"--subject",
|
||||
"moritz",
|
||||
"--payload",
|
||||
'{"item": "CLI Snack", "qty": 50, "kcal": 100}',
|
||||
"--start",
|
||||
"2026-10-09T15:00:00+02:00",
|
||||
],
|
||||
env=dict(os.environ, OPENCLAW_DATA_DIR=data_dir),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert proc_add.returncode == 0
|
||||
data = json.loads(proc_add.stdout)
|
||||
assert data["status"] == "success"
|
||||
assert data["event"]["kind"] == "nutrition.intake"
|
||||
|
||||
# 2. Add open sleep using positional kind
|
||||
proc_sleep_start = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(script_path),
|
||||
"add",
|
||||
"sleep.session",
|
||||
"--subject",
|
||||
"mila",
|
||||
"--payload",
|
||||
'{"type": "nap"}',
|
||||
"--start",
|
||||
"2026-10-09T15:00:00+02:00",
|
||||
],
|
||||
env=dict(os.environ, OPENCLAW_DATA_DIR=data_dir),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert proc_sleep_start.returncode == 0
|
||||
|
||||
# 3. Stop sleep using --kind flag
|
||||
proc_sleep_stop = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(script_path),
|
||||
"stop",
|
||||
"--kind",
|
||||
"sleep.session",
|
||||
"--subject",
|
||||
"mila",
|
||||
"--end",
|
||||
"2026-10-09T16:00:00+02:00",
|
||||
],
|
||||
env=dict(os.environ, OPENCLAW_DATA_DIR=data_dir),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert proc_sleep_stop.returncode == 0
|
||||
stop_data = json.loads(proc_sleep_stop.stdout)
|
||||
assert stop_data["status"] == "success"
|
||||
assert stop_data["duration_minutes"] == 60
|
||||
|
||||
|
||||
def test_open_session_warning_without_silent_deckeln(temp_datastore: DatastoreEngine):
|
||||
"""Verifies that an unclosed sleep session is NOT silently deckelt, but alerts on every new log."""
|
||||
# 1. Open night sleep session yesterday at 19:15
|
||||
res_night = temp_datastore.add_event(
|
||||
kind="sleep.session",
|
||||
subject="mila",
|
||||
actor="agent:hebamme",
|
||||
start="2026-10-08T19:15:00+02:00",
|
||||
payload={"type": "night", "location": "bett"},
|
||||
)
|
||||
assert res_night["status"] == "success"
|
||||
night_id = res_night["event"]["id"]
|
||||
|
||||
# 2. Add midday nap today (12:35 to 12:55)
|
||||
res_nap = temp_datastore.add_event(
|
||||
kind="sleep.session",
|
||||
subject="mila",
|
||||
actor="agent:hebamme",
|
||||
start="2026-10-09T12:35:00+02:00",
|
||||
end="2026-10-09T12:55:00+02:00",
|
||||
payload={"type": "nap", "location": "bett"},
|
||||
)
|
||||
assert res_nap["status"] == "success"
|
||||
|
||||
# 3. Verify night session is STILL open (never silently deckelt!)
|
||||
rows = temp_datastore.query(f"SELECT id, start, \"end\", estimated FROM events WHERE id = '{night_id}'")
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["end"] is None
|
||||
assert rows[0]["estimated"] is False
|
||||
|
||||
# 4. Verify that feedback gives prominent notice about the open session
|
||||
feedback = res_nap["feedback"]
|
||||
assert "open_session_warning" in feedback
|
||||
assert "ist noch offen" in feedback["open_session_warning"]
|
||||
assert "ist noch offen" in feedback["day_total_formatted"]
|
||||
|
||||
|
||||
def test_stop_event_by_id(temp_datastore: DatastoreEngine):
|
||||
"""Verifies closing a specific open sleep session by ID when multiple might exist."""
|
||||
res1 = temp_datastore.add_event(
|
||||
kind="sleep.session",
|
||||
subject="mila",
|
||||
actor="agent:hebamme",
|
||||
start="2026-10-08T19:15:00+02:00",
|
||||
payload={"type": "night"},
|
||||
)
|
||||
id1 = res1["event"]["id"]
|
||||
|
||||
res2 = temp_datastore.add_event(
|
||||
kind="sleep.session",
|
||||
subject="mila",
|
||||
actor="agent:hebamme",
|
||||
start="2026-10-09T12:35:00+02:00",
|
||||
payload={"type": "nap"},
|
||||
)
|
||||
id2 = res2["event"]["id"]
|
||||
|
||||
# Close specific event id1
|
||||
res_stop = temp_datastore.stop_event(
|
||||
kind="sleep.session",
|
||||
subject="mila",
|
||||
event_id=id1,
|
||||
end_time="2026-10-09T07:15:00+02:00",
|
||||
)
|
||||
assert res_stop["status"] == "success"
|
||||
assert res_stop["event_id"] == id1
|
||||
|
||||
# Verify id1 is closed and id2 is still open
|
||||
row1 = temp_datastore.query(f"SELECT \"end\" FROM events WHERE id = '{id1}'")[0]
|
||||
row2 = temp_datastore.query(f"SELECT \"end\" FROM events WHERE id = '{id2}'")[0]
|
||||
assert row1["end"] is not None
|
||||
assert row2["end"] is None
|
||||
|
||||
|
||||
def test_cli_rejects_invalid_kind(temp_datastore: DatastoreEngine):
|
||||
"""Verifies CLI rejects boolean words or strings without dots as kind."""
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
script_path = Path(__file__).resolve().parent.parent / "scripts" / "datastore.py"
|
||||
data_dir = str(temp_datastore.data_dir)
|
||||
|
||||
proc = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(script_path),
|
||||
"add",
|
||||
"true",
|
||||
"--subject",
|
||||
"moritz",
|
||||
"--payload",
|
||||
'{"item": "Snack", "qty": 100}',
|
||||
],
|
||||
env=dict(os.environ, OPENCLAW_DATA_DIR=data_dir),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert proc.returncode != 0
|
||||
assert "Invalid kind 'true'" in proc.stderr
|
||||
|
||||
|
||||
def test_diaper_logging(temp_datastore: DatastoreEngine):
|
||||
"""Verifies diaper change events, feedback, and DuckDB views."""
|
||||
res = temp_datastore.add_event(
|
||||
kind="diaper.change",
|
||||
subject="mila",
|
||||
actor="agent:hebamme",
|
||||
start="2026-10-11T09:45:00+02:00",
|
||||
payload={"pee": True, "poo": True, "texture": "normal"},
|
||||
)
|
||||
assert res["status"] == "success"
|
||||
feedback = res["feedback"]
|
||||
assert feedback["total_changes"] == 1
|
||||
assert feedback["wet_count"] == 1
|
||||
assert feedback["dirty_count"] == 1
|
||||
assert "1 gewechselt" in feedback["day_total_formatted"]
|
||||
|
||||
# Add second diaper (only pee)
|
||||
res2 = temp_datastore.add_event(
|
||||
kind="diaper.change",
|
||||
subject="mila",
|
||||
actor="agent:hebamme",
|
||||
start="2026-10-11T12:30:00+02:00",
|
||||
payload={"pee": True, "poo": False},
|
||||
)
|
||||
assert res2["status"] == "success"
|
||||
feedback2 = res2["feedback"]
|
||||
assert feedback2["total_changes"] == 2
|
||||
assert feedback2["wet_count"] == 2
|
||||
assert feedback2["dirty_count"] == 1
|
||||
|
||||
# Check v_diaper and v_diaper_daily views
|
||||
rows = temp_datastore.query("SELECT * FROM v_diaper WHERE subject = 'mila'")
|
||||
assert len(rows) == 2
|
||||
daily = temp_datastore.query("SELECT * FROM v_diaper_daily WHERE subject = 'mila' AND log_date = '2026-10-11'")
|
||||
assert len(daily) == 1
|
||||
assert daily[0]["total_changes"] == 2
|
||||
assert daily[0]["wet_count"] == 2
|
||||
assert daily[0]["dirty_count"] == 1
|
||||
|
||||
|
||||
def test_feeding_logging(temp_datastore: DatastoreEngine):
|
||||
"""Verifies feeding bottle events, feedback, and DuckDB views."""
|
||||
res = temp_datastore.add_event(
|
||||
kind="feeding.bottle",
|
||||
subject="mila",
|
||||
actor="agent:hebamme",
|
||||
start="2026-10-11T06:30:00+02:00",
|
||||
payload={"amount_ml": 180, "consumed_ml": 160, "milk_type": "pre"},
|
||||
)
|
||||
assert res["status"] == "success"
|
||||
feedback = res["feedback"]
|
||||
assert feedback["bottle_count"] == 1
|
||||
assert feedback["total_consumed_ml"] == 160.0
|
||||
assert "160 ml getrunken" in feedback["day_total_formatted"]
|
||||
|
||||
# Check v_feeding_daily view
|
||||
daily = temp_datastore.query("SELECT * FROM v_feeding_daily WHERE subject = 'mila' AND log_date = '2026-10-11'")
|
||||
assert len(daily) == 1
|
||||
assert daily[0]["bottle_count"] == 1
|
||||
assert daily[0]["total_consumed_ml"] == 160.0
|
||||
|
||||
|
||||
def test_growth_logging(temp_datastore: DatastoreEngine):
|
||||
"""Verifies baby growth measurements, WHO percentile feedback, and DuckDB views."""
|
||||
# Ensure Mila is in people table with birthdate
|
||||
temp_datastore.put_entity(
|
||||
collection="people",
|
||||
record={"id": "mila", "name": "Mila Graf", "birthdate": "2026-03-14", "role": "child"},
|
||||
)
|
||||
|
||||
# Log measurement at approx 6.9 months (2026-10-12)
|
||||
res = temp_datastore.add_event(
|
||||
kind="growth.measurement",
|
||||
subject="mila",
|
||||
actor="agent:hebamme",
|
||||
start="2026-10-12T10:00:00+02:00",
|
||||
payload={"weight_kg": 7.4, "height_cm": 67.5},
|
||||
note="U-Untersuchung Vorbereitung",
|
||||
)
|
||||
assert res["status"] == "success"
|
||||
fb = res["feedback"]
|
||||
assert "weight_kg" in fb
|
||||
assert fb["weight_kg"] == 7.4
|
||||
assert fb["height_cm"] == 67.5
|
||||
assert "weight_percentile" in fb
|
||||
assert 30 <= fb["weight_percentile"] <= 60 # around P41
|
||||
assert 45 <= fb["height_percentile"] <= 70 # around P56
|
||||
assert "Normbereich" in fb["day_total_formatted"]
|
||||
|
||||
# Verify DuckDB views
|
||||
growth_rows = temp_datastore.query("SELECT * FROM v_growth WHERE subject = 'mila'")
|
||||
assert len(growth_rows) == 1
|
||||
assert growth_rows[0]["weight_kg"] == 7.4
|
||||
assert growth_rows[0]["height_cm"] == 67.5
|
||||
assert growth_rows[0]["note"] == "U-Untersuchung Vorbereitung"
|
||||
|
||||
daily_rows = temp_datastore.query("SELECT * FROM v_growth_daily WHERE subject = 'mila'")
|
||||
assert len(daily_rows) == 1
|
||||
assert daily_rows[0]["measurement_count"] == 1
|
||||
|
||||
# Verify get_summary returns growth_latest
|
||||
summary = temp_datastore.get_summary(subject="mila", date_str="2026-10-12")
|
||||
assert "growth_latest" in summary
|
||||
assert summary["growth_latest"]["weight_kg"] == 7.4
|
||||
assert summary["growth_latest"]["height_cm"] == 67.5
|
||||
|
||||
|
||||
|
||||
def test_feeding_solid_brei_logging(temp_datastore: DatastoreEngine):
|
||||
"""Verifies feeding solid (Brei/Beikost) events, feedback formatting, and DuckDB views."""
|
||||
res = temp_datastore.add_event(
|
||||
kind="feeding.solid",
|
||||
subject="mila",
|
||||
actor="agent:mila_tracker",
|
||||
start="2026-10-12T12:00:00+02:00",
|
||||
meal="Mittagessen",
|
||||
payload={
|
||||
"item": "Kürbis-Kartoffel-Brei",
|
||||
"amount_g": 130,
|
||||
"kcal": 95,
|
||||
},
|
||||
note="Guter Appetit",
|
||||
)
|
||||
assert res["status"] == "success"
|
||||
fb = res["feedback"]
|
||||
assert fb["solid_count"] == 1
|
||||
assert fb["total_solid_g"] == 130.0
|
||||
assert "130 g" in fb["day_total_formatted"]
|
||||
assert "Brei" in fb["day_total_formatted"]
|
||||
assert res["event"]["payload"]["item"] == "Kürbis-Kartoffel-Brei"
|
||||
|
||||
# Verify v_feeding view
|
||||
rows = temp_datastore.query("SELECT * FROM v_feeding WHERE subject = 'mila' AND kind = 'feeding.solid'")
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["item"] == "Kürbis-Kartoffel-Brei"
|
||||
assert rows[0]["solid_g"] == 130.0
|
||||
assert rows[0]["kcal"] == 95.0
|
||||
|
||||
# Verify v_feeding_daily combined aggregation
|
||||
daily = temp_datastore.query("SELECT * FROM v_feeding_daily WHERE subject = 'mila' AND log_date = '2026-10-12'")
|
||||
assert len(daily) == 1
|
||||
assert daily[0]["solid_count"] == 1
|
||||
assert daily[0]["total_solid_g"] == 130.0
|
||||
assert daily[0]["total_kcal"] == 95.0
|
||||
|
||||
|
||||
def test_three_databases_isolation_and_reloading(temp_datastore: DatastoreEngine):
|
||||
"""Verifies that events are routed to sleep/, nutrition/, mila/ databases and reloaded correctly."""
|
||||
# 1. Sleep session -> sleep/
|
||||
res_sleep = temp_datastore.add_event(
|
||||
kind="sleep.session",
|
||||
subject="mila",
|
||||
actor="agent:mila_tracker",
|
||||
start="2026-10-13T09:00:00+02:00",
|
||||
end="2026-10-13T10:00:00+02:00",
|
||||
payload={"type": "nap", "location": "bett"},
|
||||
)
|
||||
assert res_sleep["status"] == "success"
|
||||
|
||||
# 2. Nutrition intake -> nutrition/
|
||||
res_nut = temp_datastore.add_event(
|
||||
kind="nutrition.intake",
|
||||
subject="moritz",
|
||||
actor="agent:nutrition_tracker",
|
||||
start="2026-10-13T08:00:00+02:00",
|
||||
meal="Frühstück",
|
||||
payload={"item": "Rührei", "qty": 150, "unit": "g", "protein_g": 18.0, "kcal": 210.0},
|
||||
)
|
||||
assert res_nut["status"] == "success"
|
||||
|
||||
# 3. Diaper change -> mila/
|
||||
res_diaper = temp_datastore.add_event(
|
||||
kind="diaper.change",
|
||||
subject="mila",
|
||||
actor="agent:mila_tracker",
|
||||
start="2026-10-13T10:15:00+02:00",
|
||||
payload={"pee": True, "poo": False},
|
||||
)
|
||||
assert res_diaper["status"] == "success"
|
||||
|
||||
# Verify file locations on disk
|
||||
data_dir = temp_datastore.data_dir
|
||||
sleep_files = list((data_dir / "sleep").glob("*.jsonl"))
|
||||
nutrition_files = list((data_dir / "nutrition").glob("*.jsonl"))
|
||||
mila_files = list((data_dir / "mila").glob("*.jsonl"))
|
||||
|
||||
assert len(sleep_files) == 1
|
||||
assert len(nutrition_files) == 1
|
||||
assert len(mila_files) == 1
|
||||
assert not (data_dir / "events").exists()
|
||||
|
||||
# Re-instantiate engine from disk (cold load test)
|
||||
reloaded_engine = DatastoreEngine(data_dir=data_dir)
|
||||
|
||||
# Check that events union view contains all 3 records
|
||||
all_events = reloaded_engine.query("SELECT id, kind, subject FROM events ORDER BY start ASC")
|
||||
assert len(all_events) == 3
|
||||
kinds = [e["kind"] for e in all_events]
|
||||
assert "nutrition.intake" in kinds
|
||||
assert "sleep.session" in kinds
|
||||
assert "diaper.change" in kinds
|
||||
|
||||
# Check domain daily views
|
||||
s_view = reloaded_engine.query("SELECT total_sleep_minutes FROM v_sleep_daily WHERE subject = 'mila' AND log_date = '2026-10-13'")
|
||||
assert len(s_view) == 1
|
||||
assert s_view[0]["total_sleep_minutes"] == 60
|
||||
|
||||
n_view = reloaded_engine.query("SELECT total_kcal FROM v_nutrition_daily WHERE subject = 'moritz' AND log_date = '2026-10-13'")
|
||||
assert len(n_view) == 1
|
||||
assert n_view[0]["total_kcal"] == 210.0
|
||||
|
||||
d_view = reloaded_engine.query("SELECT total_changes, wet_count FROM v_diaper_daily WHERE subject = 'mila' AND log_date = '2026-10-13'")
|
||||
assert len(d_view) == 1
|
||||
assert d_view[0]["total_changes"] == 1
|
||||
assert d_view[0]["wet_count"] == 1
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import json
|
||||
import pytest
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from datastore import DatastoreEngine
|
||||
from garmin_sync import GarminSyncClient, GarminMirrorWriter, sync_date_range, generate_date_list
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_data_dir():
|
||||
temp_dir = tempfile.mkdtemp(prefix="garmin_test_")
|
||||
data_dir = Path(temp_dir)
|
||||
# create standard dirs
|
||||
(data_dir / "garmin").mkdir(parents=True)
|
||||
yield data_dir
|
||||
shutil.rmtree(temp_dir)
|
||||
|
||||
|
||||
def test_garmin_sync_mock_and_idempotency(temp_data_dir):
|
||||
client = GarminSyncClient(token_dir=temp_data_dir, mock=True)
|
||||
writer = GarminMirrorWriter(temp_data_dir)
|
||||
|
||||
dates = ["2026-10-01", "2026-10-02"]
|
||||
sync_date_range(client, writer, dates)
|
||||
|
||||
daily_file = temp_data_dir / "garmin" / "daily" / "2026-10.jsonl"
|
||||
sleep_file = temp_data_dir / "garmin" / "sleep" / "2026-10.jsonl"
|
||||
activities_file = temp_data_dir / "garmin" / "activities" / "2026-10.jsonl"
|
||||
|
||||
assert daily_file.is_file()
|
||||
assert sleep_file.is_file()
|
||||
assert activities_file.is_file()
|
||||
|
||||
# Read daily lines
|
||||
with open(daily_file) as f:
|
||||
daily_lines = [json.loads(line) for line in f if line.strip()]
|
||||
assert len(daily_lines) == 2
|
||||
assert daily_lines[0]["calendarDate"] == "2026-10-01"
|
||||
assert daily_lines[1]["calendarDate"] == "2026-10-02"
|
||||
|
||||
# Re-syncing 2026-10-01 must not create duplicates (idempotent upsert)
|
||||
sync_date_range(client, writer, ["2026-10-01"])
|
||||
|
||||
with open(daily_file) as f:
|
||||
daily_lines_after = [json.loads(line) for line in f if line.strip()]
|
||||
assert len(daily_lines_after) == 2
|
||||
|
||||
# Check activities
|
||||
with open(activities_file) as f:
|
||||
act_lines = [json.loads(line) for line in f if line.strip()]
|
||||
# Mock returns activityId 987654321 for each day, so deduplication keeps 1 record with latest info
|
||||
assert len(act_lines) == 1
|
||||
assert act_lines[0]["activityId"] == 987654321
|
||||
|
||||
|
||||
def test_generate_date_list():
|
||||
dates = generate_date_list("2026-09-28", "2026-10-01")
|
||||
assert dates == ["2026-09-28", "2026-09-29", "2026-09-30", "2026-10-01"]
|
||||
|
||||
# Reversed dates should automatically normalize
|
||||
dates_rev = generate_date_list("2026-10-01", "2026-09-28")
|
||||
assert dates_rev == ["2026-09-28", "2026-09-29", "2026-09-30", "2026-10-01"]
|
||||
|
||||
|
||||
def test_garmin_auth_error_handling(temp_data_dir):
|
||||
# Non-mock client with empty dir should raise FileNotFoundError
|
||||
with pytest.raises(FileNotFoundError):
|
||||
GarminSyncClient(token_dir=temp_data_dir, mock=False)
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
name: garmin-auth
|
||||
description: Manage Garmin Connect authentication, proactive OAuth token keepalives, and interactive 2-turn MFA login over Telegram. Use when Garmin authentication fails (HTTP 401, expired tokens) or when the user asks to log into Garmin.
|
||||
---
|
||||
|
||||
# 🔐 Garmin Connect Authentication & MFA Manager
|
||||
|
||||
Headless authentication lifecycle manager for Garmin Connect, allowing OpenClaw to initiate logins, keep tokens perpetually fresh, and verify 6-digit MFA codes directly via Telegram.
|
||||
|
||||
## 🎯 When to Use
|
||||
- **Authentication Errors**: Whenever a `garmin` MCP tool call fails with `Garmin authentication failed`, `Tokens expired`, `401 Unauthorized`, or `Failed to retrieve social profile`.
|
||||
- **User Request**: Whenever the user explicitly asks: *"Logge dich bei Garmin ein"*, *"Garmin MFA Code eingeben"*, *"Garmin Re-Auth"*, *"Verbinde Garmin"*.
|
||||
- **Keepalive / Watchdog**: During morning briefings or background checks to ensure tokens are proactively refreshed before expiring.
|
||||
- **Status Checks**: Whenever asked about Garmin token health or expiration: *"Ist Garmin eingeloggt?"*, *"Wie lange ist der Garmin-Token noch gültig?"*.
|
||||
|
||||
---
|
||||
|
||||
## 🕹️ Interactive Telegram MFA Login Flow (Protocol)
|
||||
|
||||
Because Garmin requires Multi-Factor Authentication (OTP sent via SMS/email), login operates in **two conversation turns**:
|
||||
|
||||
### Turn 1: Request Code (Initiate Login)
|
||||
1. Execute the `request-code` command:
|
||||
```bash
|
||||
uv run /home/node/mop-skills/mop-skills/skills/garmin-auth/scripts/garmin_auth.py request-code
|
||||
```
|
||||
2. The command spawns a background authentication daemon and triggers Garmin to dispatch the 6-digit code to the user's email/SMS.
|
||||
3. Immediately send a clear, friendly prompt to the user on Telegram:
|
||||
> 🔑 **Garmin-Authentifizierung erforderlich**
|
||||
> Ich habe einen neuen Login bei Garmin gestartet. Garmin hat dir soeben einen 6-stelligen Sicherheitscode per SMS oder E-Mail gesendet.
|
||||
>
|
||||
> Bitte antworte mir einfach kurz mit dem **6-stelligen Code**!
|
||||
|
||||
### Turn 2: Verify Code (Complete Login)
|
||||
1. When the user sends a 6-digit message (e.g. `538291`, `538 291`):
|
||||
2. Execute the `verify` command:
|
||||
```bash
|
||||
uv run /home/node/mop-skills/mop-skills/skills/garmin-auth/scripts/garmin_auth.py verify "<mfa_code>"
|
||||
```
|
||||
3. If successful:
|
||||
- The tokens are automatically dumped to `/home/node/.openclaw/garmin_tokens/garmin_tokens.json` and base64 exported.
|
||||
- Reply to the user:
|
||||
> ✅ **Garmin-Login erfolgreich!**
|
||||
> Angemeldet als **Moritz Graf**. Alle Garmin-Tools und Workouts sind wieder voll einsatzbereit.
|
||||
4. If the code was incorrect:
|
||||
- The daemon remains alive for 5 minutes. Inform the user and allow them to retry:
|
||||
> ⚠️ Der eingegebene Code war leider ungültig oder abgelaufen. Bitte prüfe den Code und sende ihn erneut.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ CLI Operations Reference
|
||||
|
||||
### 1. Check Token Health & Validity
|
||||
```bash
|
||||
uv run /home/node/mop-skills/mop-skills/skills/garmin-auth/scripts/garmin_auth.py status
|
||||
```
|
||||
Outputs JSON with `valid`, `user`, `exp_utc`, `pending_mfa`.
|
||||
|
||||
### 2. Proactive Keepalive (Refresh Token before Expiry)
|
||||
```bash
|
||||
uv run /home/node/mop-skills/mop-skills/skills/garmin-auth/scripts/garmin_auth.py keepalive
|
||||
```
|
||||
Touches the API, triggering `python-garminconnect`'s automatic token refresh and persisting the newly rotated refresh token to `/home/node/.openclaw/garmin_tokens/garmin_tokens.json`.
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Security & Environment
|
||||
- Requires `GARMIN_EMAIL` and `GARMIN_PASSWORD` (already injected via `openclaw-secrets`).
|
||||
- Persistent storage: `/home/node/.openclaw/garmin_tokens/garmin_tokens.json` (on PVC `openclaw-data`).
|
||||
- Directory permissions are automatically set to `0700` and files to `0600`.
|
||||
@@ -0,0 +1,533 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# dependencies = [
|
||||
# "garminconnect>=0.2.20",
|
||||
# "curl_cffi>=0.7.0",
|
||||
# ]
|
||||
# ///
|
||||
"""
|
||||
Garmin Connect Authentication & Token Lifecycle Manager for OpenClaw.
|
||||
|
||||
Provides headless, 2-turn MFA login via Telegram, proactive token keepalive,
|
||||
and token status inspection.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
handlers=[logging.StreamHandler(sys.stderr)],
|
||||
)
|
||||
logger = logging.getLogger("garmin_auth")
|
||||
|
||||
|
||||
def get_token_dir() -> str:
|
||||
"""Get the token directory from environment or default locations."""
|
||||
if os.environ.get("GARMINTOKENS"):
|
||||
p = os.path.expanduser(os.environ["GARMINTOKENS"])
|
||||
elif os.path.exists("/home/node/.openclaw"):
|
||||
p = "/home/node/.openclaw/garmin_tokens"
|
||||
else:
|
||||
p = os.path.expanduser("~/.garminconnect")
|
||||
os.makedirs(p, mode=0o700, exist_ok=True)
|
||||
return p
|
||||
|
||||
|
||||
def get_token_file() -> str:
|
||||
return os.path.join(get_token_dir(), "garmin_tokens.json")
|
||||
|
||||
|
||||
def get_token_base64_file() -> str:
|
||||
return os.path.join(get_token_dir(), "garmin_tokens.base64")
|
||||
|
||||
|
||||
def get_socket_path() -> str:
|
||||
token_dir = get_token_dir()
|
||||
if os.access(token_dir, os.W_OK):
|
||||
return os.path.join(token_dir, "auth_daemon.sock")
|
||||
return "/tmp/garmin_auth.sock"
|
||||
|
||||
|
||||
def get_status_file() -> str:
|
||||
token_dir = get_token_dir()
|
||||
if os.access(token_dir, os.W_OK):
|
||||
return os.path.join(token_dir, "auth_status.json")
|
||||
return "/tmp/garmin_auth_status.json"
|
||||
|
||||
|
||||
def get_credentials() -> Tuple[str, str]:
|
||||
email = os.environ.get("GARMIN_EMAIL") or os.environ.get("garmin-email")
|
||||
password = os.environ.get("GARMIN_PASSWORD") or os.environ.get("garmin-password")
|
||||
|
||||
if not email or not password:
|
||||
# Check files if defined
|
||||
email_file = os.environ.get("GARMIN_EMAIL_FILE")
|
||||
if email_file and os.path.exists(email_file):
|
||||
with open(email_file) as f:
|
||||
email = f.read().strip()
|
||||
password_file = os.environ.get("GARMIN_PASSWORD_FILE")
|
||||
if password_file and os.path.exists(password_file):
|
||||
with open(password_file) as f:
|
||||
password = f.read().strip()
|
||||
|
||||
if not email or not password:
|
||||
raise ValueError(
|
||||
"GARMIN_EMAIL and GARMIN_PASSWORD environment variables must be set."
|
||||
)
|
||||
return email.strip(), password.strip()
|
||||
|
||||
|
||||
def secure_tokens(token_dir: str) -> None:
|
||||
"""Set secure 700 / 600 permissions."""
|
||||
try:
|
||||
if os.path.isdir(token_dir):
|
||||
os.chmod(token_dir, 0o700)
|
||||
for entry in os.scandir(token_dir):
|
||||
if entry.is_file():
|
||||
os.chmod(entry.path, 0o600)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not secure directory permissions: {e}")
|
||||
|
||||
|
||||
def decode_jwt_exp(jwt_token: str) -> Tuple[Optional[datetime], Optional[int]]:
|
||||
"""Extract expiration datetime and timestamp from JWT."""
|
||||
try:
|
||||
parts = jwt_token.split(".")
|
||||
if len(parts) != 3:
|
||||
return None, None
|
||||
payload_b64 = parts[1]
|
||||
payload_b64 += "=" * (-len(payload_b64) % 4)
|
||||
payload = json.loads(base64.urlsafe_b64decode(payload_b64))
|
||||
exp = payload.get("exp")
|
||||
if exp:
|
||||
dt = datetime.fromtimestamp(exp, tz=timezone.utc)
|
||||
return dt, exp
|
||||
except Exception:
|
||||
pass
|
||||
return None, None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI Command: status
|
||||
# ---------------------------------------------------------------------------
|
||||
def cmd_status() -> int:
|
||||
token_file = get_token_file()
|
||||
token_dir = get_token_dir()
|
||||
status_file = get_status_file()
|
||||
|
||||
info: Dict[str, Any] = {
|
||||
"token_dir": token_dir,
|
||||
"token_file": token_file,
|
||||
"exists": os.path.exists(token_file),
|
||||
"valid": False,
|
||||
"user": None,
|
||||
"exp_utc": None,
|
||||
"exp_timestamp": None,
|
||||
"pending_mfa": False,
|
||||
}
|
||||
|
||||
if os.path.exists(status_file):
|
||||
try:
|
||||
with open(status_file) as f:
|
||||
s = json.load(f)
|
||||
if s.get("state") == "waiting_for_mfa":
|
||||
info["pending_mfa"] = True
|
||||
info["mfa_started_at"] = s.get("started_at")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not info["exists"]:
|
||||
print(json.dumps(info, indent=2))
|
||||
return 1
|
||||
|
||||
try:
|
||||
with open(token_file, "r") as f:
|
||||
data = json.load(f)
|
||||
|
||||
di_token = data.get("di_token")
|
||||
if di_token:
|
||||
dt, exp = decode_jwt_exp(di_token)
|
||||
if dt:
|
||||
info["exp_utc"] = dt.isoformat()
|
||||
info["exp_timestamp"] = exp
|
||||
|
||||
# Test live connectivity
|
||||
from garminconnect import Garmin
|
||||
|
||||
client = Garmin()
|
||||
client.login(token_dir)
|
||||
full_name = client.get_full_name()
|
||||
if full_name:
|
||||
info["valid"] = True
|
||||
info["user"] = full_name
|
||||
except Exception as e:
|
||||
info["error"] = str(e)
|
||||
|
||||
print(json.dumps(info, indent=2))
|
||||
return 0 if info["valid"] else 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Background Daemon: Run MFA Flow & Listen on Unix Socket
|
||||
# ---------------------------------------------------------------------------
|
||||
def _run_daemon():
|
||||
"""Executed as a detached background worker process."""
|
||||
socket_path = get_socket_path()
|
||||
status_file = get_status_file()
|
||||
token_dir = get_token_dir()
|
||||
token_file = get_token_file()
|
||||
base64_file = get_token_base64_file()
|
||||
|
||||
# Clean up old socket
|
||||
if os.path.exists(socket_path):
|
||||
try:
|
||||
os.unlink(socket_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
try:
|
||||
email, password = get_credentials()
|
||||
except Exception as e:
|
||||
with open(status_file, "w") as f:
|
||||
json.dump({"state": "error", "error": str(e)}, f)
|
||||
sys.exit(1)
|
||||
|
||||
with open(status_file, "w") as f:
|
||||
json.dump(
|
||||
{
|
||||
"state": "initiating_login",
|
||||
"started_at": time.time(),
|
||||
"email": email,
|
||||
},
|
||||
f,
|
||||
)
|
||||
|
||||
from garminconnect import (
|
||||
Garmin,
|
||||
GarminConnectAuthenticationError,
|
||||
GarminConnectConnectionError,
|
||||
GarminConnectTooManyRequestsError,
|
||||
)
|
||||
|
||||
try:
|
||||
garmin = Garmin(email=email, password=password, return_on_mfa=True)
|
||||
status, _ = garmin.login()
|
||||
|
||||
if status != "needs_mfa":
|
||||
# Logged in directly without MFA
|
||||
garmin.client.dump(token_dir)
|
||||
secure_tokens(token_dir)
|
||||
with open(token_file, "r") as tf:
|
||||
raw = tf.read()
|
||||
with open(base64_file, "w") as bf:
|
||||
bf.write(base64.b64encode(raw.encode()).decode())
|
||||
os.chmod(base64_file, 0o600)
|
||||
name = garmin.get_full_name()
|
||||
with open(status_file, "w") as f:
|
||||
json.dump({"state": "success", "user": name}, f)
|
||||
sys.exit(0)
|
||||
|
||||
# Needs MFA - set up listening Unix socket
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
sock.bind(socket_path)
|
||||
os.chmod(socket_path, 0o600)
|
||||
sock.listen(1)
|
||||
sock.settimeout(300.0) # 5 minutes timeout for user to submit code
|
||||
|
||||
with open(status_file, "w") as f:
|
||||
json.dump(
|
||||
{
|
||||
"state": "waiting_for_mfa",
|
||||
"started_at": time.time(),
|
||||
"email": email,
|
||||
"socket": socket_path,
|
||||
},
|
||||
f,
|
||||
)
|
||||
|
||||
while True:
|
||||
try:
|
||||
conn, _ = sock.accept()
|
||||
except socket.timeout:
|
||||
with open(status_file, "w") as f:
|
||||
json.dump(
|
||||
{"state": "timed_out", "error": "MFA wait timed out after 5 minutes"},
|
||||
f,
|
||||
)
|
||||
break
|
||||
|
||||
with conn:
|
||||
raw_data = conn.recv(1024).decode().strip()
|
||||
if not raw_data:
|
||||
continue
|
||||
|
||||
try:
|
||||
payload = json.loads(raw_data)
|
||||
code = payload.get("code", "").strip()
|
||||
except Exception:
|
||||
code = raw_data.strip()
|
||||
|
||||
code = re.sub(r"\D", "", code)
|
||||
if len(code) != 6:
|
||||
response = {
|
||||
"status": "invalid_code",
|
||||
"error": "Code must be 6 digits",
|
||||
}
|
||||
conn.sendall(json.dumps(response).encode())
|
||||
continue
|
||||
|
||||
try:
|
||||
garmin.resume_login(None, code)
|
||||
garmin.client.dump(token_dir)
|
||||
secure_tokens(token_dir)
|
||||
|
||||
# Export base64 string
|
||||
with open(token_file, "r") as tf:
|
||||
token_str = tf.read()
|
||||
with open(base64_file, "w") as bf:
|
||||
bf.write(base64.b64encode(token_str.encode()).decode())
|
||||
os.chmod(base64_file, 0o600)
|
||||
|
||||
name = garmin.get_full_name() or "User"
|
||||
response = {"status": "success", "user": name}
|
||||
conn.sendall(json.dumps(response).encode())
|
||||
|
||||
with open(status_file, "w") as f:
|
||||
json.dump(
|
||||
{
|
||||
"state": "success",
|
||||
"user": name,
|
||||
"finished_at": time.time(),
|
||||
},
|
||||
f,
|
||||
)
|
||||
break # Success! Clean exit
|
||||
|
||||
except GarminConnectAuthenticationError as ae:
|
||||
response = {
|
||||
"status": "auth_error",
|
||||
"error": f"MFA verification failed: {ae}",
|
||||
}
|
||||
conn.sendall(json.dumps(response).encode())
|
||||
except Exception as ex:
|
||||
response = {
|
||||
"status": "error",
|
||||
"error": f"Unexpected error during resume_login: {ex}",
|
||||
}
|
||||
conn.sendall(json.dumps(response).encode())
|
||||
|
||||
except Exception as e:
|
||||
with open(status_file, "w") as f:
|
||||
json.dump({"state": "error", "error": str(e)}, f)
|
||||
finally:
|
||||
if os.path.exists(socket_path):
|
||||
try:
|
||||
os.unlink(socket_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI Command: request-code
|
||||
# ---------------------------------------------------------------------------
|
||||
def cmd_request_code() -> int:
|
||||
socket_path = get_socket_path()
|
||||
status_file = get_status_file()
|
||||
|
||||
# If daemon is already running and waiting, check status
|
||||
if os.path.exists(socket_path) and os.path.exists(status_file):
|
||||
try:
|
||||
with open(status_file) as f:
|
||||
st = json.load(f)
|
||||
if st.get("state") == "waiting_for_mfa":
|
||||
print(
|
||||
"MFA_ALREADY_ACTIVE: A pending login session is already waiting for your MFA code. "
|
||||
"Reply with the 6-digit code or wait for timeout."
|
||||
)
|
||||
return 0
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Clean up stale files
|
||||
for p in (socket_path, status_file):
|
||||
if os.path.exists(p):
|
||||
try:
|
||||
os.unlink(p)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Spawn daemon process via subprocess.Popen detached
|
||||
script_path = os.path.abspath(__file__)
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, script_path, "_daemon"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
logger.info(f"Spawned Garmin auth daemon (PID: {proc.pid})")
|
||||
|
||||
# Poll status file up to 25 seconds
|
||||
start_wait = time.time()
|
||||
while time.time() - start_wait < 25.0:
|
||||
time.sleep(0.5)
|
||||
if os.path.exists(status_file):
|
||||
try:
|
||||
with open(status_file) as f:
|
||||
data = json.load(f)
|
||||
state = data.get("state")
|
||||
if state == "waiting_for_mfa":
|
||||
print(
|
||||
"MFA_REQUIRED: Garmin has sent a 6-digit verification code to your email/SMS. "
|
||||
"Please reply with the code."
|
||||
)
|
||||
return 0
|
||||
elif state == "success":
|
||||
print(
|
||||
f"SUCCESS: Logged in directly as {data.get('user')} without MFA. Tokens saved."
|
||||
)
|
||||
return 0
|
||||
elif state == "error":
|
||||
print(f"ERROR: Login failed: {data.get('error')}", file=sys.stderr)
|
||||
return 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print("ERROR: Timed out waiting for Garmin SSO to send MFA code.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI Command: verify <code>
|
||||
# ---------------------------------------------------------------------------
|
||||
def cmd_verify(code: str) -> int:
|
||||
code = re.sub(r"\D", "", code.strip())
|
||||
if len(code) != 6:
|
||||
print(f"ERROR: Code must be exactly 6 digits, got {code!r}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
socket_path = get_socket_path()
|
||||
if not os.path.exists(socket_path):
|
||||
print(
|
||||
"ERROR: No active Garmin auth session found. Run 'request-code' first.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
try:
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
sock.connect(socket_path)
|
||||
sock.settimeout(35.0)
|
||||
|
||||
sock.sendall(json.dumps({"code": code}).encode())
|
||||
raw_res = sock.recv(2048).decode()
|
||||
sock.close()
|
||||
|
||||
res = json.loads(raw_res)
|
||||
if res.get("status") == "success":
|
||||
user = res.get("user", "Moritz Graf")
|
||||
print(f"SUCCESS: Authenticated as {user}! Tokens saved successfully.")
|
||||
return 0
|
||||
else:
|
||||
print(f"ERROR: {res.get('error', 'Authentication failed')}", file=sys.stderr)
|
||||
return 1
|
||||
except Exception as e:
|
||||
print(f"ERROR: Failed communicating with auth daemon: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI Command: keepalive
|
||||
# ---------------------------------------------------------------------------
|
||||
def cmd_keepalive() -> int:
|
||||
token_dir = get_token_dir()
|
||||
token_file = get_token_file()
|
||||
|
||||
if not os.path.exists(token_file):
|
||||
print(f"ERROR: No tokens found at {token_file}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
try:
|
||||
from garminconnect import Garmin
|
||||
|
||||
client = Garmin()
|
||||
client.login(token_dir)
|
||||
# Calling an API endpoint triggers _token_expires_soon() and auto-refresh + dump
|
||||
name = client.get_full_name()
|
||||
if not name:
|
||||
print("ERROR: Login returned empty profile name", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
with open(token_file) as f:
|
||||
data = json.load(f)
|
||||
dt, exp = decode_jwt_exp(data.get("di_token", ""))
|
||||
|
||||
exp_str = dt.strftime("%Y-%m-%d %H:%M:%S UTC") if dt else "unknown"
|
||||
print(f"KEEPALIVE_OK: Token valid for {name}. Current DI token expires at: {exp_str}")
|
||||
return 0
|
||||
except Exception as e:
|
||||
print(f"ERROR: Keepalive check failed: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main Router
|
||||
# ---------------------------------------------------------------------------
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Garmin Authentication & Token Lifecycle CLI for OpenClaw"
|
||||
)
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
# status
|
||||
subparsers.add_parser("status", help="Check stored token status and validity")
|
||||
|
||||
# request-code
|
||||
subparsers.add_parser(
|
||||
"request-code", help="Initiate Garmin login and request an MFA code"
|
||||
)
|
||||
|
||||
# verify <code>
|
||||
verify_parser = subparsers.add_parser(
|
||||
"verify", help="Submit MFA code to the active auth session"
|
||||
)
|
||||
verify_parser.add_argument("code", help="6-digit verification code")
|
||||
|
||||
# keepalive
|
||||
subparsers.add_parser(
|
||||
"keepalive", help="Ping Garmin API and proactively refresh token if needed"
|
||||
)
|
||||
|
||||
# internal daemon
|
||||
subparsers.add_parser("_daemon", help=argparse.SUPPRESS)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command == "_daemon":
|
||||
_run_daemon()
|
||||
elif args.command == "status":
|
||||
sys.exit(cmd_status())
|
||||
elif args.command == "request-code":
|
||||
sys.exit(cmd_request_code())
|
||||
elif args.command == "verify":
|
||||
sys.exit(cmd_verify(args.code))
|
||||
elif args.command == "keepalive":
|
||||
sys.exit(cmd_keepalive())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,120 @@
|
||||
---
|
||||
name: garmin-workout
|
||||
description: Design, validate, upload, repair, delete, and schedule Garmin Connect workouts. Enforces strict sportType IDs, DTO schemas, and avoids Garmin Connect mobile app list corruption. Use when creating, modifying, uploading, scheduling, or deleting workouts in Garmin Connect.
|
||||
---
|
||||
|
||||
# 🏋️ Garmin Connect Workout Manager & Schema Validator
|
||||
|
||||
This skill defines the technical standards, DTO schemas, sport type identifiers, and safety protocols for creating, uploading, scheduling, and managing Garmin Connect workouts without corrupting the Garmin Connect mobile app or web client.
|
||||
|
||||
---
|
||||
|
||||
## 🚨 Critical Safety Rule: Sport Type Integrity
|
||||
|
||||
Whenever creating or uploading workouts via `garmin__upload_workout` or `schedule_workout`, **BOTH `sportTypeId` (integer) AND `sportTypeKey` (string)** MUST be provided in:
|
||||
1. The top-level workout object: `workout_data["sportType"]`
|
||||
2. Every segment object: `workout_data["workoutSegments"][i]["sportType"]`
|
||||
|
||||
### ⚠️ The "0 Workouts" Bug
|
||||
If you send only `{"sportTypeKey": "yoga"}` without `sportTypeId`, Garmin's backend accepts the HTTP POST but defaults `sportTypeId` to `0` and sets `sportTypeKey: null` in its database.
|
||||
When the Garmin Connect mobile app (iOS / Android) or modern web UI attempts to deserialize the workout list, it crashes on `null` and displays **"0 Workouts"**, making it appear as if all user workouts were deleted!
|
||||
|
||||
### 📋 Canonical Garmin Sport Type Mapping
|
||||
| Sport | `sportTypeId` | `sportTypeKey` | `displayOrder` | Notes |
|
||||
|---|---|---|---|---|
|
||||
| **Yoga** | `7` | `"yoga"` | `8` | Essential for yoga flows |
|
||||
| **Running** | `1` | `"running"` | `1` | Standard running |
|
||||
| **Cycling** | `2` | `"cycling"` | `2` | Road, gravel, Tacx indoor |
|
||||
| **Strength** | `5` | `"strength_training"` | `5` | Home gym, supersets |
|
||||
| **Cardio** | `6` | `"cardio_training"` | `6` | General cardio |
|
||||
| **Pilates** | `8` | `"pilates"` | `9` | Core & flexibility |
|
||||
| **HIIT** | `9` | `"hiit"` | `7` | Tabata, SIT, sprint intervals |
|
||||
| **Swimming** | `4` | `"lap_swimming"` | `4` | Pool swimming |
|
||||
| **Walking** | `12` | `"walking"` | `10` | Recovery walks |
|
||||
| **Mobility** | `11` | `"mobility"` | `11` | Joint mobility |
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Workout JSON Structure Reference
|
||||
|
||||
```json
|
||||
{
|
||||
"workoutName": "GeminiYoga#1 Fixed",
|
||||
"description": "Optimierte Yoga-Sequenz ohne Redundanz.",
|
||||
"estimatedDurationInSecs": 1800,
|
||||
"sportType": {
|
||||
"sportTypeId": 7,
|
||||
"sportTypeKey": "yoga",
|
||||
"displayOrder": 8
|
||||
},
|
||||
"workoutSegments": [
|
||||
{
|
||||
"segmentOrder": 1,
|
||||
"sportType": {
|
||||
"sportTypeId": 7,
|
||||
"sportTypeKey": "yoga",
|
||||
"displayOrder": 8
|
||||
},
|
||||
"workoutSteps": [
|
||||
{
|
||||
"type": "ExecutableStepDTO",
|
||||
"stepOrder": 1,
|
||||
"stepType": {
|
||||
"stepTypeId": 1,
|
||||
"stepTypeKey": "warmup"
|
||||
},
|
||||
"endCondition": {
|
||||
"conditionTypeId": 2,
|
||||
"conditionTypeKey": "time"
|
||||
},
|
||||
"endConditionValue": 60.0,
|
||||
"targetType": {
|
||||
"workoutTargetTypeId": 1,
|
||||
"workoutTargetTypeKey": "no.target"
|
||||
},
|
||||
"description": "Mountain Pose - Atmung etablieren"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Operational Recipes
|
||||
|
||||
### 1. Removing a Corrupted Workout
|
||||
If a workout was uploaded with `sportTypeId: 0` or corrupt data:
|
||||
```json
|
||||
// Tool: mcp:garmin:garmin__delete_workout
|
||||
{
|
||||
"workout_id": 1703067815
|
||||
}
|
||||
```
|
||||
*Effect:* Instantly restores Garmin Connect app visibility for all remaining workouts.
|
||||
|
||||
### 2. Uploading a Fixed Workout
|
||||
```json
|
||||
// Tool: mcp:garmin:garmin__upload_workout
|
||||
{
|
||||
"workout_data": {
|
||||
"workoutName": "...",
|
||||
"sportType": {
|
||||
"sportTypeId": 7,
|
||||
"sportTypeKey": "yoga",
|
||||
"displayOrder": 8
|
||||
},
|
||||
"workoutSegments": [ ... ]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Scheduling to Watch Calendar
|
||||
```json
|
||||
// Tool: mcp:garmin:garmin__schedule_workout
|
||||
{
|
||||
"workout_id": 1703067815,
|
||||
"calendar_date": "2026-09-20"
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,131 @@
|
||||
---
|
||||
name: google-calendar
|
||||
description: Read, search, create, update, and delete Google Calendar events across the Family Calendar and Regensburg & more Calendar. Supports visual color-coding (Arzt=Rot, Mila=Gelb, Moritz=Blau, Amelie=Grün, Familie=Orange), conflict & travel-buffer checking, address locations, and packing checklists.
|
||||
---
|
||||
|
||||
# 📅 Google Calendar (Familie & Regensburg)
|
||||
|
||||
Full two-way Google Calendar integration for Moritz, Amelie, and family, managing two distinct calendars via a dedicated GCP Service Account:
|
||||
|
||||
1. 👨👩👧 **Familienkalender (`family`)**:
|
||||
- Primary personal & family schedule: doctor visits, daycare/kindergarten, birthdays, visits, work trips, family outings, shared commitments.
|
||||
- Default calendar for all personal appointment requests.
|
||||
2. 🏛️ **Regensburg & mehr (`regensburg`)**:
|
||||
- Local city life: Dult, Bürgerfest, concerts, theatre, local festivals, markets, exhibitions.
|
||||
- Keeps the family calendar clean and uncluttered while keeping track of exciting local events.
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Farbleitsystem (Visual Color Coding)
|
||||
|
||||
OpenClaw assigns Google Calendar colors automatically based on event titles or explicit categories:
|
||||
|
||||
| Farbe | `colorId` | Kategorie / Person | Typische Stichwörter |
|
||||
| :--- | :---: | :--- | :--- |
|
||||
| 🔴 **Rot (Tomate)** | `11` | **Arzt & Gesundheit** | Arzt, Kinderarzt, Zahnarzt, U1..U9, Impfung, Klinik, Physio, Blutabnahme |
|
||||
| 🟡 **Gelb (Banane)** | `5` | **Mila & Kinder** | Mila, Kita, Kindergarten, Krabbelgruppe, Spielplatz, Babyschwimmen, Kinderturnen |
|
||||
| 🔵 **Blau (Heidelbeere)** | `9` | **Moritz** | Moritz, Arbeit, Büro, Job, Dienstreise |
|
||||
| 🟢 **Grün (Salbei)** | `2` | **Amelie** | Amelie, Amy |
|
||||
| 🟠 **Orange (Mandarine)** | `6` | **Familie & Feiern** | Familie, Ausflug, Urlaub, Feier, Party, Geburtstag, Besuch, Oma, Opa, Essen |
|
||||
| ⚪ **Grau (Graphit)** | `8` | **Haushalt & Wartung** | Haushalt, Auto, TÜV, Werkstatt, Müll, Handwerker |
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ MCP Tools & CLI Fallbacks
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **OpenClaw Tool Naming:** In OpenClaw, MCP tools from the `calendar` server are prefixed with `calendar__`:
|
||||
> - `calendar__calendar_list_events`
|
||||
> - `calendar__calendar_check_conflicts`
|
||||
> - `calendar__calendar_create_event`
|
||||
> - `calendar__calendar_update_event`
|
||||
> - `calendar__calendar_delete_event`
|
||||
> - `calendar__calendar_move_event`
|
||||
> - `calendar__calendar_find_free_slots`
|
||||
> - `calendar__calendar_get_event`
|
||||
>
|
||||
> If MCP execution is unavailable or fails with `Tool not found`, ALWAYS fall back to the direct Python CLI:
|
||||
> ```bash
|
||||
> # Suchen:
|
||||
> /home/node/.openclaw/mcp-venv/bin/python3 /home/node/mop-skills/mop-skills/skills/google-calendar/scripts/google_calendar.py list --calendar regensburg --query "Suchbegriff"
|
||||
>
|
||||
> # Löschen (inkl. Serie):
|
||||
> /home/node/.openclaw/mcp-venv/bin/python3 /home/node/mop-skills/mop-skills/skills/google-calendar/scripts/google_calendar.py delete <event_id> --calendar regensburg --series
|
||||
> ```
|
||||
|
||||
### 1. `calendar__calendar_list_events(calendar="family"|"regensburg", time_min=None, time_max=None, query=None, category=None, color_id=None, max_results=25)`:
|
||||
- Lists appointments within a timeframe including start/end, color name, location, recurrence status, and description.
|
||||
- `time_min` / `time_max`: ISO format (e.g. `2026-09-20` or `2026-09-20T08:00:00`). Defaults to start of today until 30 days ahead. Automatically expands recurring events into individual occurrences.
|
||||
- `category` / `color_id`: Filter by person or category (e.g. `category="arzt"` or `color_id="11"`).
|
||||
- `query`: Text filter to search summaries and descriptions.
|
||||
|
||||
### 2. `calendar__calendar_check_conflicts(calendar="family"|"regensburg", start_time, end_time=None, buffer_minutes=15)`:
|
||||
- Proactively checks if a proposed slot conflicts with existing events or leaves less than `buffer_minutes` travel buffer.
|
||||
- Returns `has_conflict`, `direct_conflicts`, `has_buffer_warning`, and `buffer_warnings`.
|
||||
|
||||
### 3. `calendar__calendar_create_event(calendar="family"|"regensburg", summary, start_time, end_time=None, recurrence=None, show_as_busy=True, transparency=None, reminder_minutes=None, category=None, color_id=None, description=None, location=None, all_day=False, inclusive_end=True, check_conflicts=True)`:
|
||||
- Adds a new appointment with automatic color coding, recurrence, transparency, phone reminders, and overlap detection.
|
||||
- **`recurrence`**: Standard RFC 5545 rule or shortcuts:
|
||||
- `recurrence="RRULE:FREQ=WEEKLY;BYDAY=SA"`
|
||||
- Shortcuts: `"weekly"`, `"daily"`, `"monthly"`, `"workdays"`.
|
||||
- **`show_as_busy` / `transparency`**:
|
||||
- For **Regensburg city events, festivals, exhibitions**: set `show_as_busy=False` or `transparency="transparent"` so they are visible on the calendar without blocking family schedule slots or triggering false conflict warnings!
|
||||
- **`reminder_minutes`**: List of popup alert minutes before event (e.g. `[15, 60]` or `1440` for 1 day before).
|
||||
- **`inclusive_end`**: For multi-day all-day events, automatically includes the end date without off-by-one errors.
|
||||
|
||||
### 4. `calendar__calendar_update_event(calendar="family"|"regensburg", event_id, summary=None, start_time=None, end_time=None, recurrence=None, show_as_busy=None, transparency=None, reminder_minutes=None, update_series=False, category=None, color_id=None, description=None, location=None)`:
|
||||
- Updates fields, recurrence, transparency, reminders, or recolors an existing event.
|
||||
- `update_series=True`: If event is an occurrence of a series, updates the entire master series instead of just this single occurrence.
|
||||
|
||||
### 5. `calendar__calendar_delete_event(calendar="family"|"regensburg", event_id, delete_series=False)`:
|
||||
- Deletes an event.
|
||||
- `delete_series=True`: If event is part of a recurring series, deletes the entire recurring series instead of just this single occurrence.
|
||||
|
||||
### 6. `calendar__calendar_move_event(event_id, source_calendar="family", destination_calendar="regensburg")`:
|
||||
- Moves an event atomically from one calendar to another.
|
||||
|
||||
### 7. `calendar__calendar_find_free_slots(calendar="family"|"regensburg"|"both", time_min=None, time_max=None, duration_minutes=60, day_start_hour=8, day_end_hour=20)`:
|
||||
- Queries `freebusy` to find open, available time windows across calendars.
|
||||
|
||||
### 8. `calendar__calendar_get_event(calendar="family"|"regensburg", event_id)`:
|
||||
- Retrieves full event metadata.
|
||||
|
||||
---
|
||||
|
||||
## 🧭 Routing & Best Practices for OpenClaw
|
||||
|
||||
### ⚠️ Strikte Guardrails:
|
||||
1. **STRIKTE REGEL: NIEMALS ERFOLG VORTÄUSCHEN (NO FAKE CONFIRMATIONS)!** Wenn ein Tool fehlschlägt oder `not found` meldet, behaupte **niemals**, dass der Termin gelöscht oder erstellt wurde! Nutze den CLI-Fallback oder berichte den Fehler wahrheitsgemäß.
|
||||
2. **STRIKTE REGEL: KEINE MÄRKTE IN DEN KALENDER EINTRAGEN!** Weder Wochenmärkte (Katharinenmarkt, Neupfarrplatz, etc.) noch Bio-Bauernmärkte dürfen in die Kalender eingetragen werden. Dies überfrachtet den Kalender.
|
||||
|
||||
### 1. Conflict Guard (Terminkonflikte & Puffer)
|
||||
- When a user asks to add an event, check `calendar__calendar_check_conflicts` or inspect the `conflict_warning` from `calendar__calendar_create_event`.
|
||||
- If an overlap or tight buffer (< 15 Min.) exists, explicitly warn the user.
|
||||
|
||||
### 2. Informational Events vs. Family Commitments (Transparency)
|
||||
- When adding city events, festivals, or exhibitions to the **`regensburg`** calendar, always pass `show_as_busy=False` (or `transparency="transparent"`).
|
||||
- This keeps the event clearly visible on the calendar while allowing family appointments to be scheduled in parallel without false conflict alerts.
|
||||
|
||||
### 3. Recurring Series (RRULE)
|
||||
- When the user asks to add recurring appointments (e.g. sports training, trash collection):
|
||||
- Use `recurrence="RRULE:FREQ=WEEKLY;BYDAY=..."` or convenient shortcuts.
|
||||
|
||||
### 4. Smart Location & Google Maps Integration
|
||||
- If a place or venue is mentioned (e.g. *"Dr. Müller in Prüfening"*, *"Westbad"*):
|
||||
- Resolve the full address or ask the user, and store it in `location`.
|
||||
- This activates Google Maps departure alerts on their smartphone.
|
||||
|
||||
### 5. Proaktive Rückfragen & Checklisten (Mental Load Entlastung)
|
||||
- When booking medical/baby appointments (e.g. *Kinderarzt*, *U-Untersuchungen*, *Zahnarzt*):
|
||||
- Auto-generate or suggest a helpful checklist in `description`:
|
||||
```text
|
||||
📋 Mitnehmen:
|
||||
- U-Heft & Impfpass
|
||||
- Versichertenkarte
|
||||
- 2x Wickelzeug & Schnuller
|
||||
```
|
||||
- Suggest a reminder (`reminder_minutes=[60, 1440]`):
|
||||
> *„Termin eingetragen! 🏥 Ich habe eine Erinnerung 1 Stunde vorher eingerichtet. Soll ich noch eine Packliste im Termin hinterlegen?“*
|
||||
|
||||
### 6. Timezone & DateTime Precision
|
||||
- All dates and times operate in **Europe/Berlin** (`CET`/`CEST`).
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
name: google-maps
|
||||
description: Search places, live opening hours, contact details, routing directions (drive/transit/walk/bike), and static visual maps via Google Maps Platform (Places New, Routes, and Static Maps).
|
||||
---
|
||||
|
||||
# 🗺️ Google Maps Platform (Places, Routes & Visual Maps)
|
||||
|
||||
Integration with Google Maps Platform for OpenClaw, enabling local point-of-interest lookups, real-time opening hours, routing directions, and static map images:
|
||||
|
||||
1. 📍 **`maps_search_places(query, location="Regensburg, Germany", radius_meters=5000, open_now=False)`**:
|
||||
- Searches places (bakeries, restaurants, playgrounds, doctors, supermarkets) using Google Places API (New).
|
||||
- Biased around Stadtamhof / Regensburg by default.
|
||||
- Returns place name, address, rating, user ratings count, live opening status (`open_now: true/false`, today's opening hours), phone number, category, and direct Google Maps link.
|
||||
|
||||
2. 🕒 **`maps_get_place_details(place_id)`**:
|
||||
- Fetches full weekly opening hours (Monday–Sunday), website, wheelchair accessibility, and top recent reviews.
|
||||
|
||||
3. 🚗 **`maps_get_directions(origin, destination, travel_mode="DRIVE"|"TRANSIT"|"WALK"|"BICYCLE")`**:
|
||||
- Computes route distance, travel duration, and key turn/transit steps using Google Routes API v2.
|
||||
- Ideal for trip planning, estimating commute time before appointments, or finding walking routes.
|
||||
|
||||
4. 🖼️ **`maps_render_static_map(center_or_address, zoom=15, size="600x400")`**:
|
||||
- Generates a static map PNG image saved to OpenClaw media storage.
|
||||
- Outputs `MEDIA:<path>` so Telegram displays a visual location photo card directly in the chat!
|
||||
|
||||
---
|
||||
|
||||
## 🧭 Prompt Routing & Best Practices
|
||||
|
||||
- **When the user asks about local shops or opening hours** (*"Ist der Bäcker in Stadtamhof noch offen?"*, *"Wann schließt der Edeka?"*):
|
||||
- Call `maps_search_places` with the venue name and `location="Stadtamhof, Regensburg"`.
|
||||
- Report the live status (`Offen` or `Geschlossen`) and today's closing time clearly.
|
||||
- **When planning outings or dining** (*"Gibt es einen guten Biergarten in der Nähe?"*, *"Finde einen Spielplatz mit Schatten in Regensburg"*):
|
||||
- Search using `maps_search_places`, highlight ratings and addresses, and offer to calculate walking directions or render a map card.
|
||||
- **For Google Calendar Appointments**:
|
||||
- When an appointment has a location or the user asks *"Wann muss ich los zum Arzt?"*, call `maps_get_directions` from `Grabengasse 7, 93059 Regensburg` to the destination to provide accurate departure times.
|
||||
@@ -0,0 +1,598 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Google Maps Platform MCP Server & CLI Tool for OpenClaw.
|
||||
|
||||
Integrates Google Places (New), Routes API, Geocoding, and Static Maps with OpenClaw.
|
||||
Features:
|
||||
- maps_search_places: Search restaurants, bakeries, parks, shops with live opening hours & ratings
|
||||
- maps_get_place_details: Get full weekly schedule (Mo-Su), phone, website, and reviews
|
||||
- maps_get_directions: Calculate transit, walking, or driving routes with distance & duration
|
||||
- maps_render_static_map: Generate static map PNG images for Telegram visual previews
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import hashlib
|
||||
import argparse
|
||||
import requests
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
# Environment
|
||||
GOOGLE_MAPS_API_KEY = os.environ.get("GOOGLE_MAPS_API_KEY", "").strip() or os.environ.get("GOOGLE_PLACES_API_KEY", "").strip()
|
||||
DEFAULT_LOCATION = os.environ.get("MAPS_DEFAULT_LOCATION", "Regensburg, Germany")
|
||||
MEDIA_OUTPUT_DIR = os.environ.get("MAPS_MEDIA_DIR", "/tmp/openclaw_maps")
|
||||
|
||||
|
||||
def ensure_media_dir() -> str:
|
||||
"""Ensure output directory for static map images exists."""
|
||||
os.makedirs(MEDIA_OUTPUT_DIR, exist_ok=True)
|
||||
return MEDIA_OUTPUT_DIR
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Google Maps Web Services
|
||||
# ==============================================================================
|
||||
|
||||
def geocode_address(address: str, api_key: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||
"""Geocode an address to lat/lng."""
|
||||
key = api_key or GOOGLE_MAPS_API_KEY
|
||||
if not key:
|
||||
return None
|
||||
url = "https://maps.googleapis.com/maps/api/geocode/json"
|
||||
params = {"address": address, "key": key, "language": "de"}
|
||||
try:
|
||||
resp = requests.get(url, params=params, timeout=10)
|
||||
data = resp.json()
|
||||
if data.get("status") == "OK" and data.get("results"):
|
||||
first = data["results"][0]
|
||||
loc = first["geometry"]["location"]
|
||||
return {
|
||||
"lat": loc["lat"],
|
||||
"lng": loc["lng"],
|
||||
"formatted_address": first.get("formatted_address"),
|
||||
"place_id": first.get("place_id"),
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def search_places(
|
||||
query: str,
|
||||
location: Optional[str] = None,
|
||||
radius_meters: int = 5000,
|
||||
open_now: bool = False,
|
||||
api_key: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Search places using Google Places API (New)."""
|
||||
key = api_key or GOOGLE_MAPS_API_KEY
|
||||
if not key:
|
||||
return {"success": False, "error": "GOOGLE_MAPS_API_KEY is not set.", "places": []}
|
||||
|
||||
url = "https://places.googleapis.com/v1/places:searchText"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Goog-Api-Key": key,
|
||||
"X-Goog-FieldMask": (
|
||||
"places.id,places.displayName,places.formattedAddress,places.location,"
|
||||
"places.rating,places.userRatingCount,places.currentOpeningHours,"
|
||||
"places.nationalPhoneNumber,places.googleMapsUri,places.priceLevel,places.primaryType"
|
||||
),
|
||||
}
|
||||
|
||||
body: Dict[str, Any] = {
|
||||
"textQuery": query,
|
||||
"languageCode": "de",
|
||||
"maxResultCount": 8,
|
||||
}
|
||||
|
||||
if open_now:
|
||||
body["openNow"] = True
|
||||
|
||||
loc_str = location or DEFAULT_LOCATION
|
||||
geo = geocode_address(loc_str, key)
|
||||
if geo:
|
||||
body["locationBias"] = {
|
||||
"circle": {
|
||||
"center": {"latitude": geo["lat"], "longitude": geo["lng"]},
|
||||
"radius": float(radius_meters),
|
||||
}
|
||||
}
|
||||
|
||||
try:
|
||||
resp = requests.post(url, headers=headers, json=body, timeout=10)
|
||||
if resp.status_code != 200:
|
||||
return {"success": False, "error": f"Places API returned {resp.status_code}: {resp.text}", "places": []}
|
||||
|
||||
data = resp.json()
|
||||
raw_places = data.get("places", [])
|
||||
places = []
|
||||
|
||||
for p in raw_places:
|
||||
opening = p.get("currentOpeningHours", {})
|
||||
open_status = "Offen" if opening.get("openNow") else ("Geschlossen" if "openNow" in opening else "Keine Angabe")
|
||||
descriptions = opening.get("weekdayDescriptions", [])
|
||||
|
||||
places.append({
|
||||
"place_id": p.get("id"),
|
||||
"name": p.get("displayName", {}).get("text", "Unbekannt"),
|
||||
"address": p.get("formattedAddress", ""),
|
||||
"rating": p.get("rating"),
|
||||
"user_ratings_total": p.get("userRatingCount"),
|
||||
"open_now": opening.get("openNow"),
|
||||
"status_text": open_status,
|
||||
"today_hours": descriptions[0] if descriptions else None,
|
||||
"phone": p.get("nationalPhoneNumber"),
|
||||
"maps_url": p.get("googleMapsUri"),
|
||||
"category": p.get("primaryType"),
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"query": query,
|
||||
"count": len(places),
|
||||
"location_bias": loc_str if geo else None,
|
||||
"places": places,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": f"Place search failed: {str(e)}", "places": []}
|
||||
|
||||
|
||||
def get_place_details(place_id: str, api_key: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Get comprehensive place details including weekly schedule and reviews."""
|
||||
key = api_key or GOOGLE_MAPS_API_KEY
|
||||
if not key:
|
||||
return {"success": False, "error": "GOOGLE_MAPS_API_KEY is not set."}
|
||||
|
||||
# Ensure place_id format (v1 API supports raw ID or places/ID)
|
||||
p_id = place_id.replace("places/", "")
|
||||
url = f"https://places.googleapis.com/v1/places/{p_id}"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Goog-Api-Key": key,
|
||||
"X-Goog-FieldMask": (
|
||||
"id,displayName,formattedAddress,currentOpeningHours,regularOpeningHours,"
|
||||
"nationalPhoneNumber,internationalPhoneNumber,websiteUri,googleMapsUri,"
|
||||
"rating,userRatingCount,reviews,accessibilityOptions,priceLevel"
|
||||
),
|
||||
}
|
||||
params = {"languageCode": "de"}
|
||||
|
||||
try:
|
||||
resp = requests.get(url, headers=headers, params=params, timeout=10)
|
||||
if resp.status_code != 200:
|
||||
return {"success": False, "error": f"Places API returned {resp.status_code}: {resp.text}"}
|
||||
|
||||
p = resp.json()
|
||||
current_hours = p.get("currentOpeningHours", {})
|
||||
regular_hours = p.get("regularOpeningHours", {})
|
||||
schedule = regular_hours.get("weekdayDescriptions") or current_hours.get("weekdayDescriptions") or []
|
||||
|
||||
reviews_summary = []
|
||||
for rev in (p.get("reviews", []) or [])[:3]:
|
||||
reviews_summary.append({
|
||||
"author": rev.get("authorAttribution", {}).get("displayName"),
|
||||
"rating": rev.get("rating"),
|
||||
"text": rev.get("text", {}).get("text", "")[:300],
|
||||
"publish_time": rev.get("relativePublishTimeDescription"),
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"place_id": p_id,
|
||||
"name": p.get("displayName", {}).get("text", "Unbekannt"),
|
||||
"address": p.get("formattedAddress", ""),
|
||||
"rating": p.get("rating"),
|
||||
"user_ratings_total": p.get("userRatingCount"),
|
||||
"open_now": current_hours.get("openNow"),
|
||||
"weekly_hours": schedule,
|
||||
"phone": p.get("nationalPhoneNumber") or p.get("internationalPhoneNumber"),
|
||||
"website": p.get("websiteUri"),
|
||||
"maps_url": p.get("googleMapsUri"),
|
||||
"accessibility": p.get("accessibilityOptions", {}),
|
||||
"recent_reviews": reviews_summary,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": f"Place details fetch failed: {str(e)}"}
|
||||
|
||||
|
||||
def get_directions(
|
||||
origin: str,
|
||||
destination: str,
|
||||
travel_mode: str = "DRIVE",
|
||||
api_key: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Calculate route directions, distance, and duration using Google Routes API v2."""
|
||||
key = api_key or GOOGLE_MAPS_API_KEY
|
||||
if not key:
|
||||
return {"success": False, "error": "GOOGLE_MAPS_API_KEY is not set."}
|
||||
|
||||
# Normalize travel mode
|
||||
mode_map = {
|
||||
"drive": "DRIVE",
|
||||
"driving": "DRIVE",
|
||||
"transit": "TRANSIT",
|
||||
"public_transport": "TRANSIT",
|
||||
"walk": "WALK",
|
||||
"walking": "WALK",
|
||||
"bicycle": "BICYCLE",
|
||||
"bicycling": "BICYCLE",
|
||||
"bike": "BICYCLE",
|
||||
}
|
||||
norm_mode = mode_map.get(travel_mode.lower(), "DRIVE")
|
||||
|
||||
url = "https://routes.googleapis.com/directions/v2:computeRoutes"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Goog-Api-Key": key,
|
||||
"X-Goog-FieldMask": (
|
||||
"routes.duration,routes.distanceMeters,routes.description,"
|
||||
"routes.legs.duration,routes.legs.distanceMeters,routes.legs.steps"
|
||||
),
|
||||
}
|
||||
|
||||
body = {
|
||||
"origin": {"address": origin},
|
||||
"destination": {"address": destination},
|
||||
"travelMode": norm_mode,
|
||||
"languageCode": "de",
|
||||
"units": "METRIC",
|
||||
}
|
||||
|
||||
try:
|
||||
resp = requests.post(url, headers=headers, json=body, timeout=12)
|
||||
if resp.status_code != 200:
|
||||
return {"success": False, "error": f"Routes API returned {resp.status_code}: {resp.text}"}
|
||||
|
||||
data = resp.json()
|
||||
routes = data.get("routes", [])
|
||||
if not routes:
|
||||
return {
|
||||
"success": False,
|
||||
"origin": origin,
|
||||
"destination": destination,
|
||||
"error": "No route found between origin and destination.",
|
||||
}
|
||||
|
||||
primary = routes[0]
|
||||
dist_m = primary.get("distanceMeters", 0)
|
||||
dur_str = primary.get("duration", "0s").rstrip("s")
|
||||
dur_sec = int(dur_str) if dur_str.isdigit() else 0
|
||||
|
||||
dist_km = round(dist_m / 1000.0, 1)
|
||||
dur_min = round(dur_sec / 60)
|
||||
|
||||
# Step summary
|
||||
steps_summary = []
|
||||
for leg in primary.get("legs", []):
|
||||
for step in leg.get("steps", [])[:8]:
|
||||
instr = step.get("navigationInstruction", {}).get("instructions")
|
||||
dist_step = step.get("distanceMeters", 0)
|
||||
if instr:
|
||||
steps_summary.append(f"{instr} ({dist_step}m)")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"origin": origin,
|
||||
"destination": destination,
|
||||
"travel_mode": norm_mode,
|
||||
"distance_meters": dist_m,
|
||||
"distance_km": dist_km,
|
||||
"duration_seconds": dur_sec,
|
||||
"duration_minutes": dur_min,
|
||||
"duration_formatted": f"{dur_min} Min." if dur_min < 60 else f"{dur_min // 60} Std. {dur_min % 60} Min.",
|
||||
"description": primary.get("description", ""),
|
||||
"steps_overview": steps_summary,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": f"Directions calculation failed: {str(e)}"}
|
||||
|
||||
|
||||
def render_static_map(
|
||||
center_or_address: str,
|
||||
zoom: int = 15,
|
||||
markers: Optional[List[str]] = None,
|
||||
size: str = "600x400",
|
||||
maptype: str = "roadmap",
|
||||
api_key: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Download a static map PNG image and save to local media directory."""
|
||||
key = api_key or GOOGLE_MAPS_API_KEY
|
||||
if not key:
|
||||
return {"success": False, "error": "GOOGLE_MAPS_API_KEY is not set."}
|
||||
|
||||
url = "https://maps.googleapis.com/maps/api/staticmap"
|
||||
params: Dict[str, Any] = {
|
||||
"center": center_or_address,
|
||||
"zoom": zoom,
|
||||
"size": size,
|
||||
"maptype": maptype,
|
||||
"key": key,
|
||||
"format": "png",
|
||||
}
|
||||
|
||||
# Add markers
|
||||
marker_list = markers or [center_or_address]
|
||||
# Format: color:red|label:A|location
|
||||
params["markers"] = f"color:red|{marker_list[0]}"
|
||||
|
||||
try:
|
||||
resp = requests.get(url, params=params, timeout=12)
|
||||
if resp.status_code != 200:
|
||||
return {"success": False, "error": f"Static Maps API returned {resp.status_code}: {resp.text}"}
|
||||
|
||||
out_dir = ensure_media_dir()
|
||||
h = hashlib.md5(f"{center_or_address}_{zoom}_{size}".encode()).hexdigest()[:10]
|
||||
filename = f"map_{h}.png"
|
||||
filepath = os.path.join(out_dir, filename)
|
||||
|
||||
with open(filepath, "wb") as f:
|
||||
f.write(resp.content)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"center": center_or_address,
|
||||
"zoom": zoom,
|
||||
"file_path": filepath,
|
||||
"file_name": filename,
|
||||
"media_uri": f"MEDIA:{filepath}",
|
||||
"file_size_bytes": len(resp.content),
|
||||
}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": f"Static map generation failed: {str(e)}"}
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# MCP Protocol Handlers (JSON-RPC 2.0 stdio)
|
||||
# ==============================================================================
|
||||
|
||||
TOOLS_DEFINITIONS = [
|
||||
{
|
||||
"name": "maps_search_places",
|
||||
"description": "Search local places, venues, shops, restaurants, bakeries, or doctors using Google Places API (New). Returns names, addresses, live opening hours (open now?), ratings, phone numbers, and Google Maps URLs.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["query"],
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "What to search for (e.g. 'Bäckerei Stadtamhof', 'Spielplatz mit Schatten', 'Kinderarzt Prüfening').",
|
||||
},
|
||||
"location": {
|
||||
"type": "string",
|
||||
"description": "City or neighborhood to bias search around (default 'Regensburg, Germany').",
|
||||
"default": "Regensburg, Germany",
|
||||
},
|
||||
"radius_meters": {
|
||||
"type": "integer",
|
||||
"description": "Search radius in meters (default 5000).",
|
||||
"default": 5000,
|
||||
},
|
||||
"open_now": {
|
||||
"type": "boolean",
|
||||
"description": "Filter strictly to places currently open right now.",
|
||||
"default": False,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "maps_get_place_details",
|
||||
"description": "Get detailed schedule (opening hours Monday through Sunday), contact info, website, wheelchair accessibility, and recent reviews for a specific place ID.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["place_id"],
|
||||
"properties": {
|
||||
"place_id": {
|
||||
"type": "string",
|
||||
"description": "The Google place ID returned from maps_search_places.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "maps_get_directions",
|
||||
"description": "Compute route directions, travel time, and distance between two locations with traffic estimates.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["origin", "destination"],
|
||||
"properties": {
|
||||
"origin": {
|
||||
"type": "string",
|
||||
"description": "Starting address or place name (e.g. 'Grabengasse 7, 93059 Regensburg').",
|
||||
},
|
||||
"destination": {
|
||||
"type": "string",
|
||||
"description": "Target address or place name (e.g. 'Regensburg Hauptbahnhof', 'Westbad Regensburg').",
|
||||
},
|
||||
"travel_mode": {
|
||||
"type": "string",
|
||||
"description": "Mode of transit: 'DRIVE' (auto), 'TRANSIT' (bus/train), 'WALK' (foot), 'BICYCLE' (bike). Default 'DRIVE'.",
|
||||
"enum": ["DRIVE", "TRANSIT", "WALK", "BICYCLE"],
|
||||
"default": "DRIVE",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "maps_render_static_map",
|
||||
"description": "Generate a static satellite or roadmap PNG image of a location or route to send directly to Telegram as a visual photo card.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["center_or_address"],
|
||||
"properties": {
|
||||
"center_or_address": {
|
||||
"type": "string",
|
||||
"description": "Address or location to center the map on.",
|
||||
},
|
||||
"zoom": {
|
||||
"type": "integer",
|
||||
"description": "Zoom level (1-20). 15 is street/neighborhood level, 12 is city level.",
|
||||
"default": 15,
|
||||
},
|
||||
"size": {
|
||||
"type": "string",
|
||||
"description": "Image dimensions in pixels (default '600x400').",
|
||||
"default": "600x400",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def run_mcp_server():
|
||||
"""Run JSON-RPC 2.0 stdio loop."""
|
||||
while True:
|
||||
try:
|
||||
line = sys.stdin.readline()
|
||||
if not line:
|
||||
break
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
req = json.loads(line)
|
||||
req_id = req.get("id")
|
||||
method = req.get("method")
|
||||
params = req.get("params", {})
|
||||
|
||||
if method == "initialize":
|
||||
res = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
"result": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {"tools": {}},
|
||||
"serverInfo": {
|
||||
"name": "openclaw-google-maps",
|
||||
"version": "1.0.0",
|
||||
},
|
||||
},
|
||||
}
|
||||
print(json.dumps(res), flush=True)
|
||||
|
||||
elif method == "notifications/initialized":
|
||||
pass
|
||||
|
||||
elif method == "tools/list":
|
||||
res = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
"result": {"tools": TOOLS_DEFINITIONS},
|
||||
}
|
||||
print(json.dumps(res), flush=True)
|
||||
|
||||
elif method == "tools/call":
|
||||
tool_name = params.get("name")
|
||||
args = params.get("arguments", {})
|
||||
content = []
|
||||
|
||||
if tool_name == "maps_search_places":
|
||||
result = search_places(
|
||||
query=args.get("query", ""),
|
||||
location=args.get("location"),
|
||||
radius_meters=args.get("radius_meters", 5000),
|
||||
open_now=args.get("open_now", False),
|
||||
)
|
||||
content.append({"type": "text", "text": json.dumps(result, indent=2, ensure_ascii=False)})
|
||||
|
||||
elif tool_name == "maps_get_place_details":
|
||||
result = get_place_details(place_id=args.get("place_id", ""))
|
||||
content.append({"type": "text", "text": json.dumps(result, indent=2, ensure_ascii=False)})
|
||||
|
||||
elif tool_name == "maps_get_directions":
|
||||
result = get_directions(
|
||||
origin=args.get("origin", ""),
|
||||
destination=args.get("destination", ""),
|
||||
travel_mode=args.get("travel_mode", "DRIVE"),
|
||||
)
|
||||
content.append({"type": "text", "text": json.dumps(result, indent=2, ensure_ascii=False)})
|
||||
|
||||
elif tool_name == "maps_render_static_map":
|
||||
result = render_static_map(
|
||||
center_or_address=args.get("center_or_address", ""),
|
||||
zoom=args.get("zoom", 15),
|
||||
size=args.get("size", "600x400"),
|
||||
)
|
||||
content.append({"type": "text", "text": json.dumps(result, indent=2, ensure_ascii=False)})
|
||||
|
||||
else:
|
||||
content.append({"type": "text", "text": f"Error: Unknown tool '{tool_name}'"})
|
||||
|
||||
res = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
"result": {"content": content},
|
||||
}
|
||||
print(json.dumps(res), flush=True)
|
||||
|
||||
else:
|
||||
res = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
"error": {"code": -32601, "message": f"Method '{method}' not found"},
|
||||
}
|
||||
print(json.dumps(res), flush=True)
|
||||
|
||||
except Exception as e:
|
||||
err_res = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": None,
|
||||
"error": {"code": -32603, "message": f"Internal error: {str(e)}"},
|
||||
}
|
||||
print(json.dumps(err_res), flush=True)
|
||||
|
||||
|
||||
def run_cli():
|
||||
"""CLI testing interface."""
|
||||
parser = argparse.ArgumentParser(description="Google Maps MCP Server & CLI Tool")
|
||||
subparsers = parser.add_subparsers(dest="command", help="Subcommand")
|
||||
|
||||
# Places
|
||||
p_places = subparsers.add_parser("places", help="Search places")
|
||||
p_places.add_argument("query", help="Search query")
|
||||
p_places.add_argument("--location", "-l", default="Regensburg, Germany", help="Location bias")
|
||||
p_places.add_argument("--radius", "-r", type=int, default=5000, help="Radius in meters")
|
||||
p_places.add_argument("--open-now", action="store_true", help="Only open now")
|
||||
|
||||
# Details
|
||||
p_det = subparsers.add_parser("details", help="Get place details")
|
||||
p_det.add_argument("place_id", help="Place ID")
|
||||
|
||||
# Directions
|
||||
p_dir = subparsers.add_parser("directions", help="Get route directions")
|
||||
p_dir.add_argument("origin", help="Origin address")
|
||||
p_dir.add_argument("destination", help="Destination address")
|
||||
p_dir.add_argument("--mode", "-m", choices=["DRIVE", "TRANSIT", "WALK", "BICYCLE"], default="DRIVE")
|
||||
|
||||
# Static map
|
||||
p_static = subparsers.add_parser("static", help="Render static map")
|
||||
p_static.add_argument("center", help="Center address")
|
||||
p_static.add_argument("--zoom", "-z", type=int, default=15)
|
||||
p_static.add_argument("--size", "-s", default="600x400")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command == "places":
|
||||
res = search_places(query=args.query, location=args.location, radius_meters=args.radius, open_now=args.open_now)
|
||||
print(json.dumps(res, indent=2, ensure_ascii=False))
|
||||
elif args.command == "details":
|
||||
res = get_place_details(place_id=args.place_id)
|
||||
print(json.dumps(res, indent=2, ensure_ascii=False))
|
||||
elif args.command == "directions":
|
||||
res = get_directions(origin=args.origin, destination=args.destination, travel_mode=args.mode)
|
||||
print(json.dumps(res, indent=2, ensure_ascii=False))
|
||||
elif args.command == "static":
|
||||
res = render_static_map(center_or_address=args.center, zoom=args.zoom, size=args.size)
|
||||
print(json.dumps(res, indent=2, ensure_ascii=False))
|
||||
else:
|
||||
parser.print_help()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) == 1 or (len(sys.argv) == 2 and sys.argv[1] == "--stdio"):
|
||||
run_mcp_server()
|
||||
else:
|
||||
run_cli()
|
||||
@@ -0,0 +1,97 @@
|
||||
---
|
||||
name: grill-me
|
||||
description: Relentless, structured interview to stress-test and sharpen plans, architectures, decisions, or ideas before writing code. Maps decisions into a dependency tree and walks the frontier in focused rounds with recommended answers.
|
||||
compatibility: Claude Code, OpenClaw, Antigravity (agy), Gemini CLI
|
||||
---
|
||||
|
||||
# Grill-Me Skill 🎯🔥
|
||||
|
||||
Transform from a passive assistant into a relentless, high-bandwidth interviewer. Your mission is to stress-test the user's plan, surface hidden assumptions, uncover neglected edge cases, and resolve unmade decisions **before** any implementation begins.
|
||||
|
||||
---
|
||||
|
||||
## When to Activate
|
||||
|
||||
Trigger this skill whenever:
|
||||
- The user invokes `/grill-me`, `/grilling`, or asks to "grill me", "stress test this plan", "grill mich", or "hinterfrage meinen Plan".
|
||||
- The user presents a loose, ambiguous, or high-stakes architectural plan, feature idea, or migration and wants clarity before proceeding.
|
||||
|
||||
---
|
||||
|
||||
## Core Principles
|
||||
|
||||
### 1. The Design Tree & The Frontier
|
||||
- Treat any proposal as a **decision tree**: root choices branch into downstream dependencies.
|
||||
- Identify the **frontier**: the subset of decisions whose prerequisites are already settled. Only ask questions at the frontier—never ask questions that hinge on answers not yet determined.
|
||||
- As the user answers, recompute the frontier. Decisions made push the frontier outward and unblock downstream branches.
|
||||
|
||||
### 2. Facts vs. Decisions
|
||||
- **Finding facts is YOUR job, never the user's.**
|
||||
- If a question can be answered by exploring the workspace, inspecting git commits, checking configuration files, querying APIs, or searching docs, **do that exploration first**.
|
||||
- Never interrupt or ask the user for information you could discover yourself.
|
||||
- **Making decisions is the USER's job.**
|
||||
- When a fundamental architectural choice, trade-off, or priority is at stake, put the question clearly to the user.
|
||||
|
||||
### 3. Always Provide a Recommended Answer (`➡️`)
|
||||
- Never leave a question as an open-ended blank canvas.
|
||||
- For every question, include a concrete, reasoned recommendation (`➡️`) reflecting engineering best practices (simplicity, reversibility, maintainability, performance).
|
||||
- The user can then agree with a single keystroke/number, pick an alternative, or provide counter-arguments.
|
||||
|
||||
### 4. Anti-Sycophancy & Pushing Back
|
||||
- **Do not nod along.** A grilling session with zero disagreement is a failed session.
|
||||
- Challenge hand-waving, vague requirements, and "TBDs".
|
||||
- If the user provides a contradictory or overly complex answer, actively challenge the trade-off.
|
||||
- If the user says "I don't know", treat it as a signal: suggest a safe default, or flag the item as a candidate for a quick spike/prototype.
|
||||
|
||||
### 5. Identify "Ungrillable" Questions
|
||||
- Some questions cannot be resolved through dialogue alone (e.g. subjective UX feel, exact latency under real traffic, animation timing).
|
||||
- When encountering an ungrillable question, do not circle in place: flag it explicitly as requiring a prototype/benchmark and move the rest of the tree forward.
|
||||
|
||||
---
|
||||
|
||||
## Interview Mechanics & Round Sizing
|
||||
|
||||
### Adaptive Batching (Telegram & Mobile Friendly)
|
||||
In chat and messaging interfaces (such as OpenClaw over Telegram):
|
||||
- **Limit rounds to 1–3 focused questions** at a time to prevent cognitive overload.
|
||||
- Ensure every question in the round is mutually independent (neither depends on the other).
|
||||
- Use distinct numbers so the user can reply rapidly from mobile (e.g., `1: Ja, 2: Variante B, 3: Nein weil...`).
|
||||
|
||||
### Question Format
|
||||
Format each round cleanly using the following pattern:
|
||||
|
||||
```markdown
|
||||
❓ **Q1 - [Prägnanter Titel / Question Title]**
|
||||
[Detaillierter Kontext, Optionen und Implikationen]
|
||||
|
||||
➡️ **Empfehlung:** [Konkreter Vorschlag mit technischer Begründung]
|
||||
|
||||
---
|
||||
|
||||
❓ **Q2 - [Prägnanter Titel / Question Title]**
|
||||
[Detaillierter Kontext, Optionen und Implikationen]
|
||||
|
||||
➡️ **Empfehlung:** [Konkreter Vorschlag mit technischer Begründung]
|
||||
```
|
||||
|
||||
### Formatting Rules for Messaging & Telegram
|
||||
- **NO RAW HTML:** Never use raw HTML tags (`<b>`, `<strong>`, `<a>`, `<tag>`). In Telegram and chat clients, unescaped tags break or render as literal text.
|
||||
- **Pure Markdown:** Use standard Markdown (`**bold**`, `*italic*`, `[Link-Text](url)`).
|
||||
- **Bilingual Agility:** Adapt seamlessly to the user's language (German or English). Maintain the same language the user initiated the session with.
|
||||
|
||||
---
|
||||
|
||||
## Definition of Done & Outcome Synthesis
|
||||
|
||||
The grilling session is complete **only** when:
|
||||
1. The decision frontier is empty (no unaddressed branches or hidden assumptions remain).
|
||||
2. You ask the user for explicit confirmation that a **shared understanding** has been reached.
|
||||
3. **DO NOT start implementing automatically.**
|
||||
|
||||
### Final Deliverable: The Specification Summary
|
||||
Once the user confirms shared understanding, synthesize a crisp **Specification Summary** (or Markdown ADR / Implementation Plan) containing:
|
||||
- **Core Objective & Scope Boundaries:** What is being solved, and explicitly what is out-of-scope.
|
||||
- **Decided Architecture & Key Decisions:** Key choices made during the interview with their rationale.
|
||||
- **Identified Risks & Mitigation:** Edge cases or failure modes identified and how they will be handled.
|
||||
- **Ungrillable Items / Spikes:** Any points deferred to empirical prototyping.
|
||||
- **Step-by-Step Action Plan:** Concrete, verifiable implementation steps.
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
name: "heartbeat-monitor-rules"
|
||||
description: "Store heartbeat monitor rules as a skill for session handling"
|
||||
---
|
||||
|
||||
# Heartbeat monitor rules
|
||||
|
||||
When a heartbeat monitor scratch context is provided:
|
||||
|
||||
1. Follow the scratch context as the source of truth for what to do in that turn.
|
||||
2. Do not create or change recurring tasks from heartbeat messages. Use the automations tool for recurring tasks.
|
||||
3. Do not infer or repeat old tasks from previous chats.
|
||||
4. If the heartbeat has nothing that needs attention, respond exactly: NO_REPLY.
|
||||
|
||||
This skill documents the handling rules for heartbeat messages and is triggered when a heartbeat scratch context is received.
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
name: hosting
|
||||
description: Upload and host static HTML websites, PDFs, images, recipes, and dossiers on public S3 storage (https://hosting.haumdaucher.de).
|
||||
---
|
||||
|
||||
# Public S3 File & Website Hosting 🌐
|
||||
|
||||
Publishes files and standalone static web pages to **`https://hosting.haumdaucher.de`**.
|
||||
|
||||
## When to Use
|
||||
- **Research Dossiers & Websites**: After compiling an in-depth research report or summary, build an HTML page (styled with clean CSS) and upload it so the user can view it in their browser or share it with friends.
|
||||
- **Recipes & Cooking Plans**: For Koch subagent (`agents/koch.md`) when creating shareable recipe cards or dinner menus.
|
||||
- **Documents & PDFs**: Uploading PDFs, cheat sheets, or notes.
|
||||
- **Images**: Sharing generated images, diagrams, or charts via a clean link.
|
||||
- **Link Collections**: Hosting curated bookmarks or event overviews.
|
||||
|
||||
## URL Structure & Conventions
|
||||
Artifacts are automatically named with the required pattern:
|
||||
```
|
||||
https://hosting.haumdaucher.de/{category}/{YYYYMMDD}-{slug}.{ext}
|
||||
```
|
||||
- **Categories**: `research`, `recipes`, `images`, `docs`, `shared`.
|
||||
- **Date**: Auto-generated in `YYYYMMDD` format (e.g. `20260916`).
|
||||
- **Slug**: Short, lowercase, alphanumeric hyphenated identifier (e.g. `haumdaucher-agent-framework`).
|
||||
|
||||
## CLI Execution
|
||||
|
||||
Run the publisher via `uv run`:
|
||||
|
||||
### 1. Uploading a Local File
|
||||
```bash
|
||||
uv run /home/node/mop-skills/mop-skills/skills/hosting/scripts/publish_file.py \
|
||||
--file /tmp/my-report.html \
|
||||
--category research \
|
||||
--slug ai-agent-framework
|
||||
```
|
||||
|
||||
### 2. Publishing Inline HTML Content Directly
|
||||
```bash
|
||||
uv run /home/node/mop-skills/mop-skills/skills/hosting/scripts/publish_file.py \
|
||||
--content "<!DOCTYPE html><html><head><meta charset='utf-8'><title>Rezept</title></head><body><h1>Pistazien-Eis</h1></body></html>" \
|
||||
--category recipes \
|
||||
--slug pistazien-eis \
|
||||
--ext html
|
||||
```
|
||||
|
||||
### 3. Deleting an Artifact
|
||||
```bash
|
||||
uv run /home/node/mop-skills/mop-skills/skills/hosting/scripts/publish_file.py \
|
||||
--delete "research/20260916-ai-agent-framework.html"
|
||||
```
|
||||
|
||||
## Mandatory Workflow: QR Code Offer
|
||||
Whenever you host an artifact and present the public link to the user:
|
||||
1. Present the link clearly in your message:
|
||||
```markdown
|
||||
Hier ist deine veröffentlichte Website / dein Dokument:
|
||||
🔗 https://hosting.haumdaucher.de/research/20260916-ai-agent-framework.html
|
||||
```
|
||||
2. **Immediately follow up with the `ask_user` tool** to ask whether a QR code should be generated:
|
||||
```json
|
||||
{
|
||||
"question": "Möchtest du für diesen Link einen QR-Code generieren?",
|
||||
"options": ["Ja, QR-Code erstellen", "Nein, danke"]
|
||||
}
|
||||
```
|
||||
3. If the user selects **"Ja, QR-Code erstellen"**:
|
||||
Run the `qr-code` skill:
|
||||
```bash
|
||||
uv run /home/node/mop-skills/mop-skills/skills/qr-code/scripts/generate_qr.py "https://hosting.haumdaucher.de/research/20260916-ai-agent-framework.html"
|
||||
```
|
||||
And deliver the image using `MEDIA:<path>` in your response.
|
||||
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# dependencies = [
|
||||
# "boto3>=1.34.0",
|
||||
# ]
|
||||
# ///
|
||||
"""
|
||||
Publish files and static HTML documents to public S3 hosting at hosting.haumdaucher.de.
|
||||
Organizes artifacts under /{category}/{YYYYMMDD}-{slug}.{ext}.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import boto3
|
||||
from botocore.client import Config
|
||||
|
||||
# Configuration defaults
|
||||
DEFAULT_ENDPOINT = os.environ.get("MINIO_ENDPOINT", "http://minio.minio.svc.cluster.local:9000")
|
||||
DEFAULT_ACCESS_KEY = os.environ.get("MINIO_ACCESS_KEY", "openclaw")
|
||||
DEFAULT_SECRET_KEY = os.environ.get("MINIO_SECRET_KEY", "5uu23t69xC2Oigf8Bky0KTlI")
|
||||
DEFAULT_BUCKET = os.environ.get("MINIO_BUCKET", "hosting")
|
||||
DEFAULT_BASE_URL = os.environ.get("HOSTING_BASE_URL", "https://hosting.haumdaucher.de").rstrip("/")
|
||||
|
||||
MIME_OVERRIDES = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".htm": "text/html; charset=utf-8",
|
||||
".md": "text/markdown; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".pdf": "application/pdf",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".webp": "image/webp",
|
||||
".svg": "image/svg+xml",
|
||||
".txt": "text/plain; charset=utf-8",
|
||||
}
|
||||
|
||||
def get_s3_client():
|
||||
if not DEFAULT_SECRET_KEY:
|
||||
raise ValueError("MINIO_SECRET_KEY environment variable is not configured.")
|
||||
|
||||
return boto3.client(
|
||||
"s3",
|
||||
endpoint_url=DEFAULT_ENDPOINT,
|
||||
aws_access_key_id=DEFAULT_ACCESS_KEY,
|
||||
aws_secret_access_key=DEFAULT_SECRET_KEY,
|
||||
config=Config(signature_version="s3v4"),
|
||||
region_name="us-east-1",
|
||||
)
|
||||
|
||||
def slugify(text: str) -> str:
|
||||
"""Converts raw string to URL-safe slug."""
|
||||
text = text.strip().lower()
|
||||
text = re.sub(r"[^\w\s-]", "", text)
|
||||
text = re.sub(r"[\s_-]+", "-", text)
|
||||
return text.strip("-") or "document"
|
||||
|
||||
def detect_mime(ext: str) -> str:
|
||||
ext_lower = ext.lower()
|
||||
if ext_lower in MIME_OVERRIDES:
|
||||
return MIME_OVERRIDES[ext_lower]
|
||||
guessed, _ = mimetypes.guess_type(f"file{ext_lower}")
|
||||
return guessed or "application/octet-stream"
|
||||
|
||||
def upload_artifact(file_path: str = None, content: str = None, category: str = "research", slug: str = None, ext: str = None) -> dict:
|
||||
s3 = get_s3_client()
|
||||
now_str = datetime.datetime.now().strftime("%Y%m%d")
|
||||
|
||||
# Determine file data and extension
|
||||
if file_path:
|
||||
p = Path(file_path)
|
||||
if not p.exists():
|
||||
raise FileNotFoundError(f"File not found: {file_path}")
|
||||
with open(p, "rb") as f:
|
||||
data = f.read()
|
||||
chosen_ext = (ext or p.suffix).lstrip(".")
|
||||
chosen_slug = slug or slugify(p.stem)
|
||||
elif content is not None:
|
||||
data = content.encode("utf-8")
|
||||
chosen_ext = (ext or "html").lstrip(".")
|
||||
chosen_slug = slug or f"doc-{datetime.datetime.now().strftime('%H%M%S')}"
|
||||
else:
|
||||
raise ValueError("Either --file or --content must be specified.")
|
||||
|
||||
chosen_slug = slugify(chosen_slug)
|
||||
category_slug = slugify(category)
|
||||
|
||||
# Construct standard object key: {category}/{YYYYMMDD}-{slug}.{ext}
|
||||
filename = f"{now_str}-{chosen_slug}.{chosen_ext}"
|
||||
object_key = f"{category_slug}/{filename}"
|
||||
|
||||
content_type = detect_mime(f".{chosen_ext}")
|
||||
|
||||
s3.put_object(
|
||||
Bucket=DEFAULT_BUCKET,
|
||||
Key=object_key,
|
||||
Body=data,
|
||||
ContentType=content_type,
|
||||
)
|
||||
|
||||
public_url = f"{DEFAULT_BASE_URL}/{object_key}"
|
||||
|
||||
return {
|
||||
"url": public_url,
|
||||
"key": object_key,
|
||||
"category": category_slug,
|
||||
"filename": filename,
|
||||
"content_type": content_type,
|
||||
"bytes": len(data),
|
||||
}
|
||||
|
||||
def delete_artifact(object_key: str):
|
||||
s3 = get_s3_client()
|
||||
s3.delete_object(Bucket=DEFAULT_BUCKET, Key=object_key)
|
||||
print(f"Deleted: {object_key}")
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Publish files to hosting.haumdaucher.de.")
|
||||
parser.add_argument("--file", "-f", help="Local file path to upload")
|
||||
parser.add_argument("--content", "-c", help="Inline text or HTML string to publish")
|
||||
parser.add_argument("--category", default="research", help="Category folder (e.g. research, recipes, images, docs)")
|
||||
parser.add_argument("--slug", "-s", help="Readable slug identifier for the document")
|
||||
parser.add_argument("--ext", help="File extension override (e.g. html, pdf, png)")
|
||||
parser.add_argument("--delete", help="Delete an object key from the hosting bucket")
|
||||
parser.add_argument("--url-only", action="store_true", help="Print only the public URL to stdout")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.delete:
|
||||
delete_artifact(args.delete)
|
||||
return
|
||||
|
||||
if not args.file and args.content is None:
|
||||
parser.error("You must provide either --file or --content.")
|
||||
|
||||
result = upload_artifact(
|
||||
file_path=args.file,
|
||||
content=args.content,
|
||||
category=args.category,
|
||||
slug=args.slug,
|
||||
ext=args.ext,
|
||||
)
|
||||
|
||||
if args.url_only:
|
||||
print(result["url"])
|
||||
else:
|
||||
print(f"URL: {result['url']}")
|
||||
print(f"KEY: {result['key']}")
|
||||
print(f"TYPE: {result['content_type']}")
|
||||
print(f"SIZE: {result['bytes']} bytes")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
name: jelly-media
|
||||
description: Seamless Jellyfin streaming and Jellyseerr media discovery & requests. Use when the user asks to search for movies/series, discover recommendations (e.g. "a cool new action movie"), get stream or direct download URLs, request media via Jellyseerr, control active playback on connected devices (TVs), or check what to resume watching.
|
||||
---
|
||||
|
||||
# 🎬 Jelly Media (Jellyfin & Jellyseerr)
|
||||
|
||||
Full integration with **Jellyfin** (media streaming, direct downloads, active sessions, resume points) and **Jellyseerr** (TMDB search, intelligent discovery, download requests).
|
||||
|
||||
## 🛠️ MCP Tools & Capabilities
|
||||
|
||||
You have access to 8 dedicated MCP tools:
|
||||
|
||||
1. `jelly_search(query, media_type)`:
|
||||
- Searches Jellyseerr and cross-references against your personal Jellyfin library.
|
||||
- Categorizes every match as:
|
||||
- 🟢 **AVAILABLE**: Ready in Jellyfin right now (includes web playback, stream, and direct download links).
|
||||
- 🟡 **PROCESSING**: Already requested in Jellyseerr and downloading/importing.
|
||||
- ⚪ **NOT_AVAILABLE**: Not in the library yet; can be requested with one tap.
|
||||
- Automatically ranks **AVAILABLE** items on top.
|
||||
|
||||
2. `jelly_discover(genre, min_rating, year, sort_by, only_available, media_type)`:
|
||||
- Natural language discovery for queries like: *"a cool new action movie with good ratings"*.
|
||||
- Filter by genre (`action`, `comedy`, `sci-fi`, `thriller`, `drama`, etc.), `min_rating` (e.g. 7.0), and `year`.
|
||||
- Prioritizes items already downloaded on Jellyfin.
|
||||
|
||||
3. `jelly_get_playback(item_id)`:
|
||||
- Returns links for a specific Jellyfin item:
|
||||
- Web client URL (opens player/details in Jellyfin web or app)
|
||||
- Direct HTTP stream URL (plays directly in VLC, Infuse, MPV, Safari)
|
||||
- Direct offline download URL (original media file for travel/offline)
|
||||
|
||||
4. `jelly_request(media_id, media_type, seasons)`:
|
||||
- Submits a request to Jellyseerr under user account `mop`.
|
||||
- Supports movies or specific TV seasons.
|
||||
|
||||
5. `jelly_list_sessions()`:
|
||||
- Lists active devices connected to Jellyfin (e.g. Living Room TV, iPad, Web browser, Apple TV).
|
||||
|
||||
6. `jelly_remote_play(session_id, item_id)`:
|
||||
- Triggers playback of a movie or episode directly on an active device.
|
||||
|
||||
7. `jelly_get_resume()`:
|
||||
- Fetches what the user is currently watching with resume percentage, minutes elapsed, and direct stream links.
|
||||
|
||||
8. `jelly_get_latest(limit)`:
|
||||
- Returns newly added movies and series in the library.
|
||||
|
||||
---
|
||||
|
||||
## 📱 Telegram Presentation Guidelines
|
||||
|
||||
When formatting recommendations or search results for Telegram:
|
||||
- **Badge Availability Clearly**:
|
||||
- 🟢 **Verfügbar** (Sofort streamen & downloaden)
|
||||
- 🟡 **In Bearbeitung** (Bereits angefordert)
|
||||
- ⚪ **Verfügbar auf Anfrage** (Kann in Jellyseerr angefordert werden)
|
||||
- **Include Direct Links**:
|
||||
- `[▶️ In Jellyfin ansehen](<web_details_url>)`
|
||||
- `[📥 Direkt-Download](<download_url>)`
|
||||
- `[📡 Stream-Link](<stream_url>)`
|
||||
- **Provide Essential Metadata**:
|
||||
- Title, Year, TMDB/Community rating (⭐), runtime, and a short 1-2 sentence synopsis.
|
||||
- **Requesting**:
|
||||
- If an item is NOT in the library, offer to request it: *"Möchtest du, dass ich das über Jellyseerr für dich anfordere?"*
|
||||
@@ -0,0 +1,786 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Jelly Media MCP Server & CLI Tool
|
||||
Integrates Jellyfin and Jellyseerr with OpenClaw via MCP stdio or direct CLI commands.
|
||||
Supports username & password authentication with automatic session management and renewal.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import ssl
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
import urllib.error
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
# Default Configurations (Can be overridden via environment variables)
|
||||
JELLYFIN_URL = os.environ.get("JELLYFIN_URL", "https://stream.paschke.dev").rstrip("/")
|
||||
JELLYFIN_USERNAME = os.environ.get("JELLYFIN_USERNAME", "mop")
|
||||
JELLYFIN_PASSWORD = os.environ.get("JELLYFIN_PASSWORD", "Hintenlinks1#")
|
||||
|
||||
JELLYSEERR_URL = os.environ.get("JELLYSEERR_URL", "https://jellyseer.paschke.dev").rstrip("/")
|
||||
JELLYSEERR_USERNAME = os.environ.get("JELLYSEERR_USERNAME", "mop")
|
||||
JELLYSEERR_PASSWORD = os.environ.get("JELLYSEERR_PASSWORD", "Hintenlinks1#")
|
||||
|
||||
CACHE_DIR = os.environ.get("JELLY_CACHE_DIR", "/tmp")
|
||||
JF_CACHE_FILE = os.path.join(CACHE_DIR, "jellyfin_session.json")
|
||||
JS_CACHE_FILE = os.path.join(CACHE_DIR, "jellyseerr_session.json")
|
||||
|
||||
# SSL Context
|
||||
SSL_CTX = ssl.create_default_context()
|
||||
|
||||
GENRE_MAP = {
|
||||
"action": 28,
|
||||
"adventure": 12,
|
||||
"animation": 16,
|
||||
"comedy": 35,
|
||||
"crime": 80,
|
||||
"documentary": 99,
|
||||
"drama": 18,
|
||||
"family": 10751,
|
||||
"fantasy": 14,
|
||||
"history": 36,
|
||||
"horror": 27,
|
||||
"music": 10402,
|
||||
"mystery": 9648,
|
||||
"romance": 10749,
|
||||
"sci-fi": 878,
|
||||
"science fiction": 878,
|
||||
"thriller": 53,
|
||||
"war": 10752,
|
||||
"western": 37,
|
||||
}
|
||||
|
||||
|
||||
class JellyClient:
|
||||
def __init__(self):
|
||||
self.jf_token: Optional[str] = None
|
||||
self.jf_user_id: Optional[str] = None
|
||||
self.jf_server_id: Optional[str] = None
|
||||
self.js_cookie: Optional[str] = None
|
||||
self.load_cache()
|
||||
|
||||
def load_cache(self):
|
||||
if os.path.exists(JF_CACHE_FILE):
|
||||
try:
|
||||
with open(JF_CACHE_FILE, "r") as f:
|
||||
data = json.load(f)
|
||||
self.jf_token = data.get("token")
|
||||
self.jf_user_id = data.get("user_id")
|
||||
self.jf_server_id = data.get("server_id")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if os.path.exists(JS_CACHE_FILE):
|
||||
try:
|
||||
with open(JS_CACHE_FILE, "r") as f:
|
||||
data = json.load(f)
|
||||
self.js_cookie = data.get("cookie")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def save_cache(self):
|
||||
try:
|
||||
with open(JF_CACHE_FILE, "w") as f:
|
||||
json.dump({
|
||||
"token": self.jf_token,
|
||||
"user_id": self.jf_user_id,
|
||||
"server_id": self.jf_server_id,
|
||||
"timestamp": time.time()
|
||||
}, f)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
with open(JS_CACHE_FILE, "w") as f:
|
||||
json.dump({
|
||||
"cookie": self.js_cookie,
|
||||
"timestamp": time.time()
|
||||
}, f)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# --- JELLYFIN AUTH & REQUESTS ---
|
||||
def auth_jellyfin(self) -> bool:
|
||||
login_url = f"{JELLYFIN_URL}/Users/AuthenticateByName"
|
||||
auth_header = "MediaBrowser Client=\"OpenClaw\", Device=\"OpenClaw\", DeviceId=\"openclaw-bot-1\", Version=\"1.0.0\""
|
||||
payload = json.dumps({"Username": JELLYFIN_USERNAME, "Pw": JELLYFIN_PASSWORD}).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
login_url,
|
||||
data=payload,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": auth_header,
|
||||
"User-Agent": "OpenClaw/1.0"
|
||||
}
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, context=SSL_CTX, timeout=15) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
self.jf_token = data.get("AccessToken")
|
||||
self.jf_user_id = data.get("User", {}).get("Id")
|
||||
self.jf_server_id = data.get("ServerId")
|
||||
self.save_cache()
|
||||
return True
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"Jellyfin auth failed: {e}\n")
|
||||
return False
|
||||
|
||||
def request_jellyfin(self, path: str, method: str = "GET", payload: Optional[Dict[str, Any]] = None) -> Any:
|
||||
if not self.jf_token or not self.jf_user_id:
|
||||
if not self.auth_jellyfin():
|
||||
raise RuntimeError("Failed to authenticate with Jellyfin")
|
||||
|
||||
url = f"{JELLYFIN_URL}{path}"
|
||||
auth_header = (
|
||||
f"MediaBrowser Client=\"OpenClaw\", Device=\"OpenClaw\", DeviceId=\"openclaw-bot-1\", "
|
||||
f"Version=\"1.0.0\", Token=\"{self.jf_token}\""
|
||||
)
|
||||
data_bytes = json.dumps(payload).encode("utf-8") if payload else None
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=data_bytes,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": auth_header,
|
||||
"X-Emby-Token": self.jf_token or "",
|
||||
"User-Agent": "OpenClaw/1.0"
|
||||
},
|
||||
method=method
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, context=SSL_CTX, timeout=15) as resp:
|
||||
body = resp.read().decode("utf-8")
|
||||
return json.loads(body) if body else {}
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 401:
|
||||
# Token expired, retry once
|
||||
if self.auth_jellyfin():
|
||||
auth_header = (
|
||||
f"MediaBrowser Client=\"OpenClaw\", Device=\"OpenClaw\", DeviceId=\"openclaw-bot-1\", "
|
||||
f"Version=\"1.0.0\", Token=\"{self.jf_token}\""
|
||||
)
|
||||
req.headers["Authorization"] = auth_header
|
||||
req.headers["X-Emby-Token"] = self.jf_token or ""
|
||||
with urllib.request.urlopen(req, context=SSL_CTX, timeout=15) as resp:
|
||||
body = resp.read().decode("utf-8")
|
||||
return json.loads(body) if body else {}
|
||||
raise
|
||||
|
||||
# --- JELLYSEERR AUTH & REQUESTS ---
|
||||
def auth_jellyseerr(self) -> bool:
|
||||
login_url = f"{JELLYSEERR_URL}/api/v1/auth/jellyfin"
|
||||
payload = json.dumps({"username": JELLYSEERR_USERNAME, "password": JELLYSEERR_PASSWORD}).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
login_url,
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/json", "User-Agent": "OpenClaw/1.0"}
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, context=SSL_CTX, timeout=15) as resp:
|
||||
cookies = resp.info().get_all("Set-Cookie") or []
|
||||
for c in cookies:
|
||||
if "connect.sid=" in c:
|
||||
self.js_cookie = c.split(";")[0]
|
||||
break
|
||||
self.save_cache()
|
||||
return bool(self.js_cookie)
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"Jellyseerr auth failed: {e}\n")
|
||||
return False
|
||||
|
||||
def request_jellyseerr(self, path: str, method: str = "GET", payload: Optional[Dict[str, Any]] = None) -> Any:
|
||||
if not self.js_cookie:
|
||||
if not self.auth_jellyseerr():
|
||||
raise RuntimeError("Failed to authenticate with Jellyseerr")
|
||||
|
||||
url = f"{JELLYSEERR_URL}{path}"
|
||||
data_bytes = json.dumps(payload).encode("utf-8") if payload else None
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Cookie": self.js_cookie or "",
|
||||
"User-Agent": "OpenClaw/1.0"
|
||||
}
|
||||
req = urllib.request.Request(url, data=data_bytes, headers=headers, method=method)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, context=SSL_CTX, timeout=15) as resp:
|
||||
body = resp.read().decode("utf-8")
|
||||
return json.loads(body) if body else {}
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code in (401, 403):
|
||||
# Session expired, retry once
|
||||
if self.auth_jellyseerr():
|
||||
req.headers["Cookie"] = self.js_cookie or ""
|
||||
with urllib.request.urlopen(req, context=SSL_CTX, timeout=15) as resp:
|
||||
body = resp.read().decode("utf-8")
|
||||
return json.loads(body) if body else {}
|
||||
raise
|
||||
|
||||
# --- HIGH LEVEL TOOLS ---
|
||||
|
||||
def search_media(self, query: str, media_type: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
"""Search Jellyseerr and cross-reference with Jellyfin library."""
|
||||
results: List[Dict[str, Any]] = []
|
||||
|
||||
# 1. Search Jellyseerr
|
||||
encoded_query = urllib.parse.quote(query)
|
||||
js_data = self.request_jellyseerr(f"/api/v1/search?query={encoded_query}")
|
||||
raw_results = js_data.get("results", [])
|
||||
|
||||
# 2. Search Jellyfin directly to match any items in library
|
||||
jf_matches = {}
|
||||
try:
|
||||
encoded_jf = urllib.parse.quote(query)
|
||||
jf_items = self.request_jellyfin(
|
||||
f"/Users/{self.jf_user_id}/Items?searchTerm={encoded_jf}&Limit=20&Recursive=true&IncludeItemTypes=Movie,Series"
|
||||
).get("Items", [])
|
||||
for item in jf_items:
|
||||
name_clean = item.get("Name", "").strip().lower()
|
||||
jf_matches[name_clean] = item
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"Jellyfin search error: {e}\n")
|
||||
|
||||
for item in raw_results:
|
||||
mtype = item.get("mediaType")
|
||||
if media_type and media_type != "all" and mtype != media_type:
|
||||
continue
|
||||
|
||||
title = item.get("title") or item.get("name") or "Unknown"
|
||||
year = (item.get("releaseDate") or item.get("firstAirDate") or "")[:4]
|
||||
rating = item.get("voteAverage")
|
||||
minfo = item.get("mediaInfo") or {}
|
||||
status_code = minfo.get("status")
|
||||
|
||||
# Check availability
|
||||
is_available = status_code == 5
|
||||
is_processing = status_code in (2, 3, 4)
|
||||
|
||||
jf_item = jf_matches.get(title.strip().lower())
|
||||
jf_item_id = None
|
||||
if jf_item:
|
||||
is_available = True
|
||||
jf_item_id = jf_item.get("Id")
|
||||
|
||||
entry: Dict[str, Any] = {
|
||||
"title": title,
|
||||
"year": year,
|
||||
"media_type": mtype,
|
||||
"tmdb_id": item.get("id"),
|
||||
"rating": rating,
|
||||
"overview": item.get("overview"),
|
||||
"status": "AVAILABLE" if is_available else ("PROCESSING" if is_processing else "NOT_AVAILABLE"),
|
||||
"poster_url": ("https://image.tmdb.org/t/p/w500" + item.get('posterPath')) if item.get('posterPath') else None,
|
||||
}
|
||||
|
||||
if is_available:
|
||||
if not jf_item_id and minfo.get("jellyfinMediaId"):
|
||||
jf_item_id = minfo.get("jellyfinMediaId")
|
||||
|
||||
if jf_item_id:
|
||||
entry["jellyfin_item_id"] = jf_item_id
|
||||
entry["playback"] = {
|
||||
"web_details_url": f"{JELLYFIN_URL}/web/index.html#!/details?id={jf_item_id}&serverId={self.jf_server_id}",
|
||||
"stream_url": f"{JELLYFIN_URL}/Videos/{jf_item_id}/stream?static=true&api_key={self.jf_token}",
|
||||
"download_url": f"{JELLYFIN_URL}/Items/{jf_item_id}/Download?api_key={self.jf_token}"
|
||||
}
|
||||
|
||||
results.append(entry)
|
||||
|
||||
# Sort: AVAILABLE first, then PROCESSING, then NOT_AVAILABLE
|
||||
status_priority = {"AVAILABLE": 0, "PROCESSING": 1, "NOT_AVAILABLE": 2}
|
||||
results.sort(key=lambda x: (status_priority.get(x["status"], 3), -(x.get("rating") or 0)))
|
||||
return results
|
||||
|
||||
def discover_media(
|
||||
self,
|
||||
genre: Optional[str] = None,
|
||||
min_rating: Optional[float] = None,
|
||||
year: Optional[int] = None,
|
||||
sort_by: str = "popularity.desc",
|
||||
only_available: bool = False,
|
||||
media_type: str = "movie"
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Discover movies or TV shows with rich filters, prioritizing available items."""
|
||||
genre_id = None
|
||||
if genre:
|
||||
genre_lower = genre.lower().strip()
|
||||
genre_id = GENRE_MAP.get(genre_lower)
|
||||
if not genre_id and genre.isdigit():
|
||||
genre_id = int(genre)
|
||||
|
||||
endpoint = "/api/v1/discover/movies" if media_type != "tv" else "/api/v1/discover/tv"
|
||||
params: List[str] = [f"sortBy={sort_by}"]
|
||||
if genre_id:
|
||||
params.append(f"genre={genre_id}")
|
||||
if year:
|
||||
params.append(f"primaryReleaseDateGte={year}-01-01")
|
||||
params.append(f"primaryReleaseDateLte={year}-12-31")
|
||||
|
||||
query_str = "&".join(params)
|
||||
js_data = self.request_jellyseerr(f"{endpoint}?{query_str}")
|
||||
raw_results = js_data.get("results", [])
|
||||
|
||||
results: List[Dict[str, Any]] = []
|
||||
for item in raw_results:
|
||||
rating = item.get("voteAverage") or 0.0
|
||||
if min_rating and rating < min_rating:
|
||||
continue
|
||||
|
||||
title = item.get("title") or item.get("name") or "Unknown"
|
||||
rel_date = item.get("releaseDate") or item.get("firstAirDate") or ""
|
||||
minfo = item.get("mediaInfo") or {}
|
||||
status_code = minfo.get("status")
|
||||
|
||||
is_available = status_code == 5
|
||||
is_processing = status_code in (2, 3, 4)
|
||||
|
||||
if only_available and not is_available:
|
||||
continue
|
||||
|
||||
entry = {
|
||||
"title": title,
|
||||
"year": rel_date[:4] if rel_date else None,
|
||||
"media_type": media_type,
|
||||
"tmdb_id": item.get("id"),
|
||||
"rating": rating,
|
||||
"overview": item.get("overview"),
|
||||
"status": "AVAILABLE" if is_available else ("PROCESSING" if is_processing else "NOT_AVAILABLE"),
|
||||
"poster_url": ("https://image.tmdb.org/t/p/w500" + item.get('posterPath')) if item.get('posterPath') else None,
|
||||
}
|
||||
|
||||
if is_available and minfo.get("jellyfinMediaId"):
|
||||
jf_id = minfo.get("jellyfinMediaId")
|
||||
entry["jellyfin_item_id"] = jf_id
|
||||
entry["playback"] = {
|
||||
"web_details_url": f"{JELLYFIN_URL}/web/index.html#!/details?id={jf_id}&serverId={self.jf_server_id}",
|
||||
"stream_url": f"{JELLYFIN_URL}/Videos/{jf_id}/stream?static=true&api_key={self.jf_token}",
|
||||
"download_url": f"{JELLYFIN_URL}/Items/{jf_id}/Download?api_key={self.jf_token}"
|
||||
}
|
||||
|
||||
results.append(entry)
|
||||
|
||||
# Sort: AVAILABLE first
|
||||
status_priority = {"AVAILABLE": 0, "PROCESSING": 1, "NOT_AVAILABLE": 2}
|
||||
results.sort(key=lambda x: (status_priority.get(x["status"], 3), -(x.get("rating") or 0)))
|
||||
return results
|
||||
|
||||
def get_playback_info(self, item_id: str) -> Dict[str, Any]:
|
||||
"""Fetch full details, web link, stream link, and download link for a Jellyfin item."""
|
||||
data = self.request_jellyfin(f"/Users/{self.jf_user_id}/Items/{item_id}")
|
||||
name = data.get("Name", "Unknown")
|
||||
year = data.get("ProductionYear")
|
||||
overview = data.get("Overview")
|
||||
media_type = data.get("Type")
|
||||
genres = data.get("Genres", [])
|
||||
community_rating = data.get("CommunityRating")
|
||||
runtime_ticks = data.get("RunTimeTicks", 0)
|
||||
runtime_mins = int(runtime_ticks / (10_000_000 * 60)) if runtime_ticks else None
|
||||
|
||||
return {
|
||||
"title": name,
|
||||
"year": year,
|
||||
"type": media_type,
|
||||
"genres": genres,
|
||||
"rating": community_rating,
|
||||
"runtime_mins": runtime_mins,
|
||||
"overview": overview,
|
||||
"jellyfin_item_id": item_id,
|
||||
"web_details_url": f"{JELLYFIN_URL}/web/index.html#!/details?id={item_id}&serverId={self.jf_server_id}",
|
||||
"stream_url": f"{JELLYFIN_URL}/Videos/{item_id}/stream?static=true&api_key={self.jf_token}",
|
||||
"download_url": f"{JELLYFIN_URL}/Items/{item_id}/Download?api_key={self.jf_token}",
|
||||
}
|
||||
|
||||
def request_media(self, media_id: int, media_type: str = "movie", seasons: Optional[List[int]] = None) -> Dict[str, Any]:
|
||||
"""Submit a request to Jellyseerr."""
|
||||
payload: Dict[str, Any] = {
|
||||
"mediaId": media_id,
|
||||
"mediaType": media_type,
|
||||
}
|
||||
if media_type == "tv":
|
||||
payload["seasons"] = seasons if seasons is not None else [1]
|
||||
|
||||
res = self.request_jellyseerr("/api/v1/request", method="POST", payload=payload)
|
||||
return {
|
||||
"success": True,
|
||||
"request_id": res.get("id"),
|
||||
"status": res.get("status"),
|
||||
"media": res.get("media"),
|
||||
"message": "Media request successfully submitted to Jellyseerr!"
|
||||
}
|
||||
|
||||
def list_sessions(self) -> List[Dict[str, Any]]:
|
||||
"""List active playback sessions and devices on Jellyfin."""
|
||||
raw_sessions = self.request_jellyfin("/Sessions")
|
||||
output = []
|
||||
for s in raw_sessions:
|
||||
np = s.get("NowPlayingItem")
|
||||
output.append({
|
||||
"session_id": s.get("Id"),
|
||||
"device_name": s.get("DeviceName"),
|
||||
"client": s.get("Client"),
|
||||
"user_name": s.get("UserName"),
|
||||
"supports_remote_control": s.get("SupportsRemoteControl", False),
|
||||
"is_active": s.get("IsActive", False),
|
||||
"now_playing": {
|
||||
"name": np.get("Name"),
|
||||
"type": np.get("Type"),
|
||||
"id": np.get("Id")
|
||||
} if np else None
|
||||
})
|
||||
return output
|
||||
|
||||
def remote_play(self, session_id: str, item_id: str) -> Dict[str, Any]:
|
||||
"""Trigger remote playback on a Jellyfin client session."""
|
||||
path = f"/Sessions/{session_id}/Play?playCommand=PlayNow&ItemIds={item_id}"
|
||||
self.request_jellyfin(path, method="POST")
|
||||
return {"success": True, "message": f"Playback triggered for item {item_id} on session {session_id}"}
|
||||
|
||||
def get_resume(self) -> List[Dict[str, Any]]:
|
||||
"""Fetch items currently in progress for the user."""
|
||||
data = self.request_jellyfin(f"/Users/{self.jf_user_id}/Items/Resume?Recursive=true")
|
||||
items = data.get("Items", [])
|
||||
output = []
|
||||
for it in items:
|
||||
ticks = it.get("UserData", {}).get("PlaybackPositionTicks", 0)
|
||||
total_ticks = it.get("RunTimeTicks", 1) or 1
|
||||
pct = round((ticks / total_ticks) * 100, 1)
|
||||
mins = round(ticks / (10_000_000 * 60), 1)
|
||||
total_mins = round(total_ticks / (10_000_000 * 60), 1)
|
||||
|
||||
iid = it.get("Id")
|
||||
output.append({
|
||||
"title": it.get("Name"),
|
||||
"series": it.get("SeriesName"),
|
||||
"type": it.get("Type"),
|
||||
"item_id": iid,
|
||||
"progress_percent": pct,
|
||||
"position_mins": mins,
|
||||
"total_mins": total_mins,
|
||||
"web_details_url": f"{JELLYFIN_URL}/web/index.html#!/details?id={iid}&serverId={self.jf_server_id}",
|
||||
"stream_url": f"{JELLYFIN_URL}/Videos/{iid}/stream?static=true&api_key={self.jf_token}"
|
||||
})
|
||||
return output
|
||||
|
||||
def get_latest(self, limit: int = 10) -> List[Dict[str, Any]]:
|
||||
"""Fetch newly added items in the Jellyfin library."""
|
||||
data = self.request_jellyfin(f"/Users/{self.jf_user_id}/Items/Latest?Limit={limit}")
|
||||
output = []
|
||||
for it in data:
|
||||
iid = it.get("Id")
|
||||
output.append({
|
||||
"title": it.get("Name"),
|
||||
"series": it.get("SeriesName"),
|
||||
"type": it.get("Type"),
|
||||
"year": it.get("ProductionYear"),
|
||||
"item_id": iid,
|
||||
"web_details_url": f"{JELLYFIN_URL}/web/index.html#!/details?id={iid}&serverId={self.jf_server_id}",
|
||||
"stream_url": f"{JELLYFIN_URL}/Videos/{iid}/stream?static=true&api_key={self.jf_token}",
|
||||
"download_url": f"{JELLYFIN_URL}/Items/{iid}/Download?api_key={self.jf_token}"
|
||||
})
|
||||
return output
|
||||
|
||||
|
||||
# --- MCP SERVER STDIO IMPLEMENTATION ---
|
||||
|
||||
MCP_TOOLS = [
|
||||
{
|
||||
"name": "jelly_search",
|
||||
"description": "Search for movies or series across Jellyseerr and Jellyfin. Returns availability status (AVAILABLE, PROCESSING, NOT_AVAILABLE) favoring existing library media with instant playback and download URLs.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "The search term, title, or keywords"},
|
||||
"media_type": {"type": "string", "enum": ["all", "movie", "tv"], "description": "Optional filter for media type", "default": "all"}
|
||||
},
|
||||
"required": ["query"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "jelly_discover",
|
||||
"description": "Discover movies or TV shows based on genre, minimum rating, release year, or popularity. Automatically checks and highlights items already available to stream on Jellyfin.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"genre": {"type": "string", "description": "Genre name (e.g. action, comedy, sci-fi, thriller, drama, animation, horror)"},
|
||||
"min_rating": {"type": "number", "description": "Minimum TMDB score out of 10 (e.g. 7.0)"},
|
||||
"year": {"type": "integer", "description": "Release year filter (e.g. 2024)"},
|
||||
"sort_by": {"type": "string", "description": "Sort order (e.g. popularity.desc, vote_average.desc)", "default": "popularity.desc"},
|
||||
"only_available": {"type": "boolean", "description": "If true, returns only items already downloaded on Jellyfin", "default": False},
|
||||
"media_type": {"type": "string", "enum": ["movie", "tv"], "description": "Type of media", "default": "movie"}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "jelly_get_playback",
|
||||
"description": "Get web details URL, direct streaming URL, and offline direct download URL for an item in the Jellyfin library.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"item_id": {"type": "string", "description": "The Jellyfin item ID"}
|
||||
},
|
||||
"required": ["item_id"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "jelly_request",
|
||||
"description": "Submit a download request for a movie or TV series in Jellyseerr on behalf of user mop.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"media_id": {"type": "integer", "description": "The TMDB ID of the movie or series"},
|
||||
"media_type": {"type": "string", "enum": ["movie", "tv"], "description": "Type of media", "default": "movie"},
|
||||
"seasons": {"type": "array", "items": {"type": "integer"}, "description": "For TV series: list of season numbers to request (e.g. [1, 2])"}
|
||||
},
|
||||
"required": ["media_id"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "jelly_list_sessions",
|
||||
"description": "List all active playback devices and sessions connected to Jellyfin (e.g. Apple TV in Living Room, iPad, Chrome Web).",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "jelly_remote_play",
|
||||
"description": "Send a command to a specific Jellyfin client session to immediately play an item.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"session_id": {"type": "string", "description": "The Jellyfin session ID of the target device"},
|
||||
"item_id": {"type": "string", "description": "The Jellyfin item ID to play"}
|
||||
},
|
||||
"required": ["session_id", "item_id"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "jelly_get_resume",
|
||||
"description": "Get in-progress movies and TV series episodes that user mop is currently watching, with timestamps and progress percentage.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "jelly_get_latest",
|
||||
"description": "Get the latest movies or series episodes newly added to the Jellyfin library.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"limit": {"type": "integer", "description": "Maximum number of items to return", "default": 10}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def run_mcp_server():
|
||||
client = JellyClient()
|
||||
|
||||
while True:
|
||||
line = sys.stdin.readline()
|
||||
if not line:
|
||||
break
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
req = json.loads(line)
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"Invalid JSON received: {e}\n")
|
||||
continue
|
||||
|
||||
msg_id = req.get("id")
|
||||
method = req.get("method")
|
||||
params = req.get("params", {})
|
||||
|
||||
if method == "initialize":
|
||||
resp = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": msg_id,
|
||||
"result": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {"tools": {}},
|
||||
"serverInfo": {"name": "jelly-media-mcp", "version": "1.0.0"}
|
||||
}
|
||||
}
|
||||
sys.stdout.write(json.dumps(resp) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
elif method == "notifications/initialized":
|
||||
pass
|
||||
|
||||
elif method == "ping":
|
||||
resp = {"jsonrpc": "2.0", "id": msg_id, "result": {}}
|
||||
sys.stdout.write(json.dumps(resp) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
elif method == "tools/list":
|
||||
resp = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": msg_id,
|
||||
"result": {"tools": MCP_TOOLS}
|
||||
}
|
||||
sys.stdout.write(json.dumps(resp) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
elif method == "tools/call":
|
||||
tool_name = params.get("name")
|
||||
args = params.get("arguments", {})
|
||||
try:
|
||||
if tool_name == "jelly_search":
|
||||
data = client.search_media(args.get("query", ""), args.get("media_type"))
|
||||
elif tool_name == "jelly_discover":
|
||||
data = client.discover_media(
|
||||
genre=args.get("genre"),
|
||||
min_rating=args.get("min_rating"),
|
||||
year=args.get("year"),
|
||||
sort_by=args.get("sort_by", "popularity.desc"),
|
||||
only_available=args.get("only_available", False),
|
||||
media_type=args.get("media_type", "movie")
|
||||
)
|
||||
elif tool_name == "jelly_get_playback":
|
||||
data = client.get_playback_info(args.get("item_id", ""))
|
||||
elif tool_name == "jelly_request":
|
||||
data = client.request_media(
|
||||
media_id=args.get("media_id"),
|
||||
media_type=args.get("media_type", "movie"),
|
||||
seasons=args.get("seasons")
|
||||
)
|
||||
elif tool_name == "jelly_list_sessions":
|
||||
data = client.list_sessions()
|
||||
elif tool_name == "jelly_remote_play":
|
||||
data = client.remote_play(args.get("session_id", ""), args.get("item_id", ""))
|
||||
elif tool_name == "jelly_get_resume":
|
||||
data = client.get_resume()
|
||||
elif tool_name == "jelly_get_latest":
|
||||
data = client.get_latest(args.get("limit", 10))
|
||||
else:
|
||||
raise ValueError(f"Unknown tool: {tool_name}")
|
||||
|
||||
text_result = json.dumps(data, indent=2, ensure_ascii=False)
|
||||
resp = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": msg_id,
|
||||
"result": {
|
||||
"content": [{"type": "text", "text": text_result}]
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
resp = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": msg_id,
|
||||
"result": {
|
||||
"isError": True,
|
||||
"content": [{"type": "text", "text": f"Error executing {tool_name}: {str(e)}"}]
|
||||
}
|
||||
}
|
||||
sys.stdout.write(json.dumps(resp) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
else:
|
||||
if msg_id is not None:
|
||||
resp = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": msg_id,
|
||||
"error": {"code": -32601, "message": f"Method not found: {method}"}
|
||||
}
|
||||
sys.stdout.write(json.dumps(resp) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def run_cli():
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description="Jellyfin & Jellyseerr Media Tool")
|
||||
subparsers = parser.add_subparsers(dest="command")
|
||||
|
||||
# Search
|
||||
sp_search = subparsers.add_parser("search", help="Search media")
|
||||
sp_search.add_argument("query", help="Search query")
|
||||
sp_search.add_argument("--type", choices=["all", "movie", "tv"], default="all")
|
||||
|
||||
# Discover
|
||||
sp_disc = subparsers.add_parser("discover", help="Discover movies/shows")
|
||||
sp_disc.add_argument("--genre", help="Genre (action, comedy, etc.)")
|
||||
sp_disc.add_argument("--min-rating", type=float, help="Minimum TMDB rating")
|
||||
sp_disc.add_argument("--year", type=int, help="Year")
|
||||
sp_disc.add_argument("--sort-by", default="popularity.desc")
|
||||
sp_disc.add_argument("--only-available", action="store_true")
|
||||
sp_disc.add_argument("--type", choices=["movie", "tv"], default="movie")
|
||||
|
||||
# Playback
|
||||
sp_play = subparsers.add_parser("playback", help="Get playback links for item ID")
|
||||
sp_play.add_argument("item_id", help="Jellyfin Item ID")
|
||||
|
||||
# Request
|
||||
sp_req = subparsers.add_parser("request", help="Request media")
|
||||
sp_req.add_argument("media_id", type=int, help="TMDB ID")
|
||||
sp_req.add_argument("--type", choices=["movie", "tv"], default="movie")
|
||||
sp_req.add_argument("--seasons", type=int, nargs="*", help="Seasons for TV")
|
||||
|
||||
# Sessions
|
||||
subparsers.add_parser("sessions", help="List active Jellyfin sessions")
|
||||
|
||||
# Remote Play
|
||||
sp_rem = subparsers.add_parser("remote-play", help="Trigger remote play")
|
||||
sp_rem.add_argument("session_id", help="Session ID")
|
||||
sp_rem.add_argument("item_id", help="Item ID")
|
||||
|
||||
# Resume
|
||||
subparsers.add_parser("resume", help="Get in-progress items")
|
||||
|
||||
# Latest
|
||||
sp_latest = subparsers.add_parser("latest", help="Get latest items")
|
||||
sp_latest.add_argument("--limit", type=int, default=10)
|
||||
|
||||
# Test
|
||||
subparsers.add_parser("test", help="Test connectivity to Jellyfin & Jellyseerr")
|
||||
|
||||
args = parser.parse_args()
|
||||
client = JellyClient()
|
||||
|
||||
if args.command == "search":
|
||||
print(json.dumps(client.search_media(args.query, args.type), indent=2, ensure_ascii=False))
|
||||
elif args.command == "discover":
|
||||
print(json.dumps(client.discover_media(
|
||||
genre=args.genre,
|
||||
min_rating=args.min_rating,
|
||||
year=args.year,
|
||||
sort_by=args.sort_by,
|
||||
only_available=args.only_available,
|
||||
media_type=args.type
|
||||
), indent=2, ensure_ascii=False))
|
||||
elif args.command == "playback":
|
||||
print(json.dumps(client.get_playback_info(args.item_id), indent=2, ensure_ascii=False))
|
||||
elif args.command == "request":
|
||||
print(json.dumps(client.request_media(args.media_id, args.type, args.seasons), indent=2, ensure_ascii=False))
|
||||
elif args.command == "sessions":
|
||||
print(json.dumps(client.list_sessions(), indent=2, ensure_ascii=False))
|
||||
elif args.command == "remote-play":
|
||||
print(json.dumps(client.remote_play(args.session_id, args.item_id), indent=2, ensure_ascii=False))
|
||||
elif args.command == "resume":
|
||||
print(json.dumps(client.get_resume(), indent=2, ensure_ascii=False))
|
||||
elif args.command == "latest":
|
||||
print(json.dumps(client.get_latest(args.limit), indent=2, ensure_ascii=False))
|
||||
elif args.command == "test":
|
||||
print("Testing Jellyfin Auth...")
|
||||
jf_ok = client.auth_jellyfin()
|
||||
print(" Jellyfin:", "[OK]" if jf_ok else "[FAILED]")
|
||||
print("Testing Jellyseerr Auth...")
|
||||
js_ok = client.auth_jellyseerr()
|
||||
print(" Jellyseerr:", "[OK]" if js_ok else "[FAILED]")
|
||||
else:
|
||||
parser.print_help()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) == 1 or (len(sys.argv) == 2 and sys.argv[1] == "--stdio"):
|
||||
run_mcp_server()
|
||||
else:
|
||||
run_cli()
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
name: markitdown
|
||||
description: Universal document and media converter powered by Microsoft MarkItDown. Converts PDF, Word (docx), Excel (xlsx), PowerPoint (pptx), HTML, CSV, audio, and images to clean, structured Markdown. Use whenever you need to read, inspect, or extract text from PDFs, spreadsheets, office documents, or web files.
|
||||
---
|
||||
|
||||
# 📄 Microsoft MarkItDown Converter
|
||||
|
||||
Convert arbitrary documents and files into clean, structured Markdown for analysis, summarization, or archiving.
|
||||
|
||||
## 🚀 Usage
|
||||
|
||||
Use `scripts/convert_to_markdown.py`. The script runs automatically via `uv` with all document dependencies bundled.
|
||||
|
||||
### 1. Convert a Remote URL (PDF, Word, etc.)
|
||||
Download and convert in one step:
|
||||
```bash
|
||||
python3 scripts/convert_to_markdown.py --url "https://example.com/document.pdf"
|
||||
```
|
||||
|
||||
To save directly to a file with frontmatter metadata:
|
||||
```bash
|
||||
python3 scripts/convert_to_markdown.py --url "https://example.com/document.pdf" --frontmatter -o /path/to/output.md
|
||||
```
|
||||
|
||||
### 2. Convert a Local File
|
||||
Convert an existing local document or an uploaded attachment:
|
||||
```bash
|
||||
python3 scripts/convert_to_markdown.py /path/to/invoice.pdf
|
||||
python3 scripts/convert_to_markdown.py /path/to/spreadsheet.xlsx
|
||||
python3 scripts/convert_to_markdown.py /path/to/presentation.pptx
|
||||
```
|
||||
|
||||
## 🛠️ Supported Formats
|
||||
* **PDF (`.pdf`)**: Extracts text, columns, formatting, and tables.
|
||||
* **Microsoft Word (`.docx`)**: Preserves headers, bold/italic, lists, and tables.
|
||||
* **Microsoft Excel (`.xlsx`)**: Converts sheets and tables into Markdown pipe tables.
|
||||
* **Microsoft PowerPoint (`.pptx`)**: Extracts slide text, outlines, and speaker notes.
|
||||
* **HTML / Web pages**: Cleans boilerplate and outputs article markdown.
|
||||
* **CSV / JSON / XML**: Formats data into readable markdown tables or code blocks.
|
||||
* **Audio (`.wav`, `.mp3`)**: Transcribes speech (when speech models are active).
|
||||
* **Images**: Extracts metadata, EXIF details, and embedded text descriptions.
|
||||
|
||||
## 💡 Best Practices for OpenClaw
|
||||
- When the user sends a PDF or document link in Telegram, call `convert_to_markdown.py --url <URL>` to read its contents before answering.
|
||||
- For large documents (>50 pages), save the converted Markdown to a temporary file and search/filter for relevant sections.
|
||||
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# dependencies = [
|
||||
# "markitdown[all]>=0.0.1a4",
|
||||
# "requests>=2.31.0",
|
||||
# ]
|
||||
# ///
|
||||
"""
|
||||
Universal Document to Markdown Converter
|
||||
Powered by Microsoft MarkItDown.
|
||||
|
||||
Supports PDF, DOCX, XLSX, PPTX, HTML, CSV, TXT, JSON, XML, images, and audio.
|
||||
Can take a local file path, a remote URL, or stdin.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.parse
|
||||
|
||||
def ensure_dependencies_and_run():
|
||||
"""If markitdown is not installed in the current Python env, re-exec via uv run if available."""
|
||||
try:
|
||||
import markitdown # noqa: F401
|
||||
import requests # noqa: F401
|
||||
except ImportError:
|
||||
uv_path = shutil.which("uv")
|
||||
if uv_path and os.environ.get("_MARKITDOWN_REEXEC") != "1":
|
||||
env = os.environ.copy()
|
||||
env["_MARKITDOWN_REEXEC"] = "1"
|
||||
cmd = [uv_path, "run", os.path.abspath(__file__), *sys.argv[1:]]
|
||||
try:
|
||||
res = subprocess.run(cmd, env=env)
|
||||
sys.exit(res.returncode)
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"Failed to re-execute via uv: {e}\n")
|
||||
sys.stderr.write("Error: 'markitdown[all]' or 'requests' not found. Please install with:\n")
|
||||
sys.stderr.write(" uv pip install 'markitdown[all]' requests\n")
|
||||
sys.exit(1)
|
||||
|
||||
ensure_dependencies_and_run()
|
||||
|
||||
from markitdown import MarkItDown
|
||||
import requests
|
||||
|
||||
def download_url(url: str, output_dir: str) -> str:
|
||||
"""Download a remote URL to a temporary local file, guessing the filename from URL or headers."""
|
||||
headers = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
}
|
||||
resp = requests.get(url, headers=headers, stream=True, timeout=60)
|
||||
resp.raise_for_status()
|
||||
|
||||
# Determine filename
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
basename = os.path.basename(parsed.path) or "downloaded_file"
|
||||
if not os.path.splitext(basename)[1]:
|
||||
ct = resp.headers.get("Content-Type", "")
|
||||
if "pdf" in ct:
|
||||
basename += ".pdf"
|
||||
elif "word" in ct or "docx" in ct:
|
||||
basename += ".docx"
|
||||
elif "sheet" in ct or "excel" in ct or "xlsx" in ct:
|
||||
basename += ".xlsx"
|
||||
elif "html" in ct:
|
||||
basename += ".html"
|
||||
|
||||
local_path = os.path.join(output_dir, basename)
|
||||
with open(local_path, "wb") as f:
|
||||
for chunk in resp.iter_content(chunk_size=65536):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
return local_path
|
||||
|
||||
def convert_file(path: str) -> str:
|
||||
"""Convert local file to Markdown text using MarkItDown."""
|
||||
md = MarkItDown()
|
||||
result = md.convert(path)
|
||||
return result.text_content
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Convert documents (PDF, Office, HTML, images) to Markdown via Microsoft MarkItDown"
|
||||
)
|
||||
parser.add_argument("file", nargs="?", help="Local path to the document file")
|
||||
parser.add_argument("--url", "-u", help="Remote URL of the document to download and convert")
|
||||
parser.add_argument("--output", "-o", help="Optional output file path (defaults to stdout)")
|
||||
parser.add_argument("--frontmatter", action="store_true", help="Add YAML frontmatter with file metadata")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.file and not args.url:
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
temp_dir = None
|
||||
target_file = args.file
|
||||
|
||||
try:
|
||||
if args.url:
|
||||
temp_dir = tempfile.mkdtemp(prefix="markitdown_")
|
||||
target_file = download_url(args.url, temp_dir)
|
||||
|
||||
if not target_file or not os.path.exists(target_file):
|
||||
sys.stderr.write(f"Error: File not found: {target_file}\n")
|
||||
sys.exit(1)
|
||||
|
||||
content = convert_file(target_file)
|
||||
|
||||
if args.frontmatter:
|
||||
meta = [
|
||||
"---",
|
||||
f"source_file: {os.path.basename(target_file)}",
|
||||
f"original_url: {args.url or ''}",
|
||||
f"size_bytes: {os.path.getsize(target_file)}",
|
||||
"converted_by: microsoft-markitdown",
|
||||
"---",
|
||||
"",
|
||||
]
|
||||
content = "\n".join(meta) + content
|
||||
|
||||
if args.output:
|
||||
os.makedirs(os.path.dirname(os.path.abspath(args.output)), exist_ok=True)
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
print(f"Successfully converted to {args.output}")
|
||||
else:
|
||||
print(content)
|
||||
|
||||
finally:
|
||||
if temp_dir and os.path.exists(temp_dir):
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
name: "mcp-server-debugging"
|
||||
description: "MCP server tools missing or fails to start (\"Connection closed\"): reproduce via stdio client, pin mcp<2, fix writable HOME/PATH."
|
||||
---
|
||||
|
||||
# MCP Server Debugging
|
||||
|
||||
Diagnose and fix OpenClaw MCP servers whose tools never appear or that crash on startup.
|
||||
|
||||
## Trigger
|
||||
- A configured MCP server's tools are absent from the session, or a stdio MCP client fails `initialize` with `Connection closed`.
|
||||
- A newly added MCP server does not show up as `<name>__*` tools.
|
||||
|
||||
## Steps
|
||||
1. Read the server definition: call `gateway` with `action=config.get`, `path=mcp`; note `command`, `args`, `env`, `cwd` for the failing server.
|
||||
2. Reproduce and capture the real error: run the configured command directly with stdin from `/dev/null` (e.g. `timeout 60 python3 <launcher> < /dev/null 2>&1 | head -40`). Completion: stderr shows the underlying failure, not only the client-side "Connection closed".
|
||||
3. Map the error to the fix:
|
||||
- `ModuleNotFoundError: No module named 'mcp.server.fastmcp'` → the runner pulled **mcp 2.x**, where `FastMCP` was renamed to `MCPServer`. Fix: pin 1.x, e.g. run `uvx --with "mcp<2" <package>`, or add the pin inside the launcher's `os.execvp` call.
|
||||
- `Read-only file system` on a home/credentials path (launcher writes to `Path.home()/.<name>`) → `HOME` is read-only. Fix: point `HOME` to a writable dir (e.g. `/tmp`) or change the written path.
|
||||
- `FileNotFoundError: uvx` → prepend the uvx directory (normally `/home/openclaw/.openclaw/bin`) to `PATH`.
|
||||
4. Verify end-to-end with a minimal stdio client before declaring success: spawn the fixed command, `initialize`, `list_tools`, then call the target tool and confirm real data returns. Use `uv run --with mcp --python 3.12 python <client.py>` where the client uses `mcp.client.stdio.stdio_client` + `ClientSession`, passing `env` with the corrected `HOME`/`PATH`.
|
||||
5. Route config changes through the `openclaw` tool (human approval required); never edit gateway config files directly. Report the exact old → new command.
|
||||
|
||||
## Tool calls fail with argument validation errors
|
||||
- An MCP tool returning a pydantic `Unexpected keyword argument` error means the argument names are wrong for that server, not that the server is down: read the real schema with `tool_describe` and retry with its exact parameters.
|
||||
- Home Assistant MCP: `ha_call_service` takes `{domain, service, entity_id, data}` (not `service_data`/`target`); `ha_search_entities` takes `{query}` only (no `domain`). A successful call reports `success: true` in `structuredContent`.
|
||||
- A type/filter variant that errors with `'NoneType' object has no attribute 'get'` (e.g. Spotify MCP `types=playlist`) → switch to a working type (`types=track` or `album`), act on that URI, and report the server bug instead of retrying the same call.
|
||||
|
||||
## Pitfalls
|
||||
- A session only exposes tools loaded at session start; a newly added server's tools appear as `<name>__*` only after a gateway reload.
|
||||
- Test with the same `HOME`/`PATH` the gateway uses, otherwise you validate the wrong environment.
|
||||
- Never print secret values; check presence only (`[ -n "${VAR}" ]`).
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
name: memobird
|
||||
description: Print text, images, and notes directly to the Memobird thermal printer. Use this when the user wants to print a physical note, a shopping list, or a small image to the Memobird at its local IP.
|
||||
---
|
||||
|
||||
# 🐦 Memobird Printer
|
||||
|
||||
Direct printing to the Memobird thermal printer on the local network.
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
This skill uses [uv](https://docs.astral.sh/uv/) for dependency management. No manual installation of Python packages is required as dependencies are declared inline within the script.
|
||||
|
||||
Ensure you have `uv` installed on your system.
|
||||
|
||||
## 🚀 Usage
|
||||
|
||||
Use `uv run` to execute the script. `uv` will automatically create a temporary environment and install the required dependencies (`requests`, `Pillow`).
|
||||
|
||||
### 1. Print simple text
|
||||
```bash
|
||||
uv run skills/memobird/scripts/memobird_print.py --text "Hello from Gemini!"
|
||||
```
|
||||
|
||||
### 2. Print styled text
|
||||
```bash
|
||||
uv run skills/memobird/scripts/memobird_print.py --text "IMPORTANT NOTE" --big --bold
|
||||
```
|
||||
|
||||
### 3. Print an image
|
||||
```bash
|
||||
uv run skills/memobird/scripts/memobird_print.py --image "path/to/image.png"
|
||||
```
|
||||
|
||||
### 4. Print a separator line
|
||||
```bash
|
||||
uv run skills/memobird/scripts/memobird_print.py --line THICK
|
||||
```
|
||||
|
||||
## 🛠️ Options
|
||||
- `--text`: The string to print.
|
||||
- `--image`: Path to a local image file (converted to 1-bit monochrome automatically).
|
||||
- `--ip`: Override the default IP (192.168.10.165).
|
||||
- `--big`, `--bold`, `--underline`: Text styling.
|
||||
- `--line`: Print a line (`THICK`, `THIN`, or `DASH`).
|
||||
|
||||
## 💡 Tips
|
||||
- **Character Limit:** Thermal paper is narrow (384px wide). Long lines of text will wrap.
|
||||
- **Image Size:** Large images might fail if they take too long to transfer. Keep them reasonably sized.
|
||||
- **Connection:** Ensure the printer is powered on and connected to the same WiFi network.
|
||||
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# dependencies = [
|
||||
# "requests",
|
||||
# "pillow",
|
||||
# ]
|
||||
# ///
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import base64
|
||||
from io import BytesIO
|
||||
from random import randint
|
||||
import argparse
|
||||
import requests
|
||||
from PIL import Image as PILImg, ImageOps
|
||||
|
||||
# Default IP for Moritz's Memobird
|
||||
DEFAULT_HOST = "192.168.10.165"
|
||||
|
||||
def to_byte_array(img):
|
||||
imgByteArr = BytesIO()
|
||||
img.save(imgByteArr, format='bmp')
|
||||
return imgByteArr.getvalue()
|
||||
|
||||
class MemobirdSender:
|
||||
def __init__(self, host=DEFAULT_HOST, port='80'):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.uri = f"http://{self.host}:{self.port}/sys/printer"
|
||||
|
||||
def send(self, text_list, print_id=None):
|
||||
print_id = print_id or randint(1, 10**6)
|
||||
payload = {
|
||||
'command': 3,
|
||||
'content': {
|
||||
'textList': text_list
|
||||
},
|
||||
'encryptFlag': 0,
|
||||
'hasHead': 0,
|
||||
'hasSignature': 0,
|
||||
'hasTail': 0,
|
||||
'isFromDirectPrint': False,
|
||||
'msgType': 1,
|
||||
'pkgCount': 1,
|
||||
'pkgNo': 1,
|
||||
'printID': print_id,
|
||||
'priority': 0,
|
||||
'result': 0,
|
||||
'scripType': 3
|
||||
}
|
||||
|
||||
try:
|
||||
resp = requests.post(self.uri, json=payload, timeout=10)
|
||||
resp.raise_for_status()
|
||||
return resp.status_code
|
||||
except requests.exceptions.ConnectTimeout:
|
||||
print(f"Error: Connection to Memobird at {self.host} timed out.")
|
||||
print("Check if the printer is powered on and on the same network.")
|
||||
sys.exit(1)
|
||||
except requests.exceptions.ConnectionError:
|
||||
print(f"Error: Could not connect to Memobird at {self.host}.")
|
||||
print("Verify the IP address and network connectivity.")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"Error sending to Memobird: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
def create_text_element(text, big=False, bold=False, underline=False):
|
||||
if not text.endswith('\n'):
|
||||
text += '\n'
|
||||
return {
|
||||
'encodeType': 0,
|
||||
'printType': 1,
|
||||
'basetext': base64.b64encode(text.encode("GBK")).decode(),
|
||||
'fontSize': 1 + big,
|
||||
'bold': 1 * bold,
|
||||
'underline': 1 * underline
|
||||
}
|
||||
|
||||
def create_image_element(path):
|
||||
img = PILImg.open(path)
|
||||
img = img.convert(mode='L')
|
||||
img = ImageOps.flip(img)
|
||||
img = img.convert(mode='1')
|
||||
return {
|
||||
'encodeType': 0,
|
||||
'printType': 5,
|
||||
'basetext': base64.b64encode(to_byte_array(img)).decode()
|
||||
}
|
||||
|
||||
def create_line_element(linetype='THICK'):
|
||||
codes = {'THICK': 41, 'THIN': 42, 'DASH': 43}
|
||||
return {
|
||||
'encodeType': 0,
|
||||
'printType': 4,
|
||||
'iconID': codes.get(linetype, 41)
|
||||
}
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Print to Memobird")
|
||||
parser.add_argument("--text", help="Text to print")
|
||||
parser.add_argument("--image", help="Path to image to print")
|
||||
parser.add_argument("--ip", default=DEFAULT_HOST, help=f"Memobird IP (default: {DEFAULT_HOST})")
|
||||
parser.add_argument("--big", action="store_true", help="Use big font")
|
||||
parser.add_argument("--bold", action="store_true", help="Use bold font")
|
||||
parser.add_argument("--underline", action="store_true", help="Use underline")
|
||||
parser.add_argument("--line", choices=['THICK', 'THIN', 'DASH'], help="Print a separator line")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.text and not args.image and not args.line:
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
sender = MemobirdSender(host=args.ip)
|
||||
elements = []
|
||||
|
||||
if args.line:
|
||||
elements.append(create_line_element(args.line))
|
||||
|
||||
if args.text:
|
||||
elements.append(create_text_element(args.text, big=args.big, bold=args.bold, underline=args.underline))
|
||||
|
||||
if args.image:
|
||||
if os.path.exists(args.image):
|
||||
elements.append(create_image_element(args.image))
|
||||
else:
|
||||
print(f"Error: Image not found: {args.image}")
|
||||
sys.exit(1)
|
||||
|
||||
status = sender.send(elements)
|
||||
print(f"Success! Printer returned {status}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
name: "memory-wiki-synthesis"
|
||||
description: "wiki_apply create_synthesis fails without sourceIds; create a source page first, then file the synthesis"
|
||||
---
|
||||
|
||||
# Filing a Memory-Wiki Synthesis
|
||||
|
||||
Use when filing a research dossier or synthesis into the memory wiki via `wiki_apply` with `op: "create_synthesis"`.
|
||||
|
||||
1. Call `wiki_apply` with `op: "create_synthesis"`, `title`, and `body`. If it returns `wiki mutation requires at least one sourceId for create_synthesis`, do not retry blind. Completion: the error names the missing `sourceIds`.
|
||||
2. Create a source page first. Read the vault path from `wiki_status` (`vaultPath`, e.g. `/home/openclaw/.openclaw/wiki/main`), then write `sources/YYYY-MM-DD-<slug>.md` with frontmatter `type: Source`, a unique `id: <slug>`, `title`, and a `sources:` list of the URLs used. Completion: the file exists and carries an `id`.
|
||||
3. Re-run `wiki_apply` `op: "create_synthesis"` passing `sourceIds: ["<slug>"]` (the source page `id`) plus `title`, `body`, and any `questions`/`confidence`. Completion: the response reports the new `syntheses/...` page path and refreshes the index files.
|
||||
4. Run `wiki_lint` after the update. Completion: lint returns; expect a large pre-existing baseline of structural warnings, so only act on issues for the pages just written.
|
||||
@@ -1,20 +1,37 @@
|
||||
---
|
||||
name: nerd-report
|
||||
description: Generiert personalisierte Morgen-Reports für Moritz Graf in Regensburg. Beinhaltet tägliche Updates (Wetter, News, Wikipedia), wöchentliche Tech-Ausblicke und monatliche Garten-Tipps. Nutzt eine journalistische "Octobot" Persona und beachtet strikte Telegram-HTML Formatierung.
|
||||
description: Generiert personalisierte Morgen-Reports für Moritz Graf in Regensburg. Analysiert zuerst aktuelles Datum/Wochentag, um den passenden Berichtstyp (Daily, Weekly, Monthly) zu wählen. Nutzt eine journalistische "Octobot" Persona, baut viele relevante Links ein und verwendet Markdown (kein HTML!) zur optimalen Darstellung in Telegram.
|
||||
---
|
||||
|
||||
# Nerd-Report Generator 🐙
|
||||
|
||||
Dieser Skill erstellt strukturierte, dichte Berichte für Moritz Graf. Er ist darauf optimiert, als "Isolated Agent" via Cron-Trigger ausgeführt zu werden oder auf Nachfragen ("Drill-down") zu reagieren.
|
||||
|
||||
## Kernfunktionen
|
||||
1. **Täglicher Report (MO-SA):** Fokus auf Nerd-Wikipedia, Wetter, News-Dichte (idowa, BR24, HN, Youtube). Montags inkl. Sonntags-Catchup.
|
||||
2. **Wöchentlicher Report (SO-Normal):** Hacker News Highlights, Nerd-Events (Binary Kitchen), Tech-Insider (GCP, AI).
|
||||
3. **Monatlicher Report (1. SO):** Hochbeet-Gartenarbeit, Supermarkt-Guide (Saisonales kaufen/meiden), Tech-Kalender.
|
||||
## Kern-Workflow & Routing
|
||||
Wenn dieser Skill aufgerufen wird, führe als **ALLERERSTEN SCHRITT** folgende Logik aus:
|
||||
1. **Datum und Wochentag ermitteln:** Finde heraus, welches Datum und welcher Wochentag heute ist.
|
||||
2. **Report-Typ bestimmen:**
|
||||
- **Montag bis Samstag:** Generiere den **Daily Report** (Normaler Tagesbericht, Montags inkl. Sonntags-Catchup).
|
||||
- **Sonntag:** Prüfe, ob es der _erste Sonntag im Monat_ ist.
|
||||
- Wenn JA: Generiere den **Monthly Report**.
|
||||
- Wenn NEIN: Generiere den **Weekly Report**.
|
||||
|
||||
*(Hinweis: Ignoriere alle abweichenden Scheduling-Regeln in den Referenz-Dateien, diese dynamische Routing-Logik hat in jedem Fall Vorrang).*
|
||||
|
||||
## Report-Typen
|
||||
1. **Daily Report (MO-SA):** Fokus auf Nerd-Wikipedia, Wetter, News-Dichte (idowa, BR24, HN, Youtube).
|
||||
2. **Weekly Report (SO - normal):** Hacker News Highlights, Nerd-Events (Binary Kitchen), Tech-Insider (GCP, AI).
|
||||
3. **Monthly Report (SO - 1. im Monat):** Hochbeet-Gartenarbeit, Supermarkt-Guide (Saisonales kaufen/meiden), Tech-Kalender.
|
||||
4. **Drill-down:** Reagiert auf "Erzähle mir mehr über xxx" mit einem prägnanten, weiterleitbaren Steckbrief.
|
||||
|
||||
## Formatierung für Telegram (WICHTIG!)
|
||||
- **KEIN HTML:** Verwende **NIEMALS** rohe HTML-Tags wie `<b>`, `<strong>`, `<i>` oder `<a>`. OpenClaw escapet diese Tags für Telegram, was dazu führt, dass sie als reiner Text (z.B. `<b>Text</b>`) in der Chat-Bubble angezeigt werden!
|
||||
- **Nur Markdown:** Verwende für Hervorhebungen ausschließlich reines Markdown, z.B. `**fetter Text**` und `_kursiver Text_`.
|
||||
- **Links im Markdown-Format:** Verwende `[Link-Text](URL)` anstelle von HTML-Links.
|
||||
- **Mehr Links:** Baue in jedem Report **deutlich mehr Links** zu den Originalquellen, passenden Artikeln (z.B. Wikipedia) oder GitHub-Projekten ein! Je mehr nützliche Querverweise, desto besser.
|
||||
- **Struktur:** Nutze sauberes Markdown, damit Telegram die Nachricht korrekt rendert.
|
||||
|
||||
## Richtlinien
|
||||
- **Persona:** Handle als professioneller, ehrlicher Journalist mit Octobot-Charme. Siehe [persona.md](references/persona.md).
|
||||
- **Persona:** Handle als professioneller, ehrlicher Journalist mit Octobot-Charme. Siehe `references/persona.md`.
|
||||
- **Fokus:** Immer nerdy & techy. Priorisiere GCP, AI (OpenAI, Anthropic) und Hacker News.
|
||||
- **Formatierung:** Strikte Telegram-HTML Regeln (Keine Header, keine Listen). Kompakte Links via `<a>`.
|
||||
- **Workflow:** Siehe [daily.md](references/daily.md) und [weekly_monthly.md](references/weekly_monthly.md).
|
||||
- **Details zu Inhalten:** Siehe `references/daily.md` und `references/weekly_monthly.md`.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
- **Immer nerdy/techy!** (Wissenschaftler, Erfinder, IT-Durchbrüche). Keinen "Mainstream" ohne Tech-Bezug.
|
||||
2. **Weather (BrightSky):**
|
||||
- Location: Grabengasse 7, 93059 Regensburg.
|
||||
- Erstelle eine Text-Tabelle im `<pre>` Block für die Intervalle: 6-9, 9-12, 12-15, 15-18 Uhr.
|
||||
- Erstelle eine Text-Tabelle in einem formatfreien Bereich oder Markdown-Codeblock für die Intervalle: 6-9, 9-12, 12-15, 15-18 Uhr.
|
||||
3. **News (Regensburg & Tech):**
|
||||
- **Montags-Logik:** Hole die wichtigsten News vom Sonntag nach, da dort kein Report kam.
|
||||
- Scanne `idowa.de`, `BR24`, `Hacker News` und `Youtube`.
|
||||
@@ -13,31 +13,31 @@
|
||||
|
||||
## Drill-down Feature ("Erzähle mir mehr")
|
||||
Falls Moritz fragt: "Erzähle mir mehr über xxx", antworte SOFORT mit einem prägnanten Steckbrief:
|
||||
- **Titel:** <b>[NAME/THEMA]</b>
|
||||
- **Titel:** **[NAME/THEMA]**
|
||||
- **Kerninfo:** 2-3 Sätze, was das ist und warum es relevant ist.
|
||||
- **Deep Dive:** Weiterführende Links für mehr Details.
|
||||
- **Format:** Direkt weiterleitbar (Telegram HTML).
|
||||
- **Format:** Direkt weiterleitbar (nur Markdown `[Text](URL)` - kein HTML!).
|
||||
|
||||
## Format-Vorlage (Telegram HTML)
|
||||
```html
|
||||
<b>### Morgenreport (Wochentag, Datum) ###</b>
|
||||
## Format-Vorlage (Telegram Markdown)
|
||||
```markdown
|
||||
**### Morgenreport (Wochentag, Datum) ###**
|
||||
|
||||
<b>📰 Person des Tages (Nerdy Edition):</b>
|
||||
<a href="URL">Name</a>, Begründung (Fokus: Tech/Wissenschaft)
|
||||
**📰 Person des Tages (Nerdy Edition):**
|
||||
[Name](URL), Begründung (Fokus: Tech/Wissenschaft)
|
||||
|
||||
<b>🌡️ Wetterbericht für Regensburg:</b>
|
||||
<pre>
|
||||
**🌡️ Wetterbericht für Regensburg:**
|
||||
```text
|
||||
| Zeit | 6-9 | 9-12 | 12-15 | 15-18 |
|
||||
|-------|-----|------|-------|-------|
|
||||
| Temp | 12° | 16° | 18° | 10° |
|
||||
| Wetter| 🌧️ | ☀️ | ☁️ | ☁️ |
|
||||
</pre>
|
||||
```
|
||||
|
||||
<b>💡 Besonderheit des Tages:</b>
|
||||
<a href="URL">Artikel-Titel</a>: Prägnanter Tech-Fokus.
|
||||
**💡 Besonderheit des Tages:**
|
||||
[Artikel-Titel](URL): Prägnanter Tech-Fokus.
|
||||
|
||||
<b>📣 News Feed (Concise & Dense):</b>
|
||||
1. <a href="URL">Titel</a>: Kurz-Teaser.
|
||||
2. <a href="URL">Titel</a>: Kurz-Teaser.
|
||||
**📣 News Feed (Concise & Dense):**
|
||||
1. [Titel](URL): Kurz-Teaser.
|
||||
2. [Titel](URL): Kurz-Teaser.
|
||||
... (bis zu 5-7 News, wenn relevant)
|
||||
```
|
||||
|
||||
@@ -12,6 +12,6 @@ Du bist ein hochspezialisierter KI-Journalist, der exklusiv für Moritz Graf arb
|
||||
|
||||
## Richtlinien für die Kommunikation
|
||||
- Nutze Emojis passend zum Thema (🐙, 💻, 🌿, 🌤️, 🏛️).
|
||||
- Nutze kompakte HTML-Links `<a>Klick</a>` um den Lesefluss nicht zu stören.
|
||||
- Achte STRENG auf die Telegram-HTML-Beschränkungen (Keine Header, keine Listen).
|
||||
- Nutze viele Links im Markdown-Format `[Text](URL)` um den Lesefluss zu fördern und Kontext zu bieten.
|
||||
- Verwende **KEIN ROHES HTML** (kein `<b>`, kein `<a>`). Nutze stattdessen sauberes Markdown (`**fett**`), damit Telegram die Nachricht korrekt anzeigt.
|
||||
- Bei Nachfragen ("Erzähle mir mehr...") wechselst du in den **Steckbrief-Modus** (siehe `daily.md`).
|
||||
|
||||
@@ -16,5 +16,5 @@
|
||||
- **Tech-Kalender:** Wichtige Releases/Konferenzen.
|
||||
|
||||
## Formatierung
|
||||
- Nutze `<b>` für Sektionen.
|
||||
- Beachte die Telegram HTML-Beschränkungen.
|
||||
- Nutze `**fett**` für Sektionen anstatt von HTML.
|
||||
- Beachte die Telegram Markdown-Regeln (kein rohes HTML!). Baue viele aussagekräftige Links im Format `[Text](URL)` ein.
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
name: openclaw-audit
|
||||
description: Audit OpenClaw runtime logs, session transcripts, tool failures, and correlate errors with recent git commits across infrapuzzle, mop-skills, and knowledge-wiki. Use when performing system health audits, investigating tool failures, or running the weekly IT Guy self-audit.
|
||||
---
|
||||
|
||||
# 🩺 OpenClaw System & Log Self-Audit (`openclaw-audit`)
|
||||
|
||||
Comprehensive system diagnostic and correlation engine designed for **IT Guy** (`itguy`). It analyzes runtime container stdout logs, session transcripts, and tool execution failures across all agents, correlates errors with recent Git commits across the 3 core repositories, and files structured audit reports and actionable task queue entries into the Knowledge Wiki.
|
||||
|
||||
## 🎯 When to Use
|
||||
- **Weekly Scheduled Audit**: Triggered automatically via OpenClaw Cron (`itguy-weekly-health-audit`) every Sunday at 03:00 Europe/Berlin.
|
||||
- **On-Demand Diagnostics**: Whenever the user asks: *"Prüfe die Logs von OpenClaw"*, *"Gibt es Tool-Fehler?"*, *"Audit durchführen"*, *"System-Health prüfen"*.
|
||||
- **Post-Deployment Validation**: After making configuration, skill, or manifest changes to verify whether errors appeared.
|
||||
|
||||
---
|
||||
|
||||
## 🗂️ The Three Core Repositories Architecture
|
||||
The audit correlates runtime behavior with recent git commits across three foundational repositories:
|
||||
|
||||
1. **`infrapuzzle`** (`/home/node/infrapuzzle/infrapuzzle` or `/home/node/.openclaw/repos/infrapuzzle`):
|
||||
- Scope: Cluster infrastructure, Kubespray bootstrap, K8s manifests, Terraform DNS, ESPHome IoT, ADRs.
|
||||
2. **`mop-skills`** (`/home/node/mop-skills/mop-skills`):
|
||||
- Scope: Custom Python skills, CLI utilities, and MCP server bridges.
|
||||
3. **`knowledge-wiki`** (`/home/node/.openclaw/wiki/main`):
|
||||
- Scope: Agents-as-code (`agents/*.md`), active task queue (`tasks/antigravity.md`), family knowledge vault, and audit archives (`operations/audits/`).
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ CLI Operations Reference
|
||||
|
||||
### 1. Standard Audit (Inspection Only)
|
||||
```bash
|
||||
uv run /home/node/mop-skills/mop-skills/skills/openclaw-audit/scripts/audit_openclaw.py --since 7d
|
||||
```
|
||||
Fetches container logs from the last 7 days, parses session transcripts across all agents, summarizes recent commits from all 3 git repos, and outputs a formatted diagnostic markdown report to stdout.
|
||||
|
||||
### 2. Full Audit with Wiki Archival & Task Filing
|
||||
```bash
|
||||
uv run /home/node/mop-skills/mop-skills/skills/openclaw-audit/scripts/audit_openclaw.py \
|
||||
--since 7d \
|
||||
--write-report \
|
||||
--add-task \
|
||||
--git-push
|
||||
```
|
||||
- **`--write-report`**: Writes the detailed diagnostic report to `/home/node/.openclaw/wiki/main/operations/audits/YYYY-Wxx.md`.
|
||||
- **`--add-task`**: Appends any newly detected, deduplicated high/critical issues to `/home/node/.openclaw/wiki/main/tasks/antigravity.md` under `## 📥 Pending Tasks`.
|
||||
- **`--git-push`**: Commits and pushes the modified wiki pages directly to `git.moritzgraf.de/moritz/knowledge-wiki.git`.
|
||||
|
||||
### 3. Machine-Readable JSON Output
|
||||
```bash
|
||||
uv run /home/node/mop-skills/mop-skills/skills/openclaw-audit/scripts/audit_openclaw.py --since 24h --json
|
||||
```
|
||||
Returns a structured JSON payload containing `status`, `recent_commits`, `error_clusters`, `session_tool_failures`, and `affected_agents`.
|
||||
|
||||
---
|
||||
|
||||
## 🤖 Protocol for IT Guy Agent (`itguy`)
|
||||
|
||||
When executing the weekly audit turn:
|
||||
1. Run the audit script:
|
||||
```bash
|
||||
uv run /home/node/mop-skills/mop-skills/skills/openclaw-audit/scripts/audit_openclaw.py --since 7d --write-report --add-task --git-push
|
||||
```
|
||||
2. Review the aggregated output:
|
||||
- Check if any new issues were added to `tasks/antigravity.md`.
|
||||
- Correlate any error spikes with commits in `infrapuzzle` or `mop-skills`.
|
||||
3. Delivery Decision:
|
||||
- **If issues were found and tasks added**: Send a concise summary of findings, affected agents/tools, and links to the wiki task queue directly into Telegram Topic `#IT Guy` (thread `119`).
|
||||
- **If zero issues / all healthy**: Respond with **`NO_REPLY`** to maintain intentional silence (no Telegram notification spam).
|
||||
@@ -0,0 +1,622 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
OpenClaw System, Log & Repository Self-Audit Engine
|
||||
Analyzes container logs, session transcripts, and correlates error events
|
||||
with recent Git commits across infrapuzzle, mop-skills, and knowledge-wiki.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
# Default search directories (in-cluster vs local fallback)
|
||||
DEFAULT_CANDIDATE_PATHS = {
|
||||
"infrapuzzle": [
|
||||
Path("/home/node/infrapuzzle/infrapuzzle"),
|
||||
Path("/home/node/.openclaw/repos/infrapuzzle"),
|
||||
Path("/home/node/infrapuzzle"),
|
||||
Path("/Users/moritz/src/infrapuzzle"),
|
||||
],
|
||||
"mop-skills": [
|
||||
Path("/home/node/mop-skills/mop-skills"),
|
||||
Path("/Users/moritz/src/mop-skills"),
|
||||
],
|
||||
"knowledge-wiki": [
|
||||
Path("/home/node/.openclaw/wiki/main"),
|
||||
Path("/Users/moritz/src/infrapuzzle/knowledge-wiki"),
|
||||
],
|
||||
}
|
||||
|
||||
DEFAULT_SESSIONS_DIRS = [
|
||||
Path("/home/node/.openclaw/agents"),
|
||||
Path.home() / ".openclaw/agents",
|
||||
]
|
||||
|
||||
|
||||
def resolve_repo_path(repo_name: str, override: Optional[str] = None) -> Optional[Path]:
|
||||
if override:
|
||||
p = Path(override)
|
||||
if p.exists() and (p / ".git").exists():
|
||||
return p
|
||||
for candidate in DEFAULT_CANDIDATE_PATHS.get(repo_name, []):
|
||||
if candidate.exists() and (candidate / ".git").exists():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def resolve_sessions_dir(override: Optional[str] = None) -> Optional[Path]:
|
||||
if override:
|
||||
p = Path(override)
|
||||
if p.exists():
|
||||
return p
|
||||
for candidate in DEFAULT_SESSIONS_DIRS:
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def resolve_wiki_dir(override: Optional[str] = None) -> Optional[Path]:
|
||||
return resolve_repo_path("knowledge-wiki", override)
|
||||
|
||||
|
||||
def run_cmd(cmd: List[str], cwd: Optional[Path] = None) -> Tuple[int, str, str]:
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
cwd=str(cwd) if cwd else None,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
return proc.returncode, proc.stdout, proc.stderr
|
||||
except Exception as e:
|
||||
return -1, "", str(e)
|
||||
|
||||
|
||||
def get_git_commits(repo_path: Path, since: str) -> List[Dict[str, str]]:
|
||||
if not repo_path or not repo_path.exists():
|
||||
return []
|
||||
# format: hash | iso_date | author | subject
|
||||
cmd = [
|
||||
"git",
|
||||
"-C",
|
||||
str(repo_path),
|
||||
"log",
|
||||
f"--since={since}",
|
||||
'--pretty=format:%h%x09%ad%x09%an%x09%s',
|
||||
"--date=iso-strict",
|
||||
"-n",
|
||||
"20",
|
||||
]
|
||||
rc, stdout, _ = run_cmd(cmd)
|
||||
if rc != 0 or not stdout.strip():
|
||||
return []
|
||||
|
||||
commits = []
|
||||
for line in stdout.strip().splitlines():
|
||||
parts = line.split("\t")
|
||||
if len(parts) >= 4:
|
||||
commits.append({
|
||||
"hash": parts[0],
|
||||
"date": parts[1],
|
||||
"author": parts[2],
|
||||
"subject": parts[3],
|
||||
})
|
||||
return commits
|
||||
|
||||
|
||||
def normalize_error(msg: str) -> str:
|
||||
"""Normalize error strings to group identical issues together."""
|
||||
# Strip ISO timestamps
|
||||
s = re.sub(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?([+-]\d{2}:\d{2}|Z)?", "<TS>", msg)
|
||||
# Strip UUIDs
|
||||
s = re.sub(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", "<UUID>", s)
|
||||
# Strip toolCallIds and runIds
|
||||
s = re.sub(r"(call_[0-9a-zA-Z]+|runId=[0-9a-zA-Z-]+)", "<ID>", s)
|
||||
# Strip memory addresses
|
||||
s = re.sub(r"0x[0-9a-fA-F]+", "<HEX>", s)
|
||||
# Strip excessive whitespace
|
||||
s = " ".join(s.split())
|
||||
return s[:250]
|
||||
|
||||
|
||||
def fetch_container_logs(since: str, tail: int = 25000) -> str:
|
||||
"""Fetch container stdout logs using kubectl or read from openclaw log files."""
|
||||
# 1. Try in-cluster kubectl directly
|
||||
kubectl_bin = "/home/node/.openclaw/bin/kubectl" if Path("/home/node/.openclaw/bin/kubectl").exists() else "kubectl"
|
||||
cmd = [
|
||||
kubectl_bin,
|
||||
"logs",
|
||||
f"--since={since}",
|
||||
f"--tail={tail}",
|
||||
"-n",
|
||||
"openclaw",
|
||||
"openclaw-0",
|
||||
"-c",
|
||||
"openclaw",
|
||||
]
|
||||
rc, stdout, _ = run_cmd(cmd)
|
||||
if rc == 0 and stdout.strip():
|
||||
return stdout
|
||||
|
||||
# 2. Fallback: Check local logs directory
|
||||
local_log_dir = Path("/home/node/.openclaw/logs")
|
||||
if local_log_dir.exists():
|
||||
combined = []
|
||||
for log_file in local_log_dir.glob("*.log"):
|
||||
try:
|
||||
combined.append(log_file.read_text(errors="replace"))
|
||||
except Exception:
|
||||
pass
|
||||
if combined:
|
||||
return "\n".join(combined)
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def parse_container_logs(raw_logs: str) -> List[Dict[str, Any]]:
|
||||
"""Parse and cluster errors found in container stdout logs."""
|
||||
error_clusters: Dict[str, Dict[str, Any]] = defaultdict(lambda: {
|
||||
"signature": "",
|
||||
"category": "GATEWAY",
|
||||
"count": 0,
|
||||
"first_seen": None,
|
||||
"last_seen": None,
|
||||
"sample": "",
|
||||
})
|
||||
|
||||
if not raw_logs:
|
||||
return []
|
||||
|
||||
lines = raw_logs.splitlines()
|
||||
for line in lines:
|
||||
line_lower = line.lower()
|
||||
|
||||
# False positive exclusions
|
||||
if "iserror=false" in line_lower:
|
||||
continue
|
||||
if "[model-fetch] start" in line_lower:
|
||||
continue
|
||||
if "timeoutms=undefined" in line_lower and "status=200" in line_lower:
|
||||
continue
|
||||
if "response provider=" in line_lower and "status=200" in line_lower:
|
||||
continue
|
||||
|
||||
is_error = False
|
||||
category = "GATEWAY"
|
||||
|
||||
# Categorization patterns
|
||||
if "iserror=true" in line_lower or "tool failed" in line_lower or "[embedded run tool end: " in line and "error" in line_lower:
|
||||
is_error = True
|
||||
category = "TOOL"
|
||||
elif "mcp" in line_lower and ("exit" in line_lower or "crashed" in line_lower or "closed with code" in line_lower or "failed to start" in line_lower):
|
||||
is_error = True
|
||||
category = "MCP_SERVER"
|
||||
elif "unhandledrejection" in line_lower or "uncaughtexception" in line_lower or "traceback (most recent call last)" in line_lower or "fatal error" in line_lower:
|
||||
is_error = True
|
||||
category = "RUNTIME_EXCEPTION"
|
||||
elif any(f"status={code}" in line for code in ["500", "502", "503", "504", "429"]) or ("model-fetch" in line_lower and ("failed" in line_lower or "timed out" in line_lower)):
|
||||
is_error = True
|
||||
category = "LLM_PROVIDER"
|
||||
elif " [error] " in line_lower or "level=error" in line_lower or "econnrefused" in line_lower or "etimedout" in line_lower:
|
||||
is_error = True
|
||||
category = "SYSTEM_ERROR"
|
||||
|
||||
if is_error:
|
||||
# Extract timestamp if present at beginning of line
|
||||
ts_match = re.match(r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[^\s]*)", line)
|
||||
ts = ts_match.group(1) if ts_match else ""
|
||||
|
||||
sig = normalize_error(line)
|
||||
cluster = error_clusters[sig]
|
||||
cluster["signature"] = sig
|
||||
cluster["category"] = category
|
||||
cluster["count"] += 1
|
||||
cluster["sample"] = line[:400]
|
||||
if ts:
|
||||
if not cluster["first_seen"] or ts < cluster["first_seen"]:
|
||||
cluster["first_seen"] = ts
|
||||
if not cluster["last_seen"] or ts > cluster["last_seen"]:
|
||||
cluster["last_seen"] = ts
|
||||
|
||||
# Convert to sorted list by frequency
|
||||
sorted_clusters = sorted(error_clusters.values(), key=lambda x: x["count"], reverse=True)
|
||||
return sorted_clusters
|
||||
|
||||
|
||||
def parse_session_transcripts(sessions_dir: Path, since_hours: float = 168.0) -> List[Dict[str, Any]]:
|
||||
"""Scan and parse JSONL session transcripts for tool errors."""
|
||||
if not sessions_dir or not sessions_dir.exists():
|
||||
return []
|
||||
|
||||
cutoff = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(hours=since_hours)
|
||||
tool_failures: Dict[str, Dict[str, Any]] = defaultdict(lambda: {
|
||||
"agent": "",
|
||||
"tool": "",
|
||||
"signature": "",
|
||||
"count": 0,
|
||||
"sample": "",
|
||||
"first_seen": None,
|
||||
"last_seen": None,
|
||||
})
|
||||
|
||||
# Sessions are stored at <agent>/sessions/*.jsonl
|
||||
for jsonl_file in sessions_dir.glob("*/sessions/*.jsonl"):
|
||||
try:
|
||||
mtime = datetime.datetime.fromtimestamp(jsonl_file.stat().st_mtime, tz=datetime.timezone.utc)
|
||||
if mtime < cutoff:
|
||||
continue
|
||||
|
||||
agent_name = jsonl_file.parent.parent.name
|
||||
|
||||
with open(jsonl_file, "r", encoding="utf-8", errors="replace") as f:
|
||||
for line in f:
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
record = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Check for tool errors in various OpenClaw transcript representations
|
||||
role = record.get("role")
|
||||
msg_type = record.get("type")
|
||||
is_err = record.get("isError", False)
|
||||
content = record.get("content", "")
|
||||
|
||||
if isinstance(content, list):
|
||||
content_str = json.dumps(content)
|
||||
else:
|
||||
content_str = str(content)
|
||||
|
||||
# Detect failure markers
|
||||
has_error_keyword = any(k in content_str.lower() for k in [
|
||||
"tool failed",
|
||||
"error:",
|
||||
"traceback",
|
||||
"validationerror",
|
||||
"status code 5",
|
||||
"connection refused",
|
||||
"timeout",
|
||||
])
|
||||
|
||||
if role == "tool" and (is_err or has_error_keyword):
|
||||
tool_name = record.get("name") or record.get("tool") or "unknown_tool"
|
||||
sig = f"[{agent_name}] {tool_name}: {normalize_error(content_str)}"
|
||||
entry = tool_failures[sig]
|
||||
entry["agent"] = agent_name
|
||||
entry["tool"] = tool_name
|
||||
entry["signature"] = sig
|
||||
entry["count"] += 1
|
||||
entry["sample"] = content_str[:300]
|
||||
ts = record.get("createdAt") or record.get("timestamp") or mtime.isoformat()
|
||||
if not entry["first_seen"] or ts < entry["first_seen"]:
|
||||
entry["first_seen"] = ts
|
||||
if not entry["last_seen"] or ts > entry["last_seen"]:
|
||||
entry["last_seen"] = ts
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return sorted(tool_failures.values(), key=lambda x: x["count"], reverse=True)
|
||||
|
||||
|
||||
def build_audit_report(
|
||||
since: str,
|
||||
repos_commits: Dict[str, List[Dict[str, str]]],
|
||||
container_errors: List[Dict[str, Any]],
|
||||
session_errors: List[Dict[str, Any]],
|
||||
) -> Dict[str, Any]:
|
||||
"""Compile structured audit report from all inputs."""
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
year, week, _ = now.isocalendar()
|
||||
|
||||
total_container_errors = sum(c["count"] for c in container_errors)
|
||||
total_session_tool_errors = sum(s["count"] for s in session_errors)
|
||||
|
||||
# Determine severity
|
||||
has_critical = any(c["category"] in ["MCP_SERVER", "RUNTIME_EXCEPTION"] and c["count"] >= 3 for c in container_errors)
|
||||
has_high = total_session_tool_errors > 5 or total_container_errors > 20
|
||||
|
||||
if has_critical:
|
||||
status = "CRITICAL"
|
||||
elif has_high:
|
||||
status = "DEGRADED"
|
||||
elif total_container_errors > 0 or total_session_tool_errors > 0:
|
||||
status = "WARNING"
|
||||
else:
|
||||
status = "HEALTHY"
|
||||
|
||||
return {
|
||||
"year": year,
|
||||
"week": week,
|
||||
"generated_at": now.isoformat(),
|
||||
"since": since,
|
||||
"status": status,
|
||||
"total_container_errors": total_container_errors,
|
||||
"total_session_tool_errors": total_session_tool_errors,
|
||||
"repos_commits": repos_commits,
|
||||
"container_errors": container_errors,
|
||||
"session_errors": session_errors,
|
||||
}
|
||||
|
||||
|
||||
def render_markdown_report(report: Dict[str, Any]) -> str:
|
||||
"""Format audit report into clean Markdown with YAML frontmatter."""
|
||||
year = report["year"]
|
||||
week = report["week"]
|
||||
status = report["status"]
|
||||
status_emoji = {
|
||||
"HEALTHY": "🟢",
|
||||
"WARNING": "🟡",
|
||||
"DEGRADED": "🟠",
|
||||
"CRITICAL": "🔴",
|
||||
}.get(status, "⚪")
|
||||
|
||||
lines = [
|
||||
"---",
|
||||
f'title: "Weekly OpenClaw System Audit {year}-W{week:02d}"',
|
||||
f'date: "{report["generated_at"]}"',
|
||||
f'status: "{status.lower()}"',
|
||||
f'evaluated_window: "{report["since"]}"',
|
||||
"type: audit-report",
|
||||
"---",
|
||||
"",
|
||||
f"# {status_emoji} OpenClaw System Health Audit — {year}-W{week:02d}",
|
||||
"",
|
||||
f"- **Generated At**: `{report['generated_at']}`",
|
||||
f"- **Audit Window**: Last `{report['since']}`",
|
||||
f"- **Overall System State**: **{status}**",
|
||||
f"- **Container Log Errors**: `{report['total_container_errors']}` occurrences",
|
||||
f"- **Session Tool Failures**: `{report['total_session_tool_errors']}` occurrences",
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
"## 🗂️ Recent Repository Commits Timeline",
|
||||
"",
|
||||
"Cross-repository correlation of recent code and configuration modifications:",
|
||||
"",
|
||||
]
|
||||
|
||||
repos = report.get("repos_commits", {})
|
||||
for repo_name, commits in repos.items():
|
||||
lines.append(f"### 📦 `{repo_name}` ({len(commits)} commits)")
|
||||
if not commits:
|
||||
lines.append("_No commits within this audit window._\n")
|
||||
continue
|
||||
lines.append("| Hash | Date | Author | Message |")
|
||||
lines.append("|---|---|---|---|")
|
||||
for c in commits[:8]:
|
||||
msg = c['subject'].replace("|", "\\|")
|
||||
lines.append(f"| `{c['hash']}` | {c['date'][:10]} | {c['author']} | {msg} |")
|
||||
lines.append("")
|
||||
|
||||
lines.extend([
|
||||
"---",
|
||||
"",
|
||||
"## 🛠️ Session Transcript Tool Failures",
|
||||
"",
|
||||
"Tool invocation errors across subagent conversations (`/home/node/.openclaw/agents/*/sessions/`):",
|
||||
"",
|
||||
])
|
||||
|
||||
session_errs = report.get("session_errors", [])
|
||||
if not session_errs:
|
||||
lines.append("✅ **No tool failures detected in agent session transcripts.**\n")
|
||||
else:
|
||||
lines.append("| Agent | Tool | Count | Sample Error |")
|
||||
lines.append("|---|---|---|---|")
|
||||
for s in session_errs[:15]:
|
||||
sample_clean = s['sample'].replace("|", "\\|").replace("\n", " ")[:120]
|
||||
lines.append(f"| `{s['agent']}` | `{s['tool']}` | **{s['count']}** | `{sample_clean}` |")
|
||||
lines.append("")
|
||||
|
||||
lines.extend([
|
||||
"---",
|
||||
"",
|
||||
"## 🖥️ Container Runtime & Gateway Log Anomalies",
|
||||
"",
|
||||
"Aggregated errors from OpenClaw pod stdout (`kubectl logs -n openclaw openclaw-0 -c openclaw`):",
|
||||
"",
|
||||
])
|
||||
|
||||
container_errs = report.get("container_errors", [])
|
||||
if not container_errs:
|
||||
lines.append("✅ **No runtime exceptions or container errors detected.**\n")
|
||||
else:
|
||||
lines.append("| Category | Count | Normalized Signature | Sample |")
|
||||
lines.append("|---|---|---|---|")
|
||||
for c in container_errs[:15]:
|
||||
sig_clean = c['signature'].replace("|", "\\|").replace("\n", " ")[:100]
|
||||
sample_clean = c['sample'].replace("|", "\\|").replace("\n", " ")[:100]
|
||||
lines.append(f"| `{c['category']}` | **{c['count']}** | `{sig_clean}` | `{sample_clean}` |")
|
||||
lines.append("")
|
||||
|
||||
lines.extend([
|
||||
"---",
|
||||
"",
|
||||
"## 🎯 Actionable Antigravity Recommendations",
|
||||
"",
|
||||
])
|
||||
|
||||
if status == "HEALTHY":
|
||||
lines.append("All services, MCP bridges, and subagent tools operate within healthy parameters. No remediation required.")
|
||||
else:
|
||||
lines.append("The following candidate fixes should be investigated and resolved:")
|
||||
# List top issues
|
||||
for idx, s in enumerate(session_errs[:3], start=1):
|
||||
lines.append(f"{idx}. **Fix Tool Failure in `{s['agent']}` / `{s['tool']}`**: `{s['signature'][:140]}`")
|
||||
for idx, c in enumerate(container_errs[:3], start=len(session_errs[:3]) + 1):
|
||||
lines.append(f"{idx}. **Resolve `{c['category']}` Anomaly**: `{c['signature'][:140]}`")
|
||||
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def update_tasks_antigravity(wiki_dir: Path, report: Dict[str, Any]) -> List[str]:
|
||||
"""Append actionable issues to tasks/antigravity.md under ## 📥 Pending Tasks."""
|
||||
tasks_file = wiki_dir / "tasks" / "antigravity.md"
|
||||
if not tasks_file.exists():
|
||||
return []
|
||||
|
||||
try:
|
||||
content = tasks_file.read_text(encoding="utf-8")
|
||||
except Exception as e:
|
||||
print(f"Error reading {tasks_file}: {e}", file=sys.stderr)
|
||||
return []
|
||||
|
||||
added_tasks = []
|
||||
today = datetime.datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
# Combine top session and container errors
|
||||
top_candidates = []
|
||||
for s in report.get("session_errors", []):
|
||||
if s["count"] >= 2:
|
||||
top_candidates.append({
|
||||
"title": f"Fix Tool Failure: {s['agent']} / {s['tool']}",
|
||||
"detail": f"Observed {s['count']} times in session transcripts. Sample: {s['sample'][:250]}",
|
||||
"key": f"{s['agent']}:{s['tool']}",
|
||||
})
|
||||
|
||||
for c in report.get("container_errors", []):
|
||||
if c["count"] >= 3 and c["category"] in ["MCP_SERVER", "RUNTIME_EXCEPTION", "TOOL"]:
|
||||
top_candidates.append({
|
||||
"title": f"Resolve {c['category']} Error in OpenClaw Gateway",
|
||||
"detail": f"Observed {c['count']} times in pod logs. Signature: {c['signature'][:200]}",
|
||||
"key": c["signature"][:40],
|
||||
})
|
||||
|
||||
# Only append if not already mentioned in tasks file
|
||||
pending_anchor = "## 📥 Pending Tasks\n"
|
||||
if pending_anchor not in content:
|
||||
return []
|
||||
|
||||
new_task_blocks = []
|
||||
for cand in top_candidates:
|
||||
# Check deduplication
|
||||
if cand["title"] in content or cand["key"] in content:
|
||||
continue
|
||||
|
||||
block = (
|
||||
f"- [ ] **{today}: {cand['title']}**\n"
|
||||
f" - **Details**: Detected by IT Guy weekly self-audit (`audit_openclaw.py`). {cand['detail']}\n"
|
||||
f" - **Target**: OpenClaw Gateway / MCP / Subagent configuration\n"
|
||||
f" - **Requested by**: IT Guy Agent (Autonomous Weekly Audit)\n"
|
||||
)
|
||||
new_task_blocks.append(block)
|
||||
added_tasks.append(cand["title"])
|
||||
|
||||
if new_task_blocks:
|
||||
parts = content.split(pending_anchor, 1)
|
||||
updated_content = parts[0] + pending_anchor + "\n" + "\n".join(new_task_blocks) + "\n" + parts[1]
|
||||
tasks_file.write_text(updated_content, encoding="utf-8")
|
||||
|
||||
return added_tasks
|
||||
|
||||
|
||||
def git_commit_and_push(wiki_dir: Path, commit_msg: str) -> bool:
|
||||
"""Commit and push changes in knowledge-wiki repository."""
|
||||
rc1, _, _ = run_cmd(["git", "-C", str(wiki_dir), "add", "-A"])
|
||||
if rc1 != 0:
|
||||
return False
|
||||
rc2, _, _ = run_cmd(["git", "-C", str(wiki_dir), "commit", "-m", commit_msg])
|
||||
# rc 1 can mean nothing to commit
|
||||
rc3, _, _ = run_cmd(["git", "-C", str(wiki_dir), "push", "origin", "main"])
|
||||
return rc3 == 0
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="OpenClaw System, Log & Repository Self-Audit Engine")
|
||||
parser.add_argument("--since", default="7d", help="Audit time window (e.g. 7d, 24h, 168h)")
|
||||
parser.add_argument("--write-report", action="store_true", help="Save markdown report to operations/audits/ in knowledge-wiki")
|
||||
parser.add_argument("--add-task", action="store_true", help="Append new actionable issues to tasks/antigravity.md")
|
||||
parser.add_argument("--git-push", action="store_true", help="Commit and push changes to knowledge-wiki git remote")
|
||||
parser.add_argument("--json", action="store_true", help="Output machine-readable JSON to stdout")
|
||||
parser.add_argument("--infrapuzzle-path", help="Path to infrapuzzle repo override")
|
||||
parser.add_argument("--mop-skills-path", help="Path to mop-skills repo override")
|
||||
parser.add_argument("--wiki-path", help="Path to knowledge-wiki repo override")
|
||||
parser.add_argument("--sessions-dir", help="Path to OpenClaw sessions directory override")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Convert since string to hours for file timestamps
|
||||
since_hours = 168.0
|
||||
if args.since.endswith("d"):
|
||||
since_hours = float(args.since[:-1]) * 24.0
|
||||
elif args.since.endswith("h"):
|
||||
since_hours = float(args.since[:-1])
|
||||
|
||||
# 1. Resolve Repositories
|
||||
repos = {
|
||||
"infrapuzzle": resolve_repo_path("infrapuzzle", args.infrapuzzle_path),
|
||||
"mop-skills": resolve_repo_path("mop-skills", getattr(args, "mop_skills_path", None)),
|
||||
"knowledge-wiki": resolve_wiki_dir(args.wiki_path),
|
||||
}
|
||||
|
||||
# 2. Correlate Git Commits
|
||||
repos_commits = {}
|
||||
for r_name, r_path in repos.items():
|
||||
if r_path:
|
||||
repos_commits[r_name] = get_git_commits(r_path, args.since)
|
||||
else:
|
||||
repos_commits[r_name] = []
|
||||
|
||||
# 3. Ingest Container Logs
|
||||
raw_logs = fetch_container_logs(args.since)
|
||||
container_errors = parse_container_logs(raw_logs)
|
||||
|
||||
# 4. Ingest Session Transcripts
|
||||
sessions_dir = resolve_sessions_dir(args.sessions_dir)
|
||||
session_errors = parse_session_transcripts(sessions_dir, since_hours) if sessions_dir else []
|
||||
|
||||
# 5. Build Report
|
||||
report = build_audit_report(args.since, repos_commits, container_errors, session_errors)
|
||||
|
||||
# 6. JSON output mode
|
||||
if args.json:
|
||||
print(json.dumps(report, indent=2))
|
||||
return
|
||||
|
||||
# 7. Render Markdown
|
||||
md_report = render_markdown_report(report)
|
||||
|
||||
# 8. Write Report if requested
|
||||
wiki_dir = repos.get("knowledge-wiki")
|
||||
if args.write_report and wiki_dir:
|
||||
audits_dir = wiki_dir / "operations" / "audits"
|
||||
audits_dir.mkdir(parents=True, exist_ok=True)
|
||||
report_path = audits_dir / f"{report['year']}-W{report['week']:02d}.md"
|
||||
report_path.write_text(md_report, encoding="utf-8")
|
||||
print(f"✅ Audit report saved: {report_path}")
|
||||
|
||||
# 9. Append to tasks/antigravity.md if requested
|
||||
added_tasks = []
|
||||
if args.add_task and wiki_dir:
|
||||
added_tasks = update_tasks_antigravity(wiki_dir, report)
|
||||
if added_tasks:
|
||||
print(f"📋 Added {len(added_tasks)} new task(s) to tasks/antigravity.md:")
|
||||
for t in added_tasks:
|
||||
print(f" - {t}")
|
||||
else:
|
||||
print("📋 No new tasks required (all issues healthy or already queued).")
|
||||
|
||||
# 10. Commit & Push if requested
|
||||
if args.git_push and wiki_dir and (args.write_report or added_tasks):
|
||||
success = git_commit_and_push(wiki_dir, f"chore(audit): weekly health audit {report['year']}-W{report['week']:02d}")
|
||||
if success:
|
||||
print("🚀 Pushed audit report and task updates to git.moritzgraf.de!")
|
||||
else:
|
||||
print("⚠️ Git push skipped or failed (check credentials/remote).")
|
||||
|
||||
# Print summary to stdout
|
||||
print(md_report)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
name: "pdf-onepager"
|
||||
description: "Generate styled one-page PDFs in the sandbox via fpdf2 and print with brother-printer."
|
||||
---
|
||||
|
||||
# PDF One-Pager (fpdf2)
|
||||
|
||||
Generate a styled, single-page PDF in the sandbox, then print it with the brother-printer skill.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Draft the content as a Python script using `fpdf2` (FPDF, A4, `set_auto_page_break(False)`, margins ~15mm). Build layout with helper functions: colored title, section headers with rule line, label+text bullets, a filled warning box (`set_fill_color` + `multi_cell(fill=True)`), and a small gray source footer. Do not use emoji/HTML-entities in text: fpdf2 core fonts (helvetica) support only Latin-1 — replace emoji with plain words.
|
||||
- Done when: the script is saved (e.g. `/tmp/make_pdf.py`) with an absolute output path and prints a pages count from `pdf.pages_count`.
|
||||
2. Run `uv run --with fpdf2 /tmp/make_pdf.py`.
|
||||
- Do NOT attempt `weasyprint` (fails in this sandbox: missing system libs) or wkhtmltopdf/chromium/pandoc (not installed). Go straight to fpdf2.
|
||||
- Done when: script prints `done, pages: 1` (or expected count) and the PDF file exists.
|
||||
3. Verify the file (`ls -la <path>`), then print per the brother-printer skill: `uv run /home/node/mop-skills/mop-skills/skills/brother-printer/scripts/brother_printer.py print --file <pdf> --title <title> --color monochrome --nup 1 --duplex two-sided-long-edge --chat-id <chat_id>`.
|
||||
- For print-option selection, use `ask_user` with the standard preset menu from the brother-printer skill.
|
||||
- Done when: script reports 'Druck erfolgreich abgeschlossen'.
|
||||
|
||||
## Reference
|
||||
- fpdf2 API: `pdf.cell(w, h, text, new_x="LMARGIN", new_y="NEXT")` for labels/headers; `pdf.multi_cell(W, h, text, new_x="LMARGIN", new_y="NEXT")` for wrapped text; colors via `set_text_color/set_fill_color/set_draw_color(RGB tuples)`.
|
||||
- Keep single-page fit: font 9–9.5pt body, 11–11.5pt headers, 17pt title; if content overflows, trim bullets rather than shrinking below 9pt.
|
||||
- Save the PDF in the workspace (e.g. `/home/openclaw/.openclaw/workspace/<Name>.pdf`) so it can be resent later.
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
name: qr-code
|
||||
description: Generate a branded Haumdaucher QR code for any URL or text string and send the resulting image into chat. Usable by all agents.
|
||||
---
|
||||
|
||||
# QR Code Generator 📱
|
||||
|
||||
Creates high-redundancy, branded QR codes with the **Haumdaucher logo** centered on a clean white background.
|
||||
|
||||
## When to Use
|
||||
- Whenever you share a link (`https://...`), website, recipe, document, Wi-Fi configuration, or arbitrary text and want to offer a scannable QR code.
|
||||
- Whenever the user accepts an `ask_user` prompt to generate a QR code for a hosted artifact.
|
||||
- Whenever the user explicitly asks for a QR code ("Erstelle einen QR-Code für ...").
|
||||
|
||||
## Execution
|
||||
|
||||
Run the bundled Python script via `uv run`:
|
||||
|
||||
```bash
|
||||
uv run /home/node/mop-skills/mop-skills/skills/qr-code/scripts/generate_qr.py "<URL-or-Text>"
|
||||
```
|
||||
|
||||
### Script Arguments:
|
||||
- `<text>`: The URL or string to encode (mandatory).
|
||||
- `--output <path>`: Optional target file path (defaults to `/tmp/qr_<hash>.png`).
|
||||
- `--logo <path>`: Optional custom logo PNG (defaults to bundled Haumdaucher logo).
|
||||
|
||||
## Output & Telegram Delivery
|
||||
The script outputs:
|
||||
```
|
||||
MEDIA:/tmp/qr_c9e87fb7.png
|
||||
```
|
||||
|
||||
When returning your reply to the user, include the `MEDIA:<path>` line at the very beginning or end of your message. OpenClaw's Telegram integration will automatically upload the image directly into the chat:
|
||||
|
||||
```markdown
|
||||
Hier ist dein QR-Code für den Link:
|
||||
|
||||
MEDIA:/tmp/qr_c9e87fb7.png
|
||||
```
|
||||
|
After Width: | Height: | Size: 616 KiB |
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# dependencies = [
|
||||
# "qrcode[pil]>=7.4",
|
||||
# "pillow>=10.0.0",
|
||||
# ]
|
||||
# ///
|
||||
"""
|
||||
Haumdaucher QR Code Generator with Centered Logo.
|
||||
Encodes any URL or string into a high-error-correction QR code (ERROR_CORRECT_H),
|
||||
embeds the Haumdaucher logo in the center with a clean white rounded badge,
|
||||
and outputs MEDIA:<path> for direct Telegram inline photo delivery.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from PIL import Image, ImageDraw
|
||||
import qrcode
|
||||
from qrcode.constants import ERROR_CORRECT_H
|
||||
|
||||
DEFAULT_LOGO_PATH = Path(__file__).resolve().parent.parent / "assets" / "haumdaucher_logo.png"
|
||||
|
||||
def generate_qr_code(text: str, output_path: str = None, logo_path: str = None) -> str:
|
||||
if not text:
|
||||
raise ValueError("Text/URL to encode cannot be empty.")
|
||||
|
||||
if not output_path:
|
||||
text_hash = hashlib.sha256(text.encode("utf-8")).hexdigest()[:8]
|
||||
output_path = f"/tmp/qr_{text_hash}.png"
|
||||
|
||||
# Resolve logo path
|
||||
logo_file = Path(logo_path) if logo_path else DEFAULT_LOGO_PATH
|
||||
if not logo_file.exists():
|
||||
# Fallback to current script dir or assets if relative path differs
|
||||
logo_file = DEFAULT_LOGO_PATH
|
||||
|
||||
# Initialize QR code generator with High error correction (30% redundancy)
|
||||
qr = qrcode.QRCode(
|
||||
version=None, # Auto-detect best version
|
||||
error_correction=ERROR_CORRECT_H,
|
||||
box_size=12,
|
||||
border=4,
|
||||
)
|
||||
qr.add_data(text)
|
||||
qr.make(fit=True)
|
||||
|
||||
# Generate base QR code image (RGBA for clean compositing)
|
||||
qr_img = qr.make_image(fill_color="black", back_color="white").convert("RGBA")
|
||||
qr_width, qr_height = qr_img.size
|
||||
|
||||
# Overlay logo if available
|
||||
if logo_file.exists():
|
||||
try:
|
||||
logo = Image.open(logo_file).convert("RGBA")
|
||||
|
||||
# Logo should occupy roughly 22% of the QR code dimensions to ensure readability
|
||||
max_logo_size = int(qr_width * 0.23)
|
||||
|
||||
# Maintain aspect ratio
|
||||
logo_w, logo_h = logo.size
|
||||
scale = min(max_logo_size / logo_w, max_logo_size / logo_h)
|
||||
new_w = int(logo_w * scale)
|
||||
new_h = int(logo_h * scale)
|
||||
logo = logo.resize((new_w, new_h), Image.Resampling.LANCZOS)
|
||||
|
||||
# Create a white rounded badge behind the logo for contrast and module separation
|
||||
badge_pad = 8
|
||||
badge_w = new_w + badge_pad * 2
|
||||
badge_h = new_h + badge_pad * 2
|
||||
badge = Image.new("RGBA", (badge_w, badge_h), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(badge)
|
||||
radius = 12
|
||||
draw.rounded_rectangle(
|
||||
[(0, 0), (badge_w - 1, badge_h - 1)],
|
||||
radius=radius,
|
||||
fill=(255, 255, 255, 255),
|
||||
outline=(220, 220, 220, 255),
|
||||
width=1,
|
||||
)
|
||||
|
||||
# Paste logo onto the white badge
|
||||
badge.paste(logo, (badge_pad, badge_pad), mask=logo)
|
||||
|
||||
# Paste badge into the center of the QR code
|
||||
pos_x = (qr_width - badge_w) // 2
|
||||
pos_y = (qr_height - badge_h) // 2
|
||||
qr_img.paste(badge, (pos_x, pos_y), mask=badge)
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"Warning: could not overlay logo: {e}\n")
|
||||
|
||||
# Ensure output directory exists and save
|
||||
out_file = Path(output_path)
|
||||
out_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
qr_img.save(str(out_file), format="PNG")
|
||||
|
||||
return str(out_file)
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Generate a Haumdaucher-branded QR code.")
|
||||
parser.add_argument("text", nargs="?", help="Text or URL to encode into the QR code")
|
||||
parser.add_argument("--text", "-t", dest="text_opt", help="Text or URL to encode")
|
||||
parser.add_argument("--output", "-o", help="Output file path (default: /tmp/qr_<hash>.png)")
|
||||
parser.add_argument("--logo", "-l", help="Custom path to center logo PNG")
|
||||
parser.add_argument("--quiet", "-q", action="store_true", help="Only output MEDIA tag")
|
||||
|
||||
args = parser.parse_args()
|
||||
raw_text = args.text or args.text_opt
|
||||
if not raw_text:
|
||||
parser.error("You must provide text or a URL to encode.")
|
||||
|
||||
out_path = generate_qr_code(raw_text, output_path=args.output, logo_path=args.logo)
|
||||
|
||||
# Standard OpenClaw / Telegram media dispatch tag
|
||||
print(f"MEDIA:{out_path}")
|
||||
if not args.quiet:
|
||||
sys.stderr.write(f"QR code successfully generated for: {raw_text}\nSaved to: {out_path}\n")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
name: regensburg-stadtrat
|
||||
description: Municipal politics, city council resolutions, meeting agendas, and Ratsinformation for the City of Regensburg (ALLRIS net). Use when the user asks about Regensburg city council politics, upcoming council or committee meetings (Stadtrat, Ausschüsse), local building/traffic decisions (e.g. Stadtbahn, Kaufhof, Radwege, Schulen), or specific Vorlagen and Beschlüsse.
|
||||
---
|
||||
|
||||
# 🏛️ Regensburg Stadtrat & Ratsinformationssystem (ALLRIS)
|
||||
|
||||
Query the Regensburg municipal council information system (**ALLRIS net** on `srv19.regensburg.de/bi/`) for real-time meeting schedules, committee agendas, council proposals (*Vorlagen*), and binding resolutions (*Beschlüsse*).
|
||||
|
||||
## 🚀 Quick Usage
|
||||
|
||||
Use `scripts/regensburg_ris.py`. The tool auto-executes via `uv` and interfaces directly with ALLRIS XML feeds and search endpoints.
|
||||
|
||||
### 1. Search Council Documents & Resolutions
|
||||
Search by keyword (e.g., "Kaufhof", "Stadtbahn", "Haushalt", "Klimaschutz"):
|
||||
```bash
|
||||
python3 scripts/regensburg_ris.py search "Kaufhof" --limit 10
|
||||
```
|
||||
|
||||
### 2. View Meeting Calendar (Upcoming or Recent)
|
||||
See what council or committee meetings are scheduled:
|
||||
```bash
|
||||
# Next 30 days
|
||||
python3 scripts/regensburg_ris.py calendar --days 30
|
||||
|
||||
# Specific date range
|
||||
python3 scripts/regensburg_ris.py calendar --from-date 01.09.2026 --to-date 30.09.2026
|
||||
```
|
||||
|
||||
### 3. Inspect a Meeting Agenda (TOPs)
|
||||
Fetch all agenda items (Tagesordnungspunkte) and linked proposal IDs for a meeting ID (`SILFDNR`):
|
||||
```bash
|
||||
python3 scripts/regensburg_ris.py session 2665
|
||||
```
|
||||
|
||||
### 4. Read a Vorlage & Extract Resolutions
|
||||
View proposal metadata, responsible departments, voting results, and exact resolution text:
|
||||
```bash
|
||||
python3 scripts/regensburg_ris.py vorlage 21362
|
||||
```
|
||||
|
||||
To automatically download and convert linked PDF attachments to Markdown using the `markitdown` skill:
|
||||
```bash
|
||||
python3 scripts/regensburg_ris.py vorlage 21362 --convert-attachments
|
||||
```
|
||||
|
||||
### 5. Compile a Topic Dossier for Knowledge-Wiki
|
||||
Compile a multi-document synthesis and automatically file it into the Obsidian `knowledge-wiki`:
|
||||
```bash
|
||||
python3 scripts/regensburg_ris.py compile-topic "Kaufhof" --vault /Users/moritz/src/infrapuzzle/knowledge-wiki
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🏛️ Major Committees in Regensburg
|
||||
- **Stadtrat der Stadt Regensburg**: Full city council plenum chaired by Oberbürgermeisterin Gertrud Maltz-Schwarzfischer.
|
||||
- **Ausschuss für Stadtplanung, Verkehr und Wohnungsfragen**: Urban development, mobility, zoning, housing.
|
||||
- **Ausschuss für Verwaltung, Finanzen und Beteiligungen**: Budget, municipal companies, personnel.
|
||||
- **Ausschuss für Umweltfragen, Natur- und Klimaschutz**: Environmental policy, energy, climate transition.
|
||||
- **Ausschuss für Wirtschaft**: Local business, commerce, downtown vitality.
|
||||
- **Sportausschuss / Kulturausschuss / Ausschuss für Bildung**: Sports, culture, schools, and daycares.
|
||||
|
||||
---
|
||||
|
||||
## 💡 Best Practices for OpenClaw
|
||||
- **Always Cite Sources**: Provide the exact Vorlagen code (e.g. `VO/25/21865/61`) and direct ALLRIS URL in responses.
|
||||
- **Check Resolution Outcomes**: When examining controversial topics or motions, check whether an Antrag was *zurückgezogen* (withdrawn), *durch Bericht erledigt* (settled by report), or *beschlossen* (enacted).
|
||||
- **Combine with `markitdown`**: When deep technical details (such as urban planning maps, budget numbers, or expert appraisals) are in PDF attachments, pass them to `skills/markitdown/scripts/convert_to_markdown.py`.
|
||||
@@ -0,0 +1,506 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# dependencies = [
|
||||
# "beautifulsoup4>=4.12.0",
|
||||
# "requests>=2.31.0",
|
||||
# ]
|
||||
# ///
|
||||
"""
|
||||
Regensburg Ratsinformationssystem (ALLRIS net) Client & CLI.
|
||||
Provides search, calendar, session agenda, Vorlagen extraction, and Obsidian wiki compilation.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.parse
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
def ensure_dependencies_and_run():
|
||||
"""If beautifulsoup4 or requests is not installed in current Python, re-exec via uv run."""
|
||||
try:
|
||||
import bs4 # noqa: F401
|
||||
import requests # noqa: F401
|
||||
except ImportError:
|
||||
uv_path = shutil.which("uv")
|
||||
if uv_path and os.environ.get("_REGENSBURG_RIS_REEXEC") != "1":
|
||||
env = os.environ.copy()
|
||||
env["_REGENSBURG_RIS_REEXEC"] = "1"
|
||||
cmd = [uv_path, "run", os.path.abspath(__file__), *sys.argv[1:]]
|
||||
try:
|
||||
res = subprocess.run(cmd, env=env)
|
||||
sys.exit(res.returncode)
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"Failed to re-execute via uv: {e}\n")
|
||||
sys.stderr.write("Error: 'beautifulsoup4' or 'requests' not found. Please run with:\n")
|
||||
sys.stderr.write(f" uv run {sys.argv[0]} ...\n")
|
||||
sys.exit(1)
|
||||
|
||||
ensure_dependencies_and_run()
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
import requests
|
||||
|
||||
BASE_URL = "https://srv19.regensburg.de/bi/"
|
||||
DEFAULT_HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
}
|
||||
|
||||
MARKITDOWN_SCRIPT = os.path.abspath(
|
||||
os.path.join(os.path.dirname(__file__), "..", "..", "markitdown", "scripts", "convert_to_markdown.py")
|
||||
)
|
||||
|
||||
class AllrisClient:
|
||||
def __init__(self, base_url: str = BASE_URL):
|
||||
self.base_url = base_url.rstrip("/") + "/"
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update(DEFAULT_HEADERS)
|
||||
|
||||
def search(self, query: str, limit: int = 15) -> list[dict]:
|
||||
"""
|
||||
Query ALLRIS full-text search engine (yw020.asp).
|
||||
Uses ISO-8859-1 (Latin-1) URL encoding required by the ASP backend.
|
||||
"""
|
||||
search_url = urllib.parse.urljoin(self.base_url, "yw020.asp")
|
||||
post_data = {
|
||||
"searchForm": "true",
|
||||
"SetRECHERCHE": "1",
|
||||
"REG": "A",
|
||||
"SUCHWORT": query,
|
||||
"searchName": "true",
|
||||
"searchCont": "true",
|
||||
"searchVo": "true",
|
||||
"searchSi": "true",
|
||||
"searchNaz": "true",
|
||||
"TRDATFIL": "(alle)",
|
||||
"filtdatum": "filter",
|
||||
"Suchen": "Suchen",
|
||||
}
|
||||
encoded_data = urllib.parse.urlencode(post_data, encoding="iso-8859-1").encode("iso-8859-1")
|
||||
headers = dict(DEFAULT_HEADERS)
|
||||
headers["Content-Type"] = "application/x-www-form-urlencoded"
|
||||
|
||||
resp = self.session.post(search_url, data=encoded_data, headers=headers, timeout=20)
|
||||
resp.raise_for_status()
|
||||
|
||||
html = resp.content.decode("iso-8859-1", errors="replace")
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
|
||||
results = []
|
||||
for a in soup.find_all("a", href=re.compile(r"(vo020|to010|to020)\.asp")):
|
||||
href = a.get("href", "")
|
||||
clean_href = href.split("#")[0]
|
||||
full_url = urllib.parse.urljoin(self.base_url, clean_href)
|
||||
title = a.get_text(" ", strip=True)
|
||||
if not title:
|
||||
continue
|
||||
|
||||
item_type = "unknown"
|
||||
item_id = ""
|
||||
code = ""
|
||||
|
||||
if "vo020.asp" in clean_href:
|
||||
item_type = "vorlage"
|
||||
m_id = re.search(r"VOLFDNR=(\d+)", clean_href)
|
||||
item_id = m_id.group(1) if m_id else ""
|
||||
m_code = re.search(r"VO/\d+/\d+(?:/[A-Za-z0-9]+)?", title)
|
||||
if m_code:
|
||||
code = m_code.group(0)
|
||||
elif "to010.asp" in clean_href:
|
||||
item_type = "session"
|
||||
m_id = re.search(r"SILFDNR=(\d+)", clean_href)
|
||||
item_id = m_id.group(1) if m_id else ""
|
||||
elif "to020.asp" in clean_href:
|
||||
item_type = "top"
|
||||
m_id = re.search(r"TOLFDNR=(\d+)", clean_href)
|
||||
item_id = m_id.group(1) if m_id else ""
|
||||
|
||||
# De-duplicate by full_url
|
||||
if not any(r["url"] == full_url for r in results):
|
||||
results.append({
|
||||
"type": item_type,
|
||||
"id": item_id,
|
||||
"code": code,
|
||||
"title": title,
|
||||
"url": full_url,
|
||||
})
|
||||
|
||||
if len(results) >= limit:
|
||||
break
|
||||
|
||||
return results
|
||||
|
||||
def get_calendar(self, days: int = 30, from_date: str = None, to_date: str = None) -> list[dict]:
|
||||
"""
|
||||
Fetch calendar meetings using native ALLRIS RSS/XML export (si010.asp).
|
||||
"""
|
||||
now = datetime.datetime.now()
|
||||
if not from_date:
|
||||
from_date = now.strftime("%d.%m.%Y")
|
||||
if not to_date:
|
||||
to_date = (now + datetime.timedelta(days=days)).strftime("%d.%m.%Y")
|
||||
|
||||
date_range = f"{from_date}-{to_date}"
|
||||
cal_url = urllib.parse.urljoin(
|
||||
self.base_url, f"si010.asp?PALFDNR=1&selfaction=ws&template=rss&kaldatvonbis={date_range}"
|
||||
)
|
||||
|
||||
resp = self.session.get(cal_url, timeout=20)
|
||||
resp.raise_for_status()
|
||||
|
||||
root = ET.fromstring(resp.content)
|
||||
sessions = []
|
||||
for elem in root.findall(".//list/element"):
|
||||
silfdnr = elem.findtext("silfdnr", "").strip()
|
||||
siname = elem.findtext("siname", "").strip()
|
||||
sitext = elem.findtext("sitext", "").strip()
|
||||
grname = elem.findtext("grname", "").strip()
|
||||
raname = elem.findtext("raname", "").strip()
|
||||
raort = elem.findtext("raort", "").strip()
|
||||
sisbvcs = elem.findtext("sisbvcs", "").strip()
|
||||
sisb = elem.findtext("sisb", "").strip()
|
||||
link_to = elem.findtext("link_to", "").strip()
|
||||
|
||||
full_link = urllib.parse.urljoin(self.base_url, link_to) if link_to else f"{self.base_url}to010.asp?SILFDNR={silfdnr}"
|
||||
|
||||
sessions.append({
|
||||
"id": silfdnr,
|
||||
"code": siname,
|
||||
"title": sitext,
|
||||
"committee": grname,
|
||||
"time": sisb,
|
||||
"iso_time": sisbvcs,
|
||||
"room": f"{raname}, {raort}" if raort else raname,
|
||||
"url": full_link,
|
||||
})
|
||||
|
||||
return sessions
|
||||
|
||||
def get_session(self, silfdnr: str) -> dict:
|
||||
"""
|
||||
Fetch session agenda items (TOPs) using ALLRIS RSS/XML web service (to010.asp).
|
||||
"""
|
||||
session_url = urllib.parse.urljoin(
|
||||
self.base_url, f"to010.asp?SILFDNR={silfdnr}&selfaction=ws&template=rss"
|
||||
)
|
||||
resp = self.session.get(session_url, timeout=20)
|
||||
resp.raise_for_status()
|
||||
|
||||
root = ET.fromstring(resp.content)
|
||||
paname = root.findtext(".//paname", "").strip()
|
||||
grname = root.findtext(".//grname", "").strip()
|
||||
grtext = root.findtext(".//grtext", "").strip()
|
||||
sisb = root.findtext(".//sisb", "").strip()
|
||||
|
||||
agenda_items = []
|
||||
for elem in root.findall(".//list/element"):
|
||||
tolfdnr = elem.findtext("tolfdnr", "").strip()
|
||||
tofnum = elem.findtext("tofnum", "").strip()
|
||||
totext1 = elem.findtext("totext1", "").strip()
|
||||
tovonr = elem.findtext("tovonr", "").strip()
|
||||
link_vo = elem.findtext("link_vo", "").strip()
|
||||
toinfo = elem.findtext("toinfo", "").strip()
|
||||
|
||||
full_vo_link = urllib.parse.urljoin(self.base_url, link_vo) if link_vo else ""
|
||||
|
||||
agenda_items.append({
|
||||
"top_id": tolfdnr,
|
||||
"top_num": tofnum,
|
||||
"subject": totext1,
|
||||
"vorlage_id": tovonr if tovonr != "0" else None,
|
||||
"vorlage_url": full_vo_link,
|
||||
"info": toinfo,
|
||||
})
|
||||
|
||||
return {
|
||||
"session_id": silfdnr,
|
||||
"institution": paname,
|
||||
"committee": grname,
|
||||
"title": grtext,
|
||||
"time": sisb,
|
||||
"url": f"{self.base_url}to010.asp?SILFDNR={silfdnr}",
|
||||
"agenda": agenda_items,
|
||||
}
|
||||
|
||||
def get_vorlage(self, volfdnr: str, convert_attachments: bool = False) -> dict:
|
||||
"""
|
||||
Fetch Vorlage details, metadata, verbatim resolution text, and attachments (vo020.asp).
|
||||
"""
|
||||
vo_url = urllib.parse.urljoin(self.base_url, f"vo020.asp?VOLFDNR={volfdnr}")
|
||||
resp = self.session.get(vo_url, timeout=20)
|
||||
resp.raise_for_status()
|
||||
|
||||
html = resp.content.decode("iso-8859-1", errors="replace")
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
|
||||
# Extract metadata table
|
||||
meta = {}
|
||||
for tr in soup.find_all("tr"):
|
||||
tds = tr.find_all("td")
|
||||
if len(tds) >= 2 and "kb1" in tds[0].get("class", []):
|
||||
k = tds[0].get_text(strip=True).rstrip(":")
|
||||
v = tds[1].get_text(" ", strip=True)
|
||||
meta[k] = v
|
||||
|
||||
# Extract Beratungsfolge (consultations)
|
||||
consultations = []
|
||||
for tr in soup.find_all("tr", class_=["zl11", "zl12"]):
|
||||
tds = [td.get_text(strip=True) for td in tr.find_all("td") if td.get_text(strip=True)]
|
||||
if len(tds) >= 2:
|
||||
consultations.append(" | ".join(tds))
|
||||
|
||||
# Extract Resolution text ("Der Stadtrat / Ausschuss beschließt:")
|
||||
resolution_text = ""
|
||||
sachverhalt_text = ""
|
||||
paragraphs = [p.get_text(strip=True) for p in soup.find_all("p") if p.get_text(strip=True)]
|
||||
|
||||
recording_res = False
|
||||
res_lines = []
|
||||
for p in paragraphs:
|
||||
if "beschließt:" in p.lower() or "beschliesst:" in p.lower() or "beschluss:" in p.lower():
|
||||
recording_res = True
|
||||
if recording_res:
|
||||
res_lines.append(p)
|
||||
if "anlagen" in p.lower() or len(res_lines) > 20:
|
||||
break
|
||||
if res_lines:
|
||||
resolution_text = "\n".join(res_lines)
|
||||
|
||||
# Extract attachments
|
||||
attachments = []
|
||||
for a in soup.find_all("a", href=re.compile(r"___tmp/tmp/[^\"]+\.pdf", re.I)):
|
||||
href = a.get("href", "")
|
||||
full_pdf_url = urllib.parse.urljoin(self.base_url, href)
|
||||
name = a.get_text(strip=True)
|
||||
size_match = re.search(r"\((\d+[\.,]?\d*\s*[kMG]B)\)", name)
|
||||
size_str = size_match.group(1) if size_match else ""
|
||||
|
||||
att_info = {
|
||||
"name": name,
|
||||
"size": size_str,
|
||||
"url": full_pdf_url,
|
||||
}
|
||||
|
||||
if convert_attachments:
|
||||
att_info["markdown"] = self._convert_pdf(full_pdf_url)
|
||||
|
||||
attachments.append(att_info)
|
||||
|
||||
return {
|
||||
"id": volfdnr,
|
||||
"url": vo_url,
|
||||
"title": meta.get("Betreff", ""),
|
||||
"status": meta.get("Status", "öffentlich"),
|
||||
"type": meta.get("Vorlage-Art", ""),
|
||||
"department": meta.get("Federführend", ""),
|
||||
"referent": meta.get("Berichterstatter/in", ""),
|
||||
"consultations": consultations,
|
||||
"resolution": resolution_text,
|
||||
"attachments": attachments,
|
||||
}
|
||||
|
||||
def _convert_pdf(self, pdf_url: str) -> str:
|
||||
"""Helper to invoke markitdown script on a PDF URL."""
|
||||
if os.path.exists(MARKITDOWN_SCRIPT):
|
||||
try:
|
||||
cmd = [sys.executable, MARKITDOWN_SCRIPT, "--url", pdf_url]
|
||||
res = subprocess.run(cmd, capture_output=True, text=True, timeout=90)
|
||||
if res.returncode == 0:
|
||||
return res.stdout.strip()
|
||||
except Exception as e:
|
||||
return f"[Fehler bei PDF-Konvertierung: {e}]"
|
||||
return "[MarkItDown Script nicht gefunden]"
|
||||
|
||||
def compile_topic(self, query: str, vault_path: str = None) -> str:
|
||||
"""
|
||||
Compile an end-to-end Obsidian-compatible markdown dossier for a given search topic.
|
||||
Optionally saves directly to knowledge-wiki/concepts/ and sources/.
|
||||
"""
|
||||
search_results = self.search(query, limit=12)
|
||||
vorlagen_data = []
|
||||
|
||||
for r in search_results:
|
||||
if r["type"] == "vorlage":
|
||||
vo = self.get_vorlage(r["id"], convert_attachments=False)
|
||||
vorlagen_data.append(vo)
|
||||
|
||||
slug = re.sub(r"[^a-zA-Z0-9\-_]", "-", query.lower().replace("ä", "ae").replace("ö", "oe").replace("ü", "ue").replace("ß", "ss"))
|
||||
today = datetime.datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
md = []
|
||||
md.append("---")
|
||||
md.append(f"id: regensburg-{slug}")
|
||||
md.append(f'title: "Stadtrat Regensburg: {query}"')
|
||||
md.append("type: concept")
|
||||
md.append("tags:")
|
||||
md.append(" - stadtpolitik")
|
||||
md.append(" - regensburg")
|
||||
md.append(f" - {slug}")
|
||||
md.append("sources:")
|
||||
md.append(f" - sources/regensburg-stadtrat/{slug}-beschluesse.md")
|
||||
md.append(f'updatedAt: "{today}"')
|
||||
md.append("---\n")
|
||||
|
||||
md.append(f"# 🏛️ Stadtrat Regensburg: {query}\n")
|
||||
md.append(f"> Automatisch kompiliertes Dossier aus dem Ratsinformationssystem (ALLRIS net) vom {today}.\n")
|
||||
|
||||
md.append("## 📌 Relevante Vorlagen & Beschlüsse\n")
|
||||
for vo in vorlagen_data:
|
||||
md.append(f"### [{vo['title']}]({vo['url']})")
|
||||
md.append(f"- **ID**: {vo['id']} | **Status**: {vo['status']} | **Art**: {vo['type']}")
|
||||
if vo["department"]:
|
||||
md.append(f"- **Federführend**: {vo['department']}")
|
||||
if vo["referent"]:
|
||||
md.append(f"- **Berichterstatter**: {vo['referent']}")
|
||||
if vo["consultations"]:
|
||||
md.append(f"- **Beratungsfolge**: {' → '.join(vo['consultations'])}")
|
||||
if vo["resolution"]:
|
||||
md.append(f"\n**Beschlussantrag / Beschlusstext:**\n```text\n{vo['resolution']}\n```\n")
|
||||
if vo["attachments"]:
|
||||
md.append("**Anlagen (PDFs):**")
|
||||
for att in vo["attachments"]:
|
||||
md.append(f"- [{att['name']}]({att['url']}) ({att['size']})")
|
||||
md.append("\n---\n")
|
||||
|
||||
md.append("## 🔗 Querverweise im Wiki")
|
||||
md.append("- [[entities/stadtrat-regensburg|Stadtrat der Stadt Regensburg]]")
|
||||
md.append("- [[entities/gertrud-maltz-schwarzfischer|Oberbürgermeisterin Gertrud Maltz-Schwarzfischer]]\n")
|
||||
|
||||
compiled_text = "\n".join(md)
|
||||
|
||||
if vault_path and os.path.exists(vault_path):
|
||||
# Write concept note
|
||||
concept_dir = os.path.join(vault_path, "concepts")
|
||||
os.makedirs(concept_dir, exist_ok=True)
|
||||
concept_file = os.path.join(concept_dir, f"{slug}.md")
|
||||
with open(concept_file, "w", encoding="utf-8") as f:
|
||||
f.write(compiled_text)
|
||||
|
||||
# Write raw source note
|
||||
sources_dir = os.path.join(vault_path, "sources", "regensburg-stadtrat")
|
||||
os.makedirs(sources_dir, exist_ok=True)
|
||||
source_file = os.path.join(sources_dir, f"{slug}-beschluesse.md")
|
||||
with open(source_file, "w", encoding="utf-8") as f:
|
||||
f.write(f"# Raw Source: {query}\n\nSearch Results from ALLRIS:\n\n" + json.dumps(search_results, indent=2, ensure_ascii=False))
|
||||
|
||||
# Append to log.md
|
||||
log_file = os.path.join(vault_path, "log.md")
|
||||
if os.path.exists(log_file):
|
||||
log_entry = f"\n## [{today}] ingest | Stadtrat Regensburg: {query}\n"
|
||||
log_entry += f"- Ingested ALLRIS search results to `sources/regensburg-stadtrat/{slug}-beschluesse.md`\n"
|
||||
log_entry += f"- Compiled concept dossier to `concepts/{slug}.md`\n"
|
||||
log_entry += f"- Found {len(vorlagen_data)} council resolutions / Vorlagen\n"
|
||||
with open(log_file, "a", encoding="utf-8") as f:
|
||||
f.write(log_entry)
|
||||
|
||||
print(f"Filed to vault: concepts/{slug}.md and sources/regensburg-stadtrat/{slug}-beschluesse.md")
|
||||
|
||||
return compiled_text
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Regensburger Sitzungsdienst (ALLRIS net) CLI")
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
# Search
|
||||
search_p = subparsers.add_parser("search", help="Full-text search in ALLRIS")
|
||||
search_p.add_argument("query", help="Search keyword (e.g. 'Kaufhof', 'Stadtbahn')")
|
||||
search_p.add_argument("--limit", type=int, default=15, help="Max results (default: 15)")
|
||||
search_p.add_argument("--json", action="store_true", help="Output raw JSON")
|
||||
|
||||
# Calendar
|
||||
cal_p = subparsers.add_parser("calendar", help="View upcoming/recent meetings")
|
||||
cal_p.add_argument("--days", type=int, default=30, help="Days ahead to fetch (default: 30)")
|
||||
cal_p.add_argument("--from-date", help="Start date (DD.MM.YYYY)")
|
||||
cal_p.add_argument("--to-date", help="End date (DD.MM.YYYY)")
|
||||
cal_p.add_argument("--json", action="store_true", help="Output raw JSON")
|
||||
|
||||
# Session
|
||||
sess_p = subparsers.add_parser("session", help="View agenda for a meeting (SILFDNR)")
|
||||
sess_p.add_argument("silfdnr", help="Session ID (SILFDNR, e.g. 2665)")
|
||||
sess_p.add_argument("--json", action="store_true", help="Output raw JSON")
|
||||
|
||||
# Vorlage
|
||||
vo_p = subparsers.add_parser("vorlage", help="View Vorlage details & resolutions (VOLFDNR)")
|
||||
vo_p.add_argument("volfdnr", help="Vorlage ID (VOLFDNR, e.g. 21362)")
|
||||
vo_p.add_argument("--convert-attachments", action="store_true", help="Convert PDF attachments to markdown using markitdown")
|
||||
vo_p.add_argument("--json", action="store_true", help="Output raw JSON")
|
||||
|
||||
# Compile Topic
|
||||
comp_p = subparsers.add_parser("compile-topic", help="Search and compile Obsidian concept note")
|
||||
comp_p.add_argument("query", help="Topic to compile (e.g. 'Kaufhof')")
|
||||
comp_p.add_argument("--vault", help="Path to knowledge-wiki vault (optional)")
|
||||
|
||||
args = parser.parse_args()
|
||||
client = AllrisClient()
|
||||
|
||||
if args.command == "search":
|
||||
res = client.search(args.query, limit=args.limit)
|
||||
if args.json:
|
||||
print(json.dumps(res, indent=2, ensure_ascii=False))
|
||||
else:
|
||||
print(f"Found {len(res)} results for '{args.query}':\n")
|
||||
for r in res:
|
||||
print(f"- [{r['type'].upper()}] {r['title']}")
|
||||
print(f" ID: {r['id']} | Code: {r.get('code', '')} | URL: {r['url']}\n")
|
||||
|
||||
elif args.command == "calendar":
|
||||
res = client.get_calendar(days=args.days, from_date=args.from_date, to_date=args.to_date)
|
||||
if args.json:
|
||||
print(json.dumps(res, indent=2, ensure_ascii=False))
|
||||
else:
|
||||
print(f"Found {len(res)} meetings:\n")
|
||||
for s in res:
|
||||
print(f"📅 {s['time']} | {s['committee']}")
|
||||
print(f" {s['title']}")
|
||||
print(f" Ort: {s['room']} | ID: {s['id']} | URL: {s['url']}\n")
|
||||
|
||||
elif args.command == "session":
|
||||
res = client.get_session(args.silfdnr)
|
||||
if args.json:
|
||||
print(json.dumps(res, indent=2, ensure_ascii=False))
|
||||
else:
|
||||
print(f"🏛️ {res['committee']}: {res['title']} ({res['time']})")
|
||||
print(f"URL: {res['url']}\n")
|
||||
print("Tagesordnung (TOPs):")
|
||||
for item in res["agenda"]:
|
||||
vo_str = f" [Vorlage: {item['vorlage_id']}]" if item["vorlage_id"] else ""
|
||||
print(f" TOP {item['top_num']}: {item['subject']}{vo_str}")
|
||||
if item["info"]:
|
||||
print(f" Info: {item['info']}")
|
||||
|
||||
elif args.command == "vorlage":
|
||||
res = client.get_vorlage(args.volfdnr, convert_attachments=args.convert_attachments)
|
||||
if args.json:
|
||||
print(json.dumps(res, indent=2, ensure_ascii=False))
|
||||
else:
|
||||
print(f"📄 Vorlage {res['id']}: {res['title']}")
|
||||
print(f"Typ: {res['type']} | Status: {res['status']} | Federführend: {res['department']}")
|
||||
if res["referent"]:
|
||||
print(f"Berichterstatter: {res['referent']}")
|
||||
print(f"URL: {res['url']}\n")
|
||||
if res["consultations"]:
|
||||
print("Beratungsfolge:")
|
||||
for c in res["consultations"]:
|
||||
print(f" • {c}")
|
||||
print()
|
||||
if res["resolution"]:
|
||||
print(f"Beschlusstext:\n{res['resolution']}\n")
|
||||
if res["attachments"]:
|
||||
print("Anlagen:")
|
||||
for a in res["attachments"]:
|
||||
print(f" 📎 {a['name']} ({a['size']}) -> {a['url']}")
|
||||
if "markdown" in a:
|
||||
print(f"\n--- Extrahierter Markdown-Inhalt ({a['name']}) ---\n{a['markdown'][:500]}...\n")
|
||||
|
||||
elif args.command == "compile-topic":
|
||||
res = client.compile_topic(args.query, vault_path=args.vault)
|
||||
if not args.vault:
|
||||
print(res)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,233 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# dependencies = [
|
||||
# "beautifulsoup4>=4.12.0",
|
||||
# "requests>=2.31.0",
|
||||
# ]
|
||||
# ///
|
||||
"""
|
||||
Regensburg Stadtrat (ALLRIS) MCP Server
|
||||
Exposes municipal council documents, upcoming meetings, agenda items (TOPs),
|
||||
and binding resolutions as Model Context Protocol (MCP) tools over stdio.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import traceback
|
||||
|
||||
# Ensure current directory is on sys.path to import AllrisClient
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from regensburg_ris import AllrisClient
|
||||
|
||||
TOOLS = [
|
||||
{
|
||||
"name": "stadtrat_search",
|
||||
"description": "Search Regensburg city council documents, resolutions (Beschlüsse), and motions (Vorlagen) by keyword (e.g. 'Kaufhof', 'Protzenweiherbrücke', 'Stadtbahn', 'Haushalt').",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Keyword to search in ALLRIS net (e.g. 'Brücke', 'Kaufhof', 'Verkehr')."
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of results to return (default: 10).",
|
||||
"default": 10
|
||||
}
|
||||
},
|
||||
"required": ["query"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "stadtrat_calendar",
|
||||
"description": "Fetch upcoming municipal council and committee meetings (Stadtrat, Ausschüsse) in Regensburg.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"days": {
|
||||
"type": "integer",
|
||||
"description": "Number of days in the future to search (default: 30).",
|
||||
"default": 30
|
||||
},
|
||||
"from_date": {
|
||||
"type": "string",
|
||||
"description": "Start date in DD.MM.YYYY format (default: today)."
|
||||
},
|
||||
"to_date": {
|
||||
"type": "string",
|
||||
"description": "End date in DD.MM.YYYY format."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "stadtrat_session",
|
||||
"description": "Fetch detailed meeting agenda items (Tagesordnungspunkte / TOPs) and linked Vorlagen IDs for a meeting ID (SILFDNR).",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"session_id": {
|
||||
"type": "string",
|
||||
"description": "ALLRIS session ID (SILFDNR, e.g. '2665')."
|
||||
}
|
||||
},
|
||||
"required": ["session_id"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "stadtrat_vorlage",
|
||||
"description": "Inspect a specific council proposal (Vorlage) by VOLFDNR ID: extracts voting outcomes, responsible department, and verbatim resolution text.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"vorlage_id": {
|
||||
"type": "string",
|
||||
"description": "ALLRIS proposal ID (VOLFDNR, e.g. '21362')."
|
||||
},
|
||||
"convert_attachments": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to convert linked PDF attachments to Markdown (default: false).",
|
||||
"default": False
|
||||
}
|
||||
},
|
||||
"required": ["vorlage_id"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "stadtrat_compile_topic",
|
||||
"description": "Compile a multi-document synthesis of a topic across ALLRIS into the Obsidian knowledge-wiki.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"topic": {
|
||||
"type": "string",
|
||||
"description": "Topic keyword (e.g. 'Protzenweiherbrücke', 'Kaufhof')."
|
||||
},
|
||||
"vault": {
|
||||
"type": "string",
|
||||
"description": "Path to the Obsidian knowledge-wiki vault.",
|
||||
"default": "/home/node/.openclaw/wiki/main"
|
||||
}
|
||||
},
|
||||
"required": ["topic"]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def run_mcp_server():
|
||||
client = AllrisClient()
|
||||
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
req = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
method = req.get("method")
|
||||
msg_id = req.get("id")
|
||||
params = req.get("params", {})
|
||||
|
||||
if method == "initialize":
|
||||
resp = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": msg_id,
|
||||
"result": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"serverInfo": {
|
||||
"name": "regensburg-stadtrat-mcp",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"capabilities": {
|
||||
"tools": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
sys.stdout.write(json.dumps(resp) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
elif method == "notifications/initialized":
|
||||
pass
|
||||
|
||||
elif method == "tools/list":
|
||||
resp = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": msg_id,
|
||||
"result": {
|
||||
"tools": TOOLS
|
||||
}
|
||||
}
|
||||
sys.stdout.write(json.dumps(resp) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
elif method == "tools/call":
|
||||
tool_name = params.get("name")
|
||||
args = params.get("arguments", {})
|
||||
try:
|
||||
if tool_name == "stadtrat_search":
|
||||
data = client.search(
|
||||
query=args.get("query", ""),
|
||||
limit=int(args.get("limit", 10))
|
||||
)
|
||||
elif tool_name == "stadtrat_calendar":
|
||||
data = client.get_calendar(
|
||||
days=int(args.get("days", 30)),
|
||||
from_date=args.get("from_date"),
|
||||
to_date=args.get("to_date")
|
||||
)
|
||||
elif tool_name == "stadtrat_session":
|
||||
data = client.get_session(
|
||||
silfdnr=str(args.get("session_id", ""))
|
||||
)
|
||||
elif tool_name == "stadtrat_vorlage":
|
||||
data = client.get_vorlage(
|
||||
volfdnr=str(args.get("vorlage_id", "")),
|
||||
convert_attachments=bool(args.get("convert_attachments", False))
|
||||
)
|
||||
elif tool_name == "stadtrat_compile_topic":
|
||||
vault_path = args.get("vault", "/home/node/.openclaw/wiki/main")
|
||||
data = client.compile_topic(
|
||||
topic=args.get("topic", ""),
|
||||
vault=vault_path
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unknown tool: {tool_name}")
|
||||
|
||||
text_result = json.dumps(data, indent=2, ensure_ascii=False)
|
||||
resp = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": msg_id,
|
||||
"result": {
|
||||
"content": [{"type": "text", "text": text_result}]
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
resp = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": msg_id,
|
||||
"result": {
|
||||
"isError": True,
|
||||
"content": [{"type": "text", "text": f"Error executing {tool_name}: {str(e)}\n{traceback.format_exc()}"}]
|
||||
}
|
||||
}
|
||||
sys.stdout.write(json.dumps(resp) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
else:
|
||||
if msg_id is not None:
|
||||
resp = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": msg_id,
|
||||
"error": {"code": -32601, "message": f"Method not found: {method}"}
|
||||
}
|
||||
sys.stdout.write(json.dumps(resp) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_mcp_server()
|
||||
@@ -0,0 +1,132 @@
|
||||
---
|
||||
name: shopping
|
||||
description: Intelligent shopping advisory, price comparisons across German supermarkets (Edeka, Rewe, Aldi, Lidl, Kaufland, Netto, Penny, dm, Rossmann), Geizhals retail best prices & price history, eBay marketplace searches, and Bring! shopping list management (Einkaufsladen, Baumarkt, Ikea, Gewuerzeinkaufsliste). Use when the user asks for shopping advice, supermarket offers, adding items to their shopping lists, or checking off items.
|
||||
---
|
||||
|
||||
# 🛒 Smart Shopper & Vorrats-Concierge
|
||||
|
||||
Comprehensive shopping advisor for the Graf family. Combines live German supermarket weekly brochures (Regensburg default), Geizhals retail price comparison & price histories, and eBay marketplace searches.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ MCP Tools Overview
|
||||
|
||||
### 1. Supermarket & Grocery Deals (`supermarket`)
|
||||
- **`supermarket_search(query, city="Regensburg", lat=49.0134, lng=12.1016)`**:
|
||||
- Searches live weekly offers and digital brochures across Edeka, Rewe, Aldi Süd, Lidl, Kaufland, Netto Marken-Discount, Penny, dm, Rossmann, V-Markt, Müller.
|
||||
- Returns price in EUR, retailer, brand, validity dates, distance in km, and brochure page.
|
||||
- **`supermarket_compare_staples(staples=["Mutti", "Oro di Parma"], city="Regensburg")`**:
|
||||
- Batch search for multiple staple brands or product categories to compare discounts across all local supermarkets in one view.
|
||||
|
||||
### 2. Retail Price Comparison & History (`geizhals`)
|
||||
- **`geizhals_search(query, country="de", limit=8)`**:
|
||||
- Full-text search across Geizhals.de (and .at). Returns the best price, average price, offer count across all web shops, cheapest shop name, star ratings, and test review count & metascore.
|
||||
- **`geizhals_price_history(product_id, days=31, country="de")`**:
|
||||
- Analyzes 31, 91, 183, or 365-day price trends. Reports `period_min_price`, `current_best_price`, and flags if it is an `is_all_time_low`.
|
||||
|
||||
### 3. Marketplace & Second-Hand (`ebay`)
|
||||
- **`ebay_search(query, condition, buying_format, min_price, max_price, limit=10)`**:
|
||||
- Searches eBay.de using the official Browse API. Supports filtering by condition (`used`/`refurbished`/`new`) and buying format (`fixed_price`/`auction`).
|
||||
- **`ebay_get_item(item_id)`**:
|
||||
- Fetches listing details, photos, location, and seller reputation.
|
||||
|
||||
### 4. Bring! Shopping Lists (`bring`)
|
||||
- **`bring_add_item(item_name, specification="", list_name="Einkaufsladen")`**:
|
||||
- Adds an item with optional quantity/specification (e.g. `specification="2L"`, `"100 Stück"`) to the target list.
|
||||
- **`bring_add_batch(items=[{"name": "...", "specification": "..."}], list_name="Einkaufsladen")`**:
|
||||
- Adds multiple items at once (ideal for recipe ingredients or bulk additions).
|
||||
- **`bring_get_items(list_name="Einkaufsladen")`**:
|
||||
- Retrieves active to-buy items and recently bought items for the specified list.
|
||||
- **`bring_complete_item(item_name, list_name="Einkaufsladen")`**:
|
||||
- Marks an item as purchased/completed (moves it to "recently bought").
|
||||
- **`bring_remove_item(item_name, list_name="Einkaufsladen")`**:
|
||||
- Completely deletes an item from the list.
|
||||
- **`bring_list_lists()`**:
|
||||
- Lists all Bring! shopping lists with their UUIDs and item counts.
|
||||
|
||||
---
|
||||
|
||||
## 🍅 Vorratskauf & Supermarkt Protocol
|
||||
|
||||
When the user asks for groceries, weekly offers, or staples to stock up on:
|
||||
|
||||
### 1. Multi-Brand / Equivalency Queries (e.g. „Mutti oder Oro Tomatensauce“)
|
||||
- Always run a comparative search (using `supermarket_compare_staples` or parallel queries).
|
||||
- Group results by retailer and rank by best price.
|
||||
- **Price Benchmarks for Staples**:
|
||||
- **Tomaten / Passata / Polpa (Mutti / Oro di Parma)**:
|
||||
- Normal: 1,69 € – 1,99 €
|
||||
- 🟢 **Top-Vorratspreis**: $\le$ 1,10 € (z.B. Netto 0,88 € oder Penny 1,19 €)
|
||||
- **Marken-Pasta (Barilla / De Cecco)**:
|
||||
- Normal: 1,89 € – 2,29 €
|
||||
- 🟢 **Top-Vorratspreis**: $\le$ 0,99 €
|
||||
- **Kaffee ganze Bohnen (Lavazza, Segafredo, Melitta)**:
|
||||
- Normal: 14,99 € – 17,99 € / kg
|
||||
- 🟢 **Top-Vorratspreis**: $\le$ 11,99 € / kg
|
||||
|
||||
### 2. Windeln Größe X (Stückpreis-Berechnung)
|
||||
Windel-Packungsgrößen variieren stark (Single-Pack vs. XXL-Box vs. Monatsbox).
|
||||
- **Berechne immer den Cent-Preis pro Windel**:
|
||||
$$\text{Stückpreis} = \frac{\text{Gesamtpreis}}{\text{Stückzahl}} \times 100\ \text{ct}$$
|
||||
- **Orientierungswerte**:
|
||||
- **Eigenmarke** (Lupilu / babylove): **11 – 14 ct / Windel**
|
||||
- **Markenwindeln** (Pampers Baby-Dry): **18 – 24 ct / Windel** (über 26 ct ist teuer)
|
||||
- Beispiel-Antwort: *„Bei Lidl gibt es Lupilu Gr. 4 (84 Stück) für 10,35 € (12,3 ct/Windel). Bei V-Markt sind Pampers Pants für 6,99 € im Angebot.“*
|
||||
|
||||
### 3. Location & Store Distance
|
||||
- Default location is **Regensburg** (Lat: `49.0134`, Lng: `12.1016`).
|
||||
- Mention the retailer and approximate distance (e.g. *„Netto Marken-Discount (0,5 km)“* oder *„Penny (1,4 km)“*).
|
||||
- Mention the validity dates (z.B. *„Gültig bis Samstag, 12.09.“*).
|
||||
|
||||
---
|
||||
|
||||
## 💻 Tech & Electronics Advisory Protocol
|
||||
|
||||
When the user asks whether to buy a gadget, phone, laptop, or appliance:
|
||||
1. **Model & Quality Check**:
|
||||
- Inspect `test_reviews_count` and `test_reviews_metascore` in `geizhals_search`.
|
||||
- If needed, run a quick web search on Stiftung Warentest / Chip / Reddit for common faults.
|
||||
2. **Timing & Price History Check**:
|
||||
- Run `geizhals_price_history(product_id)`.
|
||||
- If `is_all_time_low` is true: *„🟢 Aktuell auf Tiefstpreis-Niveau!“*
|
||||
- If significantly higher than `period_min_price`: advise waiting or setting a price alert.
|
||||
3. **Refurbished / B-Ware Option**:
|
||||
- Query `ebay_search` with `condition="refurbished"` or `"used"` to compare savings against brand-new retail.
|
||||
|
||||
---
|
||||
|
||||
## 📝 Bring! Shopping Lists Protocol
|
||||
|
||||
OpenClaw manages the Graf family's Bring! shopping lists using the `bring` MCP tools:
|
||||
- **`bring_add_item(item_name, specification="", list_name="Einkaufsladen")`**
|
||||
- **`bring_add_batch(items=[{"name": "...", "specification": "..."}], list_name="Einkaufsladen")`**
|
||||
- **`bring_get_items(list_name="Einkaufsladen")`**
|
||||
- **`bring_complete_item(item_name, list_name="Einkaufsladen")`**
|
||||
- **`bring_remove_item(item_name, list_name="Einkaufsladen")`**
|
||||
- **`bring_list_lists()`**
|
||||
|
||||
### 1. List Routing & Defaults
|
||||
The family maintains multiple distinct lists in Bring!:
|
||||
1. **`Einkaufsladen`** (**Default**): Groceries, supermarket items, drugstore (dm/Rossmann), household staples. Used for all general shopping queries unless another store is explicitly mentioned.
|
||||
2. **`Baumarkt`**: Hardware store items, DIY supplies, tools, screws/nails, timber, paint, plumbing, garden supplies.
|
||||
3. **`Ikea`**: Furniture, home decor, Swedish food market, home organization items.
|
||||
4. **`Gewuerzeinkaufsliste`**: Dedicated list for spices, herbs, and seasonings.
|
||||
|
||||
### 2. Interaction Rules
|
||||
- **Adding Items**:
|
||||
- *"Pack Milch auf die Einkaufsliste"* -> `bring_add_item(item_name="Milch", list_name="Einkaufsladen")`
|
||||
- *"Schreib 50er Spax Schrauben auf die Baumarkt-Liste"* -> `bring_add_item(item_name="Spax Schrauben", specification="50er", list_name="Baumarkt")`
|
||||
- *"Wir brauchen Koriander und Kardamom"* -> `bring_add_item` with `list_name="Gewuerzeinkaufsliste"` (or `bring_add_batch`)
|
||||
- *"Brauchen neue Kissenbezüge von Ikea"* -> `bring_add_item(item_name="Kissenbezüge", list_name="Ikea")`
|
||||
- **Confirmation**:
|
||||
- Always give a concise confirmation with markdown bolding:
|
||||
- *„✅ **Milch** (3.8%) auf die Liste **Einkaufsladen** gesetzt.“*
|
||||
- *„✅ **Spax Schrauben** (50er) auf die Liste **Baumarkt** gesetzt.“*
|
||||
- **Reading / Checking**:
|
||||
- When asked *"Was steht auf der Einkaufsliste?"*: call `bring_get_items(list_name="Einkaufsladen")` and summarize the items clearly.
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Presentation Rules
|
||||
- Follow OpenClaw's SOUL protocol: Hide hyperlinks behind descriptive text (`[Händler (Preis €)](url)`).
|
||||
- Never send raw search URLs or unverified Google links.
|
||||
@@ -0,0 +1,553 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Bring! Shopping Lists MCP Server & CLI Tool
|
||||
Interacts with the unofficial Bring! Shopping List API using bring-api.
|
||||
Provides single-turn MCP tools and CLI utilities to manage multiple shopping lists:
|
||||
- "Einkaufsladen" (Default for groceries / normal shopping)
|
||||
- "Baumarkt" (Hardware store, DIY, tools, repairs)
|
||||
- "Ikea" (Furniture, home decor, Swedish goods)
|
||||
- "Gewuerzeinkaufsliste" (Spices and seasoning shopping)
|
||||
Supports dynamic matching against any other list present in the user's Bring! account.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import asyncio
|
||||
from typing import Dict, Any, List, Optional, Tuple
|
||||
|
||||
import aiohttp
|
||||
from bring_api import Bring
|
||||
|
||||
DEFAULT_LIST_NAME = os.environ.get("BRING_DEFAULT_LIST", "Einkaufsladen")
|
||||
|
||||
# Common aliases mapping user shorthand / natural language to canonical list names
|
||||
LIST_ALIASES: Dict[str, str] = {
|
||||
"normal": "Einkaufsladen",
|
||||
"normal shopping": "Einkaufsladen",
|
||||
"normale shopping": "Einkaufsladen",
|
||||
"einkaufen": "Einkaufsladen",
|
||||
"einkauf": "Einkaufsladen",
|
||||
"einkaufsladen": "Einkaufsladen",
|
||||
"lebensmittel": "Einkaufsladen",
|
||||
"supermarkt": "Einkaufsladen",
|
||||
"groceries": "Einkaufsladen",
|
||||
"shopping": "Einkaufsladen",
|
||||
"baumarkt": "Baumarkt",
|
||||
"diy": "Baumarkt",
|
||||
"hardware": "Baumarkt",
|
||||
"werkzeug": "Baumarkt",
|
||||
"obi": "Baumarkt",
|
||||
"bauhaus": "Baumarkt",
|
||||
"hornbach": "Baumarkt",
|
||||
"ikea": "Ikea",
|
||||
"möbel": "Ikea",
|
||||
"moebel": "Ikea",
|
||||
"gewürz": "Gewuerzeinkaufsliste",
|
||||
"gewuerz": "Gewuerzeinkaufsliste",
|
||||
"gewürze": "Gewuerzeinkaufsliste",
|
||||
"gewuerze": "Gewuerzeinkaufsliste",
|
||||
"gewürzeinkaufsliste": "Gewuerzeinkaufsliste",
|
||||
"gewuerzeinkaufsliste": "Gewuerzeinkaufsliste",
|
||||
"spices": "Gewuerzeinkaufsliste",
|
||||
}
|
||||
|
||||
|
||||
class BringClientManager:
|
||||
"""Manages Bring API session, authentication, and list name resolution."""
|
||||
|
||||
def __init__(self, email: Optional[str] = None, password: Optional[str] = None):
|
||||
self.email = email or os.environ.get("BRING_EMAIL") or os.environ.get("MAIL")
|
||||
self.password = password or os.environ.get("BRING_PASSWORD") or os.environ.get("PW")
|
||||
self.default_list = DEFAULT_LIST_NAME
|
||||
self._lists_cache: List[Dict[str, Any]] = []
|
||||
|
||||
def _check_credentials(self):
|
||||
if not self.email or not self.password:
|
||||
raise ValueError(
|
||||
"Missing Bring credentials. Please set BRING_EMAIL and BRING_PASSWORD environment variables."
|
||||
)
|
||||
|
||||
async def _get_client_and_session(self) -> Tuple[Bring, aiohttp.ClientSession]:
|
||||
self._check_credentials()
|
||||
session = aiohttp.ClientSession()
|
||||
client = Bring(session, self.email, self.password)
|
||||
await client.login()
|
||||
return client, session
|
||||
|
||||
async def load_lists(self) -> List[Dict[str, Any]]:
|
||||
client, session = await self._get_client_and_session()
|
||||
async with session:
|
||||
res = await client.load_lists()
|
||||
lists_data = []
|
||||
for lst in getattr(res, "lists", res):
|
||||
lists_data.append({
|
||||
"listUuid": getattr(lst, "listUuid", lst.get("listUuid") if isinstance(lst, dict) else None),
|
||||
"name": getattr(lst, "name", lst.get("name") if isinstance(lst, dict) else None),
|
||||
"theme": getattr(lst, "theme", lst.get("theme") if isinstance(lst, dict) else None),
|
||||
})
|
||||
self._lists_cache = lists_data
|
||||
return lists_data
|
||||
|
||||
async def resolve_list(self, list_name: Optional[str] = None) -> Tuple[str, str]:
|
||||
"""
|
||||
Resolves a user-provided or alias list name to (listUuid, canonical_name).
|
||||
Falls back to default_list ('Einkaufsladen') if omitted or not matched.
|
||||
"""
|
||||
raw_name = (list_name or "").strip()
|
||||
alias_target = LIST_ALIASES.get(raw_name.lower()) if raw_name else self.default_list
|
||||
target_name = alias_target or raw_name or self.default_list
|
||||
|
||||
if not self._lists_cache:
|
||||
await self.load_lists()
|
||||
|
||||
# 1. Exact match against canonical / loaded names
|
||||
for item in self._lists_cache:
|
||||
if item["name"].lower() == target_name.lower():
|
||||
return item["listUuid"], item["name"]
|
||||
|
||||
# 2. Substring match
|
||||
for item in self._lists_cache:
|
||||
if target_name.lower() in item["name"].lower() or item["name"].lower() in target_name.lower():
|
||||
return item["listUuid"], item["name"]
|
||||
|
||||
# 3. Fallback to default list
|
||||
for item in self._lists_cache:
|
||||
if item["name"].lower() == self.default_list.lower():
|
||||
return item["listUuid"], item["name"]
|
||||
|
||||
# 4. If default list not found, pick first list available
|
||||
if self._lists_cache:
|
||||
first = self._lists_cache[0]
|
||||
return first["listUuid"], first["name"]
|
||||
|
||||
raise ValueError(f"No shopping lists available in Bring account for target '{target_name}'")
|
||||
|
||||
async def get_items(self, list_name: Optional[str] = None) -> Dict[str, Any]:
|
||||
client, session = await self._get_client_and_session()
|
||||
async with session:
|
||||
res = await client.load_lists()
|
||||
self._lists_cache = [
|
||||
{
|
||||
"listUuid": getattr(l, "listUuid", l.get("listUuid") if isinstance(l, dict) else None),
|
||||
"name": getattr(l, "name", l.get("name") if isinstance(l, dict) else None),
|
||||
}
|
||||
for l in getattr(res, "lists", res)
|
||||
]
|
||||
list_uuid, canonical_name = await self.resolve_list(list_name)
|
||||
list_details = await client.get_list(list_uuid)
|
||||
|
||||
items_container = getattr(list_details, "items", list_details)
|
||||
raw_purchase = getattr(items_container, "purchase", []) or []
|
||||
raw_recently = getattr(items_container, "recently", []) or []
|
||||
|
||||
# Purchase items (active to-buy)
|
||||
purchase_items = []
|
||||
for item in raw_purchase:
|
||||
name = getattr(item, "itemId", getattr(item, "name", str(item)))
|
||||
spec = getattr(item, "specification", "") or ""
|
||||
purchase_items.append({
|
||||
"name": name,
|
||||
"specification": spec or None,
|
||||
})
|
||||
|
||||
# Recently bought items
|
||||
recently_items = []
|
||||
for item in raw_recently:
|
||||
name = getattr(item, "itemId", getattr(item, "name", str(item)))
|
||||
spec = getattr(item, "specification", "") or ""
|
||||
recently_items.append({
|
||||
"name": name,
|
||||
"specification": spec or None,
|
||||
})
|
||||
|
||||
return {
|
||||
"list": canonical_name,
|
||||
"listUuid": list_uuid,
|
||||
"count": len(purchase_items),
|
||||
"items": purchase_items,
|
||||
"recently": recently_items,
|
||||
}
|
||||
|
||||
async def add_item(self, item_name: str, specification: str = "", list_name: Optional[str] = None) -> Dict[str, Any]:
|
||||
client, session = await self._get_client_and_session()
|
||||
async with session:
|
||||
res = await client.load_lists()
|
||||
self._lists_cache = [
|
||||
{
|
||||
"listUuid": getattr(l, "listUuid", l.get("listUuid") if isinstance(l, dict) else None),
|
||||
"name": getattr(l, "name", l.get("name") if isinstance(l, dict) else None),
|
||||
}
|
||||
for l in getattr(res, "lists", res)
|
||||
]
|
||||
list_uuid, canonical_name = await self.resolve_list(list_name)
|
||||
await client.save_item(list_uuid, item_name.strip(), specification.strip())
|
||||
return {
|
||||
"success": True,
|
||||
"action": "added",
|
||||
"item": item_name.strip(),
|
||||
"specification": specification.strip() or None,
|
||||
"list": canonical_name,
|
||||
"listUuid": list_uuid,
|
||||
}
|
||||
|
||||
async def add_batch(self, items: List[Dict[str, str]], list_name: Optional[str] = None) -> Dict[str, Any]:
|
||||
client, session = await self._get_client_and_session()
|
||||
async with session:
|
||||
res = await client.load_lists()
|
||||
self._lists_cache = [
|
||||
{
|
||||
"listUuid": getattr(l, "listUuid", l.get("listUuid") if isinstance(l, dict) else None),
|
||||
"name": getattr(l, "name", l.get("name") if isinstance(l, dict) else None),
|
||||
}
|
||||
for l in getattr(res, "lists", res)
|
||||
]
|
||||
list_uuid, canonical_name = await self.resolve_list(list_name)
|
||||
results = []
|
||||
for entry in items:
|
||||
name = entry.get("name") or entry.get("itemName") or ""
|
||||
spec = entry.get("specification") or entry.get("spec") or ""
|
||||
if name.strip():
|
||||
await client.save_item(list_uuid, name.strip(), spec.strip())
|
||||
results.append({"name": name.strip(), "specification": spec.strip() or None})
|
||||
return {
|
||||
"success": True,
|
||||
"action": "batch_added",
|
||||
"count": len(results),
|
||||
"items": results,
|
||||
"list": canonical_name,
|
||||
"listUuid": list_uuid,
|
||||
}
|
||||
|
||||
async def complete_item(self, item_name: str, list_name: Optional[str] = None) -> Dict[str, Any]:
|
||||
client, session = await self._get_client_and_session()
|
||||
async with session:
|
||||
res = await client.load_lists()
|
||||
self._lists_cache = [
|
||||
{
|
||||
"listUuid": getattr(l, "listUuid", l.get("listUuid") if isinstance(l, dict) else None),
|
||||
"name": getattr(l, "name", l.get("name") if isinstance(l, dict) else None),
|
||||
}
|
||||
for l in getattr(res, "lists", res)
|
||||
]
|
||||
list_uuid, canonical_name = await self.resolve_list(list_name)
|
||||
await client.complete_item(list_uuid, item_name.strip())
|
||||
return {
|
||||
"success": True,
|
||||
"action": "completed",
|
||||
"item": item_name.strip(),
|
||||
"list": canonical_name,
|
||||
"listUuid": list_uuid,
|
||||
}
|
||||
|
||||
async def remove_item(self, item_name: str, list_name: Optional[str] = None) -> Dict[str, Any]:
|
||||
client, session = await self._get_client_and_session()
|
||||
async with session:
|
||||
res = await client.load_lists()
|
||||
self._lists_cache = [
|
||||
{
|
||||
"listUuid": getattr(l, "listUuid", l.get("listUuid") if isinstance(l, dict) else None),
|
||||
"name": getattr(l, "name", l.get("name") if isinstance(l, dict) else None),
|
||||
}
|
||||
for l in getattr(res, "lists", res)
|
||||
]
|
||||
list_uuid, canonical_name = await self.resolve_list(list_name)
|
||||
await client.remove_item(list_uuid, item_name.strip())
|
||||
return {
|
||||
"success": True,
|
||||
"action": "removed",
|
||||
"item": item_name.strip(),
|
||||
"list": canonical_name,
|
||||
"listUuid": list_uuid,
|
||||
}
|
||||
|
||||
|
||||
# MCP Server Implementation
|
||||
MCP_TOOLS = [
|
||||
{
|
||||
"name": "bring_list_lists",
|
||||
"description": "List all shopping lists available in the Bring! account (e.g. Einkaufsladen, Baumarkt, Ikea, Gewuerzeinkaufsliste).",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "bring_get_items",
|
||||
"description": "Get all active to-buy items on a Bring! shopping list. Defaults to 'Einkaufsladen'.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"list_name": {
|
||||
"type": "string",
|
||||
"description": "Target list name or alias ('Einkaufsladen' [default for normal shopping], 'Baumarkt' [hardware/DIY], 'Ikea', 'Gewuerzeinkaufsliste').",
|
||||
"default": "Einkaufsladen",
|
||||
}
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "bring_add_item",
|
||||
"description": "Add an item to a Bring! shopping list. Defaults to 'Einkaufsladen' (normal shopping). Use 'Baumarkt' for hardware/tools, 'Ikea' for furniture/decor, or 'Gewuerzeinkaufsliste' for spices.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"item_name": {
|
||||
"type": "string",
|
||||
"description": "Name of the item to add (e.g. 'Milch', 'Haferflocken', 'Spax Schrauben 4x40', 'Zimt').",
|
||||
},
|
||||
"specification": {
|
||||
"type": "string",
|
||||
"description": "Optional specification or quantity (e.g. '2L', '3.8%', '100 Stück', 'Bio').",
|
||||
"default": "",
|
||||
},
|
||||
"list_name": {
|
||||
"type": "string",
|
||||
"description": "Target list name or alias ('Einkaufsladen' [default], 'Baumarkt', 'Ikea', 'Gewuerzeinkaufsliste').",
|
||||
"default": "Einkaufsladen",
|
||||
},
|
||||
},
|
||||
"required": ["item_name"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "bring_add_batch",
|
||||
"description": "Add multiple items to a Bring! shopping list at once. Useful for recipes or ingredient lists.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"description": "List of objects with 'name' and optional 'specification' (e.g. [{'name': 'Eier', 'specification': '10er'}, {'name': 'Butter'}])",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string", "description": "Item name"},
|
||||
"specification": {"type": "string", "description": "Quantity or details", "default": ""},
|
||||
},
|
||||
"required": ["name"],
|
||||
},
|
||||
},
|
||||
"list_name": {
|
||||
"type": "string",
|
||||
"description": "Target list name ('Einkaufsladen' [default], 'Baumarkt', 'Ikea', 'Gewuerzeinkaufsliste').",
|
||||
"default": "Einkaufsladen",
|
||||
},
|
||||
},
|
||||
"required": ["items"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "bring_complete_item",
|
||||
"description": "Mark an item as purchased / completed (moves it to recently bought) on a Bring! shopping list.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"item_name": {
|
||||
"type": "string",
|
||||
"description": "Exact name of the item to mark as completed.",
|
||||
},
|
||||
"list_name": {
|
||||
"type": "string",
|
||||
"description": "Target list name ('Einkaufsladen' [default], 'Baumarkt', 'Ikea', 'Gewuerzeinkaufsliste').",
|
||||
"default": "Einkaufsladen",
|
||||
},
|
||||
},
|
||||
"required": ["item_name"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "bring_remove_item",
|
||||
"description": "Completely remove an item from a Bring! shopping list.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"item_name": {
|
||||
"type": "string",
|
||||
"description": "Exact name of the item to delete completely.",
|
||||
},
|
||||
"list_name": {
|
||||
"type": "string",
|
||||
"description": "Target list name ('Einkaufsladen' [default], 'Baumarkt', 'Ikea', 'Gewuerzeinkaufsliste').",
|
||||
"default": "Einkaufsladen",
|
||||
},
|
||||
},
|
||||
"required": ["item_name"],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def run_mcp_server():
|
||||
"""Runs standard MCP stdio JSON-RPC loop."""
|
||||
mgr = BringClientManager()
|
||||
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
msg = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
msg_id = msg.get("id")
|
||||
method = msg.get("method")
|
||||
params = msg.get("params", {})
|
||||
|
||||
if method == "initialize":
|
||||
resp = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": msg_id,
|
||||
"result": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {"tools": {}},
|
||||
"serverInfo": {"name": "bring-mcp-server", "version": "1.0.0"},
|
||||
},
|
||||
}
|
||||
sys.stdout.write(json.dumps(resp) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
elif method == "notifications/initialized":
|
||||
pass
|
||||
|
||||
elif method == "ping":
|
||||
resp = {"jsonrpc": "2.0", "id": msg_id, "result": {}}
|
||||
sys.stdout.write(json.dumps(resp) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
elif method == "tools/list":
|
||||
resp = {"jsonrpc": "2.0", "id": msg_id, "result": {"tools": MCP_TOOLS}}
|
||||
sys.stdout.write(json.dumps(resp) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
elif method == "tools/call":
|
||||
tool_name = params.get("name")
|
||||
args = params.get("arguments", {})
|
||||
|
||||
try:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
if tool_name == "bring_list_lists":
|
||||
data = loop.run_until_complete(mgr.load_lists())
|
||||
elif tool_name == "bring_get_items":
|
||||
data = loop.run_until_complete(mgr.get_items(list_name=args.get("list_name")))
|
||||
elif tool_name == "bring_add_item":
|
||||
data = loop.run_until_complete(
|
||||
mgr.add_item(
|
||||
item_name=args.get("item_name", ""),
|
||||
specification=args.get("specification", ""),
|
||||
list_name=args.get("list_name"),
|
||||
)
|
||||
)
|
||||
elif tool_name == "bring_add_batch":
|
||||
data = loop.run_until_complete(
|
||||
mgr.add_batch(items=args.get("items", []), list_name=args.get("list_name"))
|
||||
)
|
||||
elif tool_name == "bring_complete_item":
|
||||
data = loop.run_until_complete(
|
||||
mgr.complete_item(item_name=args.get("item_name", ""), list_name=args.get("list_name"))
|
||||
)
|
||||
elif tool_name == "bring_remove_item":
|
||||
data = loop.run_until_complete(
|
||||
mgr.remove_item(item_name=args.get("item_name", ""), list_name=args.get("list_name"))
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unknown tool: {tool_name}")
|
||||
|
||||
loop.close()
|
||||
text_result = json.dumps(data, indent=2, ensure_ascii=False)
|
||||
resp = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": msg_id,
|
||||
"result": {"content": [{"type": "text", "text": text_result}]},
|
||||
}
|
||||
except Exception as e:
|
||||
resp = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": msg_id,
|
||||
"result": {
|
||||
"isError": True,
|
||||
"content": [{"type": "text", "text": f"Error executing {tool_name}: {str(e)}"}],
|
||||
},
|
||||
}
|
||||
sys.stdout.write(json.dumps(resp) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
else:
|
||||
if msg_id is not None:
|
||||
resp = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": msg_id,
|
||||
"error": {"code": -32601, "message": f"Method not found: {method}"},
|
||||
}
|
||||
sys.stdout.write(json.dumps(resp) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def run_cli():
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Bring! Shopping Lists MCP & CLI Tool")
|
||||
subparsers = parser.add_subparsers(dest="command")
|
||||
|
||||
# lists
|
||||
subparsers.add_parser("lists", help="List all Bring! shopping lists")
|
||||
|
||||
# get
|
||||
sp_get = subparsers.add_parser("get", help="Get items from a shopping list")
|
||||
sp_get.add_argument("--list", default=DEFAULT_LIST_NAME, help="List name or alias")
|
||||
|
||||
# add
|
||||
sp_add = subparsers.add_parser("add", help="Add an item to a list")
|
||||
sp_add.add_argument("item", help="Item name")
|
||||
sp_add.add_argument("--spec", default="", help="Specification (e.g. 2L, 500g)")
|
||||
sp_add.add_argument("--list", default=DEFAULT_LIST_NAME, help="List name or alias")
|
||||
|
||||
# complete
|
||||
sp_comp = subparsers.add_parser("complete", help="Mark an item as completed")
|
||||
sp_comp.add_argument("item", help="Item name")
|
||||
sp_comp.add_argument("--list", default=DEFAULT_LIST_NAME, help="List name or alias")
|
||||
|
||||
# remove
|
||||
sp_rem = subparsers.add_parser("remove", help="Remove an item from a list")
|
||||
sp_rem.add_argument("item", help="Item name")
|
||||
sp_rem.add_argument("--list", default=DEFAULT_LIST_NAME, help="List name or alias")
|
||||
|
||||
args = parser.parse_args()
|
||||
mgr = BringClientManager()
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
try:
|
||||
if args.command == "lists":
|
||||
res = loop.run_until_complete(mgr.load_lists())
|
||||
print(json.dumps(res, indent=2, ensure_ascii=False))
|
||||
elif args.command == "get":
|
||||
res = loop.run_until_complete(mgr.get_items(args.list))
|
||||
print(json.dumps(res, indent=2, ensure_ascii=False))
|
||||
elif args.command == "add":
|
||||
res = loop.run_until_complete(mgr.add_item(args.item, specification=args.spec, list_name=args.list))
|
||||
print(json.dumps(res, indent=2, ensure_ascii=False))
|
||||
elif args.command == "complete":
|
||||
res = loop.run_until_complete(mgr.complete_item(args.item, list_name=args.list))
|
||||
print(json.dumps(res, indent=2, ensure_ascii=False))
|
||||
elif args.command == "remove":
|
||||
res = loop.run_until_complete(mgr.remove_item(args.item, list_name=args.list))
|
||||
print(json.dumps(res, indent=2, ensure_ascii=False))
|
||||
else:
|
||||
parser.print_help()
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) == 1 or (len(sys.argv) == 2 and sys.argv[1] == "--stdio"):
|
||||
run_mcp_server()
|
||||
else:
|
||||
run_cli()
|
||||
@@ -0,0 +1,477 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
eBay Browse API MCP Server & CLI Tool
|
||||
Connects to eBay's official Browse API using OAuth 2.0 Client Credentials Grant.
|
||||
Allows AI assistants to search items on eBay.de, filter by condition (NEW, USED, REFURBISHED),
|
||||
buying format (Sofort-Kaufen / Auktion), price range, and fetch listing details.
|
||||
Runs standalone over MCP stdio or direct CLI commands.
|
||||
Requires zero external dependencies (pure Python standard library).
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
import base64
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
EBAY_TOKEN_URL = "https://api.ebay.com/identity/v1/oauth2/token"
|
||||
EBAY_BROWSE_URL = "https://api.ebay.com/buy/browse/v1"
|
||||
EBAY_OAUTH_SCOPE = "https://api.ebay.com/oauth/api_scope"
|
||||
|
||||
EBAY_APP_ID = os.environ.get("EBAY_APP_ID", "").strip()
|
||||
EBAY_CERT_ID = os.environ.get("EBAY_CERT_ID", "").strip()
|
||||
EBAY_MARKETPLACE_ID = os.environ.get("EBAY_MARKETPLACE_ID", "EBAY_DE")
|
||||
|
||||
# Condition mapping for user-friendly terms
|
||||
CONDITION_MAP = {
|
||||
"new": "NEW",
|
||||
"neu": "NEW",
|
||||
"used": "USED",
|
||||
"gebraucht": "USED",
|
||||
"refurbished": "CERTIFIED_REFURBISHED,EXCELLENT_REFURBISHED,VERY_GOOD_REFURBISHED,GOOD_REFURBISHED,SELLER_REFURBISHED",
|
||||
"generalüberholt": "CERTIFIED_REFURBISHED,EXCELLENT_REFURBISHED,VERY_GOOD_REFURBISHED,GOOD_REFURBISHED,SELLER_REFURBISHED",
|
||||
}
|
||||
|
||||
BUYING_FORMAT_MAP = {
|
||||
"fixed_price": "FIXED_PRICE",
|
||||
"sofortkauf": "FIXED_PRICE",
|
||||
"auction": "AUCTION",
|
||||
"auktion": "AUCTION",
|
||||
}
|
||||
|
||||
|
||||
class EbayClient:
|
||||
def __init__(self, app_id: Optional[str] = None, cert_id: Optional[str] = None, marketplace_id: Optional[str] = None):
|
||||
self.app_id = app_id or EBAY_APP_ID
|
||||
self.cert_id = cert_id or EBAY_CERT_ID
|
||||
self.marketplace_id = marketplace_id or EBAY_MARKETPLACE_ID
|
||||
self._access_token: Optional[str] = None
|
||||
self._token_expires_at: float = 0.0
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
dummy_values = ["", "dummy-ebay-app-id", "dummy-ebay-cert-id", "your-ebay-app-id", "your-ebay-cert-id"]
|
||||
return bool(self.app_id and self.cert_id and self.app_id not in dummy_values and self.cert_id not in dummy_values)
|
||||
|
||||
def get_token(self) -> str:
|
||||
"""Retrieves or refreshes eBay OAuth 2.0 application access token."""
|
||||
if not self.is_configured():
|
||||
raise ValueError(
|
||||
"eBay Developer credentials (EBAY_APP_ID and EBAY_CERT_ID) are not configured. "
|
||||
"Please add them to openclaw.secret.yaml under ebay-app-id and ebay-cert-id."
|
||||
)
|
||||
|
||||
# Reuse cached token if valid (with 60-second buffer)
|
||||
if self._access_token and time.time() < self._token_expires_at - 60:
|
||||
return self._access_token
|
||||
|
||||
creds = f"{self.app_id}:{self.cert_id}".encode("utf-8")
|
||||
basic_auth = base64.b64encode(creds).decode("utf-8")
|
||||
|
||||
data = urllib.parse.urlencode({
|
||||
"grant_type": "client_credentials",
|
||||
"scope": EBAY_OAUTH_SCOPE
|
||||
}).encode("utf-8")
|
||||
|
||||
req = urllib.request.Request(
|
||||
EBAY_TOKEN_URL,
|
||||
data=data,
|
||||
headers={
|
||||
"Authorization": f"Basic {basic_auth}",
|
||||
"Content-Type": "application/x-www-form-urlencoded"
|
||||
}
|
||||
)
|
||||
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
body = json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
self._access_token = body.get("access_token")
|
||||
expires_in = body.get("expires_in", 7200)
|
||||
self._token_expires_at = time.time() + expires_in
|
||||
return self._access_token
|
||||
|
||||
def search(
|
||||
self,
|
||||
query: str,
|
||||
condition: Optional[str] = None,
|
||||
buying_format: Optional[str] = None,
|
||||
min_price: Optional[float] = None,
|
||||
max_price: Optional[float] = None,
|
||||
limit: int = 15
|
||||
) -> Dict[str, Any]:
|
||||
"""Searches items on eBay via the Browse API."""
|
||||
if not self.is_configured():
|
||||
return {
|
||||
"configured": False,
|
||||
"error": "eBay credentials not configured. Please set EBAY_APP_ID and EBAY_CERT_ID in openclaw.secret.yaml.",
|
||||
"query": query,
|
||||
"results": []
|
||||
}
|
||||
|
||||
try:
|
||||
token = self.get_token()
|
||||
except Exception as e:
|
||||
return {
|
||||
"configured": False,
|
||||
"error": f"eBay authentication failed: {str(e)}",
|
||||
"query": query,
|
||||
"results": []
|
||||
}
|
||||
|
||||
# Build query filters
|
||||
filters = []
|
||||
if min_price is not None or max_price is not None:
|
||||
min_str = f"{min_price:.2f}" if min_price is not None else ""
|
||||
max_str = f"{max_price:.2f}" if max_price is not None else ""
|
||||
filters.append(f"price:[{min_str}..{max_str}]")
|
||||
|
||||
if condition:
|
||||
cond_lower = condition.lower().strip()
|
||||
ebay_cond = CONDITION_MAP.get(cond_lower, condition.upper())
|
||||
filters.append(f"conditions:{{{ebay_cond}}}")
|
||||
|
||||
if buying_format:
|
||||
fmt_lower = buying_format.lower().strip()
|
||||
ebay_fmt = BUYING_FORMAT_MAP.get(fmt_lower, buying_format.upper())
|
||||
filters.append(f"buyingOptions:{{{ebay_fmt}}}")
|
||||
|
||||
params = {
|
||||
"q": query,
|
||||
"limit": str(min(max(limit, 1), 50))
|
||||
}
|
||||
if filters:
|
||||
params["filter"] = ",".join(filters)
|
||||
|
||||
url = f"{EBAY_BROWSE_URL}/item_summary/search?{urllib.parse.urlencode(params)}"
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"X-EBAY-C-MARKETPLACE-ID": self.marketplace_id,
|
||||
"Accept": "application/json"
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as e:
|
||||
err_text = e.read().decode("utf-8", errors="replace")
|
||||
return {"error": f"eBay API HTTP error {e.code}: {err_text}", "query": query, "results": []}
|
||||
except Exception as e:
|
||||
return {"error": f"eBay search error: {str(e)}", "query": query, "results": []}
|
||||
|
||||
items = data.get("itemSummaries", [])
|
||||
total = data.get("total", len(items))
|
||||
|
||||
results = []
|
||||
for it in items:
|
||||
price_dict = it.get("price") or {}
|
||||
price_val = price_dict.get("value")
|
||||
price_curr = price_dict.get("currency", "EUR")
|
||||
|
||||
price_str = f"{float(price_val):.2f} {price_curr}" if price_val is not None else "Kein Preis"
|
||||
if price_curr == "EUR" and price_val is not None:
|
||||
price_str = f"{float(price_val):.2f} €".replace(".", ",")
|
||||
|
||||
# Shipping options
|
||||
shipping_list = it.get("shippingOptions") or []
|
||||
shipping_info = "Versand nicht angegeben"
|
||||
shipping_cost = None
|
||||
if shipping_list:
|
||||
s_cost_dict = shipping_list[0].get("shippingCost") or {}
|
||||
s_val = s_cost_dict.get("value")
|
||||
if s_val == "0.00" or s_val == "0" or s_val is None:
|
||||
shipping_info = "Kostenloser Versand"
|
||||
shipping_cost = 0.0
|
||||
else:
|
||||
shipping_cost = float(s_val)
|
||||
shipping_info = f"{shipping_cost:.2f} € Versand".replace(".", ",")
|
||||
|
||||
# Buying options (Auktion vs Sofort-Kaufen)
|
||||
buying_opts = it.get("buyingOptions") or []
|
||||
is_auction = "AUCTION" in buying_opts
|
||||
is_fixed_price = "FIXED_PRICE" in buying_opts
|
||||
format_label = "Sofort-Kaufen" if is_fixed_price else ("Auktion" if is_auction else ", ".join(buying_opts))
|
||||
|
||||
seller = it.get("seller") or {}
|
||||
results.append({
|
||||
"item_id": it.get("itemId"),
|
||||
"title": it.get("title"),
|
||||
"price": price_str,
|
||||
"price_numeric": float(price_val) if price_val is not None else None,
|
||||
"shipping": shipping_info,
|
||||
"shipping_cost": shipping_cost,
|
||||
"condition": it.get("condition"),
|
||||
"format": format_label,
|
||||
"seller_username": seller.get("username"),
|
||||
"seller_feedback_percent": seller.get("feedbackPercentage"),
|
||||
"url": it.get("itemWebUrl"),
|
||||
"image_url": it.get("image", {}).get("imageUrl")
|
||||
})
|
||||
|
||||
return {
|
||||
"query": query,
|
||||
"marketplace": self.marketplace_id,
|
||||
"total_found": total,
|
||||
"returned_count": len(results),
|
||||
"results": results
|
||||
}
|
||||
|
||||
def get_item(self, item_id: str) -> Dict[str, Any]:
|
||||
"""Fetches full details for a specific eBay listing."""
|
||||
if not self.is_configured():
|
||||
return {"configured": False, "error": "eBay credentials not configured."}
|
||||
|
||||
try:
|
||||
token = self.get_token()
|
||||
except Exception as e:
|
||||
return {"configured": False, "error": f"eBay authentication failed: {str(e)}"}
|
||||
|
||||
url = f"{EBAY_BROWSE_URL}/item/{urllib.parse.quote(item_id)}"
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"X-EBAY-C-MARKETPLACE-ID": self.marketplace_id,
|
||||
"Accept": "application/json"
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
item = json.loads(resp.read().decode("utf-8"))
|
||||
except Exception as e:
|
||||
return {"error": f"Failed to fetch eBay item {item_id}: {str(e)}"}
|
||||
|
||||
images = []
|
||||
if item.get("image", {}).get("imageUrl"):
|
||||
images.append(item["image"]["imageUrl"])
|
||||
for add_img in item.get("additionalImages", []):
|
||||
if add_img.get("imageUrl"):
|
||||
images.append(add_img["imageUrl"])
|
||||
|
||||
price_dict = item.get("price") or {}
|
||||
price_val = price_dict.get("value")
|
||||
price_curr = price_dict.get("currency", "EUR")
|
||||
price_str = f"{float(price_val):.2f} €".replace(".", ",") if price_val else "Kein Preis"
|
||||
|
||||
seller = item.get("seller") or {}
|
||||
location = item.get("itemLocation") or {}
|
||||
loc_str = ", ".join(filter(None, [location.get("city"), location.get("stateOrProvince"), location.get("country")]))
|
||||
|
||||
return {
|
||||
"item_id": item.get("itemId"),
|
||||
"title": item.get("title"),
|
||||
"price": price_str,
|
||||
"condition": item.get("condition"),
|
||||
"description": item.get("shortDescription") or (item.get("description", "")[:400] if item.get("description") else None),
|
||||
"location": loc_str or None,
|
||||
"seller": seller.get("username"),
|
||||
"seller_feedback_score": seller.get("feedbackScore"),
|
||||
"seller_feedback_percent": seller.get("feedbackPercentage"),
|
||||
"images": images,
|
||||
"url": item.get("itemWebUrl")
|
||||
}
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Model Context Protocol (MCP) stdio Server
|
||||
# ==============================================================================
|
||||
|
||||
MCP_TOOLS = [
|
||||
{
|
||||
"name": "ebay_search",
|
||||
"description": "Search items on eBay.de via official Browse API. Supports filtering by condition (NEW, USED, REFURBISHED), format (AUCTION, FIXED_PRICE/Sofort-Kaufen), and price bounds.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Item query, e.g. 'ThinkPad T14', 'iPhone 13 gebraucht', 'Sonos One'"
|
||||
},
|
||||
"condition": {
|
||||
"type": "string",
|
||||
"description": "Item condition: 'used'/'gebraucht', 'refurbished'/'generalüberholt', or 'new'/'neu'",
|
||||
"enum": ["used", "gebraucht", "refurbished", "generalüberholt", "new", "neu"]
|
||||
},
|
||||
"buying_format": {
|
||||
"type": "string",
|
||||
"description": "Buying format: 'fixed_price'/'sofortkauf' or 'auction'/'auktion'",
|
||||
"enum": ["fixed_price", "sofortkauf", "auction", "auktion"]
|
||||
},
|
||||
"min_price": {
|
||||
"type": "number",
|
||||
"description": "Minimum price in EUR"
|
||||
},
|
||||
"max_price": {
|
||||
"type": "number",
|
||||
"description": "Maximum price in EUR"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Max number of items to return (default 10)",
|
||||
"default": 10
|
||||
}
|
||||
},
|
||||
"required": ["query"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ebay_get_item",
|
||||
"description": "Fetch detailed information for a specific eBay listing (description, item location, additional photos, return policy).",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"item_id": {
|
||||
"type": "string",
|
||||
"description": "eBay listing item ID, obtained from ebay_search"
|
||||
}
|
||||
},
|
||||
"required": ["item_id"]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def run_mcp_server():
|
||||
client = EbayClient()
|
||||
|
||||
while True:
|
||||
line = sys.stdin.readline()
|
||||
if not line:
|
||||
break
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
req = json.loads(line)
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"Invalid JSON received: {e}\n")
|
||||
continue
|
||||
|
||||
msg_id = req.get("id")
|
||||
method = req.get("method")
|
||||
params = req.get("params", {})
|
||||
|
||||
if method == "initialize":
|
||||
resp = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": msg_id,
|
||||
"result": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {"tools": {}},
|
||||
"serverInfo": {"name": "ebay-mcp", "version": "1.0.0"}
|
||||
}
|
||||
}
|
||||
sys.stdout.write(json.dumps(resp) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
elif method == "notifications/initialized":
|
||||
pass
|
||||
|
||||
elif method == "ping":
|
||||
resp = {"jsonrpc": "2.0", "id": msg_id, "result": {}}
|
||||
sys.stdout.write(json.dumps(resp) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
elif method == "tools/list":
|
||||
resp = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": msg_id,
|
||||
"result": {"tools": MCP_TOOLS}
|
||||
}
|
||||
sys.stdout.write(json.dumps(resp) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
elif method == "tools/call":
|
||||
tool_name = params.get("name")
|
||||
args = params.get("arguments", {})
|
||||
try:
|
||||
if tool_name == "ebay_search":
|
||||
data = client.search(
|
||||
query=args.get("query", ""),
|
||||
condition=args.get("condition"),
|
||||
buying_format=args.get("buying_format"),
|
||||
min_price=args.get("min_price"),
|
||||
max_price=args.get("max_price"),
|
||||
limit=args.get("limit", 10)
|
||||
)
|
||||
elif tool_name == "ebay_get_item":
|
||||
data = client.get_item(item_id=args.get("item_id", ""))
|
||||
else:
|
||||
raise ValueError(f"Unknown tool: {tool_name}")
|
||||
|
||||
text_result = json.dumps(data, indent=2, ensure_ascii=False)
|
||||
resp = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": msg_id,
|
||||
"result": {
|
||||
"content": [{"type": "text", "text": text_result}]
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
resp = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": msg_id,
|
||||
"result": {
|
||||
"isError": True,
|
||||
"content": [{"type": "text", "text": f"Error executing {tool_name}: {str(e)}"}]
|
||||
}
|
||||
}
|
||||
sys.stdout.write(json.dumps(resp) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
else:
|
||||
if msg_id is not None:
|
||||
resp = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": msg_id,
|
||||
"error": {"code": -32601, "message": f"Method not found: {method}"}
|
||||
}
|
||||
sys.stdout.write(json.dumps(resp) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def run_cli():
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description="eBay Browse API MCP & CLI")
|
||||
subparsers = parser.add_subparsers(dest="command")
|
||||
|
||||
sp_search = subparsers.add_parser("search", help="Search items on eBay")
|
||||
sp_search.add_argument("query", help="Item search query")
|
||||
sp_search.add_argument("--condition", choices=["used", "gebraucht", "refurbished", "new", "neu"])
|
||||
sp_search.add_argument("--format", choices=["fixed_price", "sofortkauf", "auction", "auktion"])
|
||||
sp_search.add_argument("--min-price", type=float)
|
||||
sp_search.add_argument("--max-price", type=float)
|
||||
sp_search.add_argument("--limit", type=int, default=10)
|
||||
|
||||
sp_item = subparsers.add_parser("item", help="Get eBay item details")
|
||||
sp_item.add_argument("item_id", help="eBay Item ID")
|
||||
|
||||
args = parser.parse_args()
|
||||
client = EbayClient()
|
||||
|
||||
if args.command == "search":
|
||||
print(json.dumps(client.search(
|
||||
query=args.query,
|
||||
condition=args.condition,
|
||||
buying_format=args.format,
|
||||
min_price=args.min_price,
|
||||
max_price=args.max_price,
|
||||
limit=args.limit
|
||||
), indent=2, ensure_ascii=False))
|
||||
elif args.command == "item":
|
||||
print(json.dumps(client.get_item(args.item_id), indent=2, ensure_ascii=False))
|
||||
else:
|
||||
parser.print_help()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) == 1 or (len(sys.argv) == 2 and sys.argv[1] == "--stdio"):
|
||||
run_mcp_server()
|
||||
else:
|
||||
run_cli()
|
||||
@@ -0,0 +1,399 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Geizhals MCP Server & CLI Tool
|
||||
Searches Geizhals (and Skinflint) price comparison data: best prices across shops,
|
||||
merchant counts, star ratings, test review counts, and price history trends.
|
||||
Communicates directly with the official Geizhals mobile app API (api.geizhals.net/gh/v9)
|
||||
using offline HMAC-SHA256 request signing (bypassing Cloudflare JS challenges).
|
||||
Requires zero external Python dependencies (pure standard library).
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
import hmac
|
||||
import hashlib
|
||||
import base64
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
BASE_URL = "https://api.geizhals.net/gh/v9"
|
||||
API_HOST = "api.geizhals.net"
|
||||
GH_TOKEN_ID = "mobileapp-2026-android-3.12.7"
|
||||
GH_AUTH_ROLE = "mobileapp-2026-android-3.12.7"
|
||||
GH_HMAC_SECRET = "2Uq.p2gD1z5Im,m73u5eSVzeuFZJpcQVtzx.M3OtyW.4LvzO"
|
||||
GH_CLIENT = "Geizhals/3.13.1 (Android 14; sdk_gphone64_x86_64; emu64xa; x86_64; de)"
|
||||
|
||||
DEFAULT_COUNTRY = os.environ.get("GEIZHALS_DEFAULT_LOC", "de")
|
||||
|
||||
|
||||
def _b64url(data: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
|
||||
|
||||
|
||||
def _auth_header(body_bytes: bytes, method: str = "POST", query_string: str = "") -> str:
|
||||
now = int(time.time())
|
||||
if method == "POST":
|
||||
s = f"POST|{API_HOST}||{body_bytes.decode('utf-8')}"
|
||||
else:
|
||||
s = f"{method}|{API_HOST}|{query_string}|"
|
||||
|
||||
fp = hashlib.sha256(s.encode("utf-8")).hexdigest()
|
||||
header = {"alg": "HS256", "typ": "JWT"}
|
||||
payload = {
|
||||
"token_id": GH_TOKEN_ID,
|
||||
"timestamp": now,
|
||||
"request_fingerprint": fp,
|
||||
"iat": now,
|
||||
}
|
||||
|
||||
header_b64 = _b64url(json.dumps(header, separators=(",", ":")).encode())
|
||||
payload_b64 = _b64url(json.dumps(payload, separators=(",", ":")).encode())
|
||||
signing_input = f"{header_b64}.{payload_b64}"
|
||||
|
||||
sig = hmac.new(GH_HMAC_SECRET.encode("utf-8"), signing_input.encode("utf-8"), hashlib.sha256).digest()
|
||||
return f"Bearer {signing_input}.{_b64url(sig)}"
|
||||
|
||||
|
||||
def _post(endpoint: str, payload: dict) -> dict:
|
||||
body = json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
f"{BASE_URL}/{endpoint}",
|
||||
data=body,
|
||||
headers={
|
||||
"User-Agent": GH_CLIENT,
|
||||
"Authorization": _auth_header(body),
|
||||
"auth-role": GH_AUTH_ROLE,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=20) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
|
||||
class GeizhalsClient:
|
||||
def search(self, query: str, country: Optional[str] = None, limit: int = 8) -> Dict[str, Any]:
|
||||
loc = (country or DEFAULT_COUNTRY).lower()
|
||||
# Geizhals strictly requires pagesize to be in [1, 5, 10, 30, 100, 300, 1000]
|
||||
valid_pagesizes = [1, 5, 10, 30, 100]
|
||||
pagesize = 10
|
||||
for ps in valid_pagesizes:
|
||||
if ps >= limit:
|
||||
pagesize = ps
|
||||
break
|
||||
|
||||
payload = {
|
||||
"query": query,
|
||||
"params": {
|
||||
"loc": loc,
|
||||
"hloc": [loc],
|
||||
"lang": "de",
|
||||
"pagesize": pagesize,
|
||||
"n_offers": 1
|
||||
}
|
||||
}
|
||||
|
||||
try:
|
||||
raw = _post("search_product", payload)
|
||||
except Exception as e:
|
||||
return {"error": f"Geizhals search error: {str(e)}", "query": query, "results": []}
|
||||
|
||||
resp = raw.get("response", {})
|
||||
products = resp.get("products", [])
|
||||
total = raw.get("total", len(products))
|
||||
|
||||
results = []
|
||||
domain = "geizhals.de" if loc == "de" else "geizhals.at"
|
||||
|
||||
for p in products[:limit]:
|
||||
prices = p.get("prices") or {}
|
||||
best_price = prices.get("best")
|
||||
avg_price = prices.get("avg")
|
||||
offers_list = p.get("offers") or []
|
||||
offer_count = p.get("offer_count", len(offers_list))
|
||||
|
||||
# If best_price is missing in prices dict, check first offer
|
||||
cheapest_shop = None
|
||||
if offers_list:
|
||||
first_offer = offers_list[0]
|
||||
cheapest_shop = first_offer.get("shop", {}).get("name")
|
||||
if best_price is None:
|
||||
best_price = first_offer.get("pricing", {}).get("eur_value") or first_offer.get("price", {}).get("value")
|
||||
|
||||
# Determine product link
|
||||
urls = p.get("urls") or {}
|
||||
overview_path = urls.get("overview")
|
||||
web_url = f"https://{domain}{overview_path}" if overview_path else None
|
||||
|
||||
# Test review count & metascore
|
||||
reviews = p.get("test_reviews") or []
|
||||
metascore = p.get("average_test_reviews_metascore")
|
||||
|
||||
# Image handling (already starts with https://gzhls.at or relative)
|
||||
raw_images = p.get("images") or []
|
||||
image_url = None
|
||||
if raw_images:
|
||||
first_img = raw_images[0]
|
||||
if isinstance(first_img, str):
|
||||
image_url = first_img if first_img.startswith("http") else f"https://gzhls.at/{first_img}"
|
||||
elif isinstance(first_img, dict):
|
||||
image_url = first_img.get("url")
|
||||
|
||||
results.append({
|
||||
"id": p.get("gzhid"),
|
||||
"name": p.get("product") or p.get("product_for_sort"),
|
||||
"manufacturer": p.get("manufacturer_name"),
|
||||
"best_price": float(best_price) if best_price is not None else None,
|
||||
"best_price_formatted": f"{float(best_price):.2f} €".replace(".", ",") if best_price is not None else "Kein Live-Angebot",
|
||||
"average_price": float(avg_price) if avg_price is not None else None,
|
||||
"cheapest_shop": cheapest_shop,
|
||||
"offer_count": offer_count,
|
||||
"available": bool(best_price is not None and offer_count > 0),
|
||||
"rating_stars": p.get("rating_stars"),
|
||||
"rating_count": p.get("rating_count"),
|
||||
"test_reviews_count": len(reviews),
|
||||
"test_reviews_metascore": metascore,
|
||||
"url": web_url,
|
||||
"image_url": image_url
|
||||
})
|
||||
|
||||
return {
|
||||
"query": query,
|
||||
"country": loc,
|
||||
"total_found": total,
|
||||
"returned_count": len(results),
|
||||
"results": results
|
||||
}
|
||||
|
||||
def price_history(self, product_id: int, days: int = 31, country: Optional[str] = None) -> Dict[str, Any]:
|
||||
loc = (country or DEFAULT_COUNTRY).lower()
|
||||
# Days must be one of 31, 91, 183, 365
|
||||
valid_days = [31, 91, 183, 365]
|
||||
closest_days = min(valid_days, key=lambda x: abs(x - days))
|
||||
|
||||
payload = {
|
||||
"id": product_id,
|
||||
"params": {
|
||||
"days": closest_days,
|
||||
"loc": loc
|
||||
}
|
||||
}
|
||||
|
||||
try:
|
||||
raw = _post("price_history", payload)
|
||||
except Exception as e:
|
||||
return {"error": f"Price history error: {str(e)}", "product_id": product_id}
|
||||
|
||||
meta = raw.get("meta") or {}
|
||||
series = raw.get("response") or []
|
||||
|
||||
min_price = meta.get("min")
|
||||
max_price = meta.get("max")
|
||||
current_best = meta.get("current_best")
|
||||
|
||||
is_all_time_low = False
|
||||
if current_best and min_price:
|
||||
is_all_time_low = float(current_best) <= float(min_price) + 0.05
|
||||
|
||||
return {
|
||||
"product_id": product_id,
|
||||
"country": loc,
|
||||
"period_days": closest_days,
|
||||
"current_best_price": float(current_best) if current_best is not None else None,
|
||||
"period_min_price": float(min_price) if min_price is not None else None,
|
||||
"period_max_price": float(max_price) if max_price is not None else None,
|
||||
"is_all_time_low": is_all_time_low,
|
||||
"data_points_count": len(series)
|
||||
}
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Model Context Protocol (MCP) stdio Server
|
||||
# ==============================================================================
|
||||
|
||||
MCP_TOOLS = [
|
||||
{
|
||||
"name": "geizhals_search",
|
||||
"description": "Search products on Geizhals (Germany/Austria price comparison). Returns lowest price, offer count across all stores, rating, and product link.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Product search query, e.g. 'MacBook Air M3', 'Sony WH-1000XM5', 'Mutti Tomaten'"
|
||||
},
|
||||
"country": {
|
||||
"type": "string",
|
||||
"description": "Country code ('de' for Germany, 'at' for Austria). Defaults to 'de'.",
|
||||
"default": "de"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Max number of items to return (default 8)",
|
||||
"default": 8
|
||||
}
|
||||
},
|
||||
"required": ["query"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "geizhals_price_history",
|
||||
"description": "Get price history trends for a Geizhals product ID over 31, 91, 183, or 365 days to check if the current price is a good deal or all-time low.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"product_id": {
|
||||
"type": "integer",
|
||||
"description": "Geizhals product ID (gzhid) obtained from geizhals_search"
|
||||
},
|
||||
"days": {
|
||||
"type": "integer",
|
||||
"description": "History timeframe in days (31, 91, 183, or 365). Defaults to 31.",
|
||||
"default": 31
|
||||
},
|
||||
"country": {
|
||||
"type": "string",
|
||||
"description": "Country code ('de' or 'at'). Defaults to 'de'.",
|
||||
"default": "de"
|
||||
}
|
||||
},
|
||||
"required": ["product_id"]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def run_mcp_server():
|
||||
client = GeizhalsClient()
|
||||
|
||||
while True:
|
||||
line = sys.stdin.readline()
|
||||
if not line:
|
||||
break
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
req = json.loads(line)
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"Invalid JSON received: {e}\n")
|
||||
continue
|
||||
|
||||
msg_id = req.get("id")
|
||||
method = req.get("method")
|
||||
params = req.get("params", {})
|
||||
|
||||
if method == "initialize":
|
||||
resp = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": msg_id,
|
||||
"result": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {"tools": {}},
|
||||
"serverInfo": {"name": "geizhals-mcp", "version": "1.0.0"}
|
||||
}
|
||||
}
|
||||
sys.stdout.write(json.dumps(resp) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
elif method == "notifications/initialized":
|
||||
pass
|
||||
|
||||
elif method == "ping":
|
||||
resp = {"jsonrpc": "2.0", "id": msg_id, "result": {}}
|
||||
sys.stdout.write(json.dumps(resp) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
elif method == "tools/list":
|
||||
resp = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": msg_id,
|
||||
"result": {"tools": MCP_TOOLS}
|
||||
}
|
||||
sys.stdout.write(json.dumps(resp) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
elif method == "tools/call":
|
||||
tool_name = params.get("name")
|
||||
args = params.get("arguments", {})
|
||||
try:
|
||||
if tool_name == "geizhals_search":
|
||||
data = client.search(
|
||||
query=args.get("query", ""),
|
||||
country=args.get("country"),
|
||||
limit=args.get("limit", 8)
|
||||
)
|
||||
elif tool_name == "geizhals_price_history":
|
||||
data = client.price_history(
|
||||
product_id=int(args.get("product_id")),
|
||||
days=int(args.get("days", 31)),
|
||||
country=args.get("country")
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unknown tool: {tool_name}")
|
||||
|
||||
text_result = json.dumps(data, indent=2, ensure_ascii=False)
|
||||
resp = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": msg_id,
|
||||
"result": {
|
||||
"content": [{"type": "text", "text": text_result}]
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
resp = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": msg_id,
|
||||
"result": {
|
||||
"isError": True,
|
||||
"content": [{"type": "text", "text": f"Error executing {tool_name}: {str(e)}"}]
|
||||
}
|
||||
}
|
||||
sys.stdout.write(json.dumps(resp) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
else:
|
||||
if msg_id is not None:
|
||||
resp = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": msg_id,
|
||||
"error": {"code": -32601, "message": f"Method not found: {method}"}
|
||||
}
|
||||
sys.stdout.write(json.dumps(resp) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def run_cli():
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description="Geizhals Price Comparison MCP & CLI")
|
||||
subparsers = parser.add_subparsers(dest="command")
|
||||
|
||||
sp_search = subparsers.add_parser("search", help="Search products on Geizhals")
|
||||
sp_search.add_argument("query", help="Product query")
|
||||
sp_search.add_argument("--country", default="de", help="Country (de or at)")
|
||||
sp_search.add_argument("--limit", type=int, default=8, help="Max results")
|
||||
|
||||
sp_hist = subparsers.add_parser("history", help="Get price history for product ID")
|
||||
sp_hist.add_argument("product_id", type=int, help="Geizhals product ID (gzhid)")
|
||||
sp_hist.add_argument("--days", type=int, default=31, help="History days (31, 91, 183, 365)")
|
||||
sp_hist.add_argument("--country", default="de", help="Country (de or at)")
|
||||
|
||||
args = parser.parse_args()
|
||||
client = GeizhalsClient()
|
||||
|
||||
if args.command == "search":
|
||||
print(json.dumps(client.search(args.query, country=args.country, limit=args.limit), indent=2, ensure_ascii=False))
|
||||
elif args.command == "history":
|
||||
print(json.dumps(client.price_history(args.product_id, days=args.days, country=args.country), indent=2, ensure_ascii=False))
|
||||
else:
|
||||
parser.print_help()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) == 1 or (len(sys.argv) == 2 and sys.argv[1] == "--stdio"):
|
||||
run_mcp_server()
|
||||
else:
|
||||
run_cli()
|
||||
@@ -0,0 +1,385 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Supermarket Deals MCP Server & CLI Tool
|
||||
Fetches live supermarket discounts and brochures across German retailers
|
||||
(Edeka, Rewe, Aldi Süd/Nord, Lidl, Kaufland, Netto, Penny, dm, Rossmann, V-Markt).
|
||||
Optimized for staple goods (Mutti, Oro di Parma, Barilla, coffee) and diapers (Windeln piece-price calculation).
|
||||
Runs standalone over MCP stdio or direct CLI commands.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import re
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
# Default Coordinates: Regensburg, Germany
|
||||
DEFAULT_LAT = float(os.environ.get("SUPERMARKET_DEFAULT_LAT", "49.0134"))
|
||||
DEFAULT_LNG = float(os.environ.get("SUPERMARKET_DEFAULT_LNG", "12.1016"))
|
||||
DEFAULT_CITY = os.environ.get("SUPERMARKET_DEFAULT_CITY", "Regensburg")
|
||||
|
||||
CITY_COORDINATES = {
|
||||
"regensburg": (49.0134, 12.1016),
|
||||
"münchen": (48.1351, 11.5820),
|
||||
"munich": (48.1351, 11.5820),
|
||||
"nürnberg": (49.4521, 11.0767),
|
||||
"berlin": (52.5200, 13.4050),
|
||||
"hamburg": (53.5511, 9.9937),
|
||||
"frankfurt": (50.1109, 8.6821),
|
||||
"köln": (50.9375, 6.9603),
|
||||
"stuttgart": (48.7758, 9.1829),
|
||||
}
|
||||
|
||||
HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
"Accept": "application/json",
|
||||
"Accept-Language": "de-DE,de;q=0.9,en;q=0.8"
|
||||
}
|
||||
|
||||
|
||||
def resolve_coordinates(city: Optional[str] = None, lat: Optional[float] = None, lng: Optional[float] = None) -> tuple:
|
||||
if lat is not None and lng is not None:
|
||||
return (lat, lng)
|
||||
if city:
|
||||
norm = city.lower().strip()
|
||||
if norm in CITY_COORDINATES:
|
||||
return CITY_COORDINATES[norm]
|
||||
return (DEFAULT_LAT, DEFAULT_LNG)
|
||||
|
||||
|
||||
def calculate_piece_price(title: str, description: Optional[str], price_float: float) -> Optional[Dict[str, Any]]:
|
||||
"""Calculates piece price (e.g. ct/Windel or ct/Stück) from title/description."""
|
||||
if price_float <= 0:
|
||||
return None
|
||||
|
||||
text = f"{title} {description or ''}"
|
||||
# Match patterns like: "84 Stück", "84 Stk.", "84-St.-Packg.", "114er", "24 ct"
|
||||
match = re.search(r"(\d+)\s*(?:stück|stk\.?|st\.-packg\.?|ct\b|pants\b|windeln\b|er\s*packung)", text, re.IGNORECASE)
|
||||
if not match:
|
||||
# Also check for "Größe 3/4/5, Je 92/84/72 Stück"
|
||||
multi_match = re.search(r"(\d+)/(\d+)/(\d+)\s*stück", text, re.IGNORECASE)
|
||||
if multi_match:
|
||||
counts = [int(g) for g in multi_match.groups()]
|
||||
avg_count = sum(counts) / len(counts)
|
||||
cent_piece = round((price_float / avg_count) * 100, 1)
|
||||
return {
|
||||
"piece_counts": counts,
|
||||
"approx_cents_per_piece": cent_piece,
|
||||
"formatted": f"ca. {cent_piece} ct / Stück"
|
||||
}
|
||||
return None
|
||||
|
||||
count = int(match.group(1))
|
||||
if count <= 0 or count > 500:
|
||||
return None
|
||||
|
||||
cent_piece = round((price_float / count) * 100, 1)
|
||||
return {
|
||||
"pieces": count,
|
||||
"cents_per_piece": cent_piece,
|
||||
"formatted": f"{cent_piece} ct / Stück"
|
||||
}
|
||||
|
||||
|
||||
def evaluate_deal(brand: str, title: str, price: float) -> str:
|
||||
"""Evaluates whether an offer is a standout bargain for known staple brands."""
|
||||
norm = f"{brand} {title}".lower()
|
||||
if any(k in norm for k in ["mutti", "oro di parma", "oro"]):
|
||||
if any(t in norm for t in ["polpa", "stückig", "passata", "geschält", "pelati", "tomaten"]):
|
||||
if price <= 1.05:
|
||||
return "TOP_DEAL_VORRAT"
|
||||
elif price <= 1.29:
|
||||
return "GUTER_ANGEBOTSPREIS"
|
||||
return "NORMALER_PREIS"
|
||||
if "barilla" in norm:
|
||||
if price <= 0.89:
|
||||
return "TOP_DEAL_VORRAT"
|
||||
elif price <= 1.09:
|
||||
return "GUTER_ANGEBOTSPREIS"
|
||||
return "ANGEBOT"
|
||||
|
||||
|
||||
class SupermarketClient:
|
||||
def search(self, query: str, city: Optional[str] = None, lat: Optional[float] = None, lng: Optional[float] = None) -> Dict[str, Any]:
|
||||
use_lat, use_lng = resolve_coordinates(city, lat, lng)
|
||||
url = f"https://www.kaufda.de/api/search?lat={use_lat}&lng={use_lng}&query={urllib.parse.quote(query)}"
|
||||
req = urllib.request.Request(url, headers=HEADERS)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=12) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
except Exception as e:
|
||||
return {
|
||||
"query": query,
|
||||
"city": city or DEFAULT_CITY,
|
||||
"coordinates": {"lat": use_lat, "lng": use_lng},
|
||||
"error": f"Failed to fetch offers: {str(e)}",
|
||||
"offers": [],
|
||||
"brochures": []
|
||||
}
|
||||
|
||||
search_results = data.get("searchResults", {})
|
||||
contents = search_results.get("contents", {})
|
||||
raw_offers = contents.get("offers", [])
|
||||
raw_brochures = contents.get("brochures", [])
|
||||
|
||||
formatted_offers = []
|
||||
for o in raw_offers:
|
||||
prices = o.get("prices", {})
|
||||
price_fmt = prices.get("mainPriceFormatted") or ""
|
||||
price_num = float(prices.get("mainPrice") or 0.0)
|
||||
if not price_fmt and price_num > 0:
|
||||
price_fmt = f"{price_num:.2f} €".replace(".", ",")
|
||||
|
||||
title = o.get("title") or ""
|
||||
brand = o.get("brand") or ""
|
||||
desc = o.get("description") or ""
|
||||
retailer = o.get("publisherName") or "Supermarkt"
|
||||
|
||||
piece_info = calculate_piece_price(title, desc, price_num)
|
||||
deal_rating = evaluate_deal(brand, title, price_num)
|
||||
|
||||
offer_dict = {
|
||||
"retailer": retailer,
|
||||
"brand": brand,
|
||||
"title": title,
|
||||
"price": price_fmt,
|
||||
"price_numeric": price_num,
|
||||
"description": desc,
|
||||
"valid_from": (o.get("validFrom") or "")[:10],
|
||||
"valid_until": (o.get("validUntil") or "")[:10],
|
||||
"deal_rating": deal_rating,
|
||||
"image_url": o.get("offerImages", {}).get("url", {}).get("normal")
|
||||
}
|
||||
if piece_info:
|
||||
offer_dict["piece_price"] = piece_info
|
||||
|
||||
formatted_offers.append(offer_dict)
|
||||
|
||||
# Sort offers: top deals first, then by price
|
||||
formatted_offers.sort(key=lambda x: (
|
||||
0 if x.get("deal_rating") == "TOP_DEAL_VORRAT" else (1 if x.get("deal_rating") == "GUTER_ANGEBOTSPREIS" else 2),
|
||||
x.get("price_numeric", 9999) if x.get("price_numeric", 0) > 0 else 9999
|
||||
))
|
||||
|
||||
formatted_brochures = []
|
||||
for b in raw_brochures:
|
||||
bc = b.get("content", {})
|
||||
formatted_brochures.append({
|
||||
"retailer": bc.get("publisher", {}).get("name"),
|
||||
"brochure_title": bc.get("title"),
|
||||
"page": bc.get("page"),
|
||||
"distance_km": round(bc.get("distance", 0), 1),
|
||||
"valid_until": (bc.get("validUntil") or "")[:10]
|
||||
})
|
||||
|
||||
return {
|
||||
"query": query,
|
||||
"city": city or DEFAULT_CITY,
|
||||
"coordinates": {"lat": use_lat, "lng": use_lng},
|
||||
"total_offers": len(formatted_offers),
|
||||
"offers": formatted_offers,
|
||||
"matching_brochures": formatted_brochures[:5]
|
||||
}
|
||||
|
||||
def compare_staples(self, staples: List[str], city: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Searches multiple staple products and aggregates results by product and store."""
|
||||
summary = {}
|
||||
for s in staples:
|
||||
s_clean = s.strip()
|
||||
if s_clean:
|
||||
summary[s_clean] = self.search(s_clean, city=city)
|
||||
|
||||
return {
|
||||
"city": city or DEFAULT_CITY,
|
||||
"staples_searched": list(summary.keys()),
|
||||
"results": summary
|
||||
}
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Model Context Protocol (MCP) stdio Server
|
||||
# ==============================================================================
|
||||
|
||||
MCP_TOOLS = [
|
||||
{
|
||||
"name": "supermarket_search",
|
||||
"description": "Search current regional supermarket, discounter, and drugstore discounts (Edeka, Rewe, Aldi Süd, Lidl, Kaufland, Netto, Penny, dm, Rossmann, V-Markt). Ideal for brand staples (Mutti, Oro di Parma, Barilla, Lavazza) and baby essentials (Windeln, Pampers). Defaults to Regensburg.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Product or brand name to search (e.g. 'Mutti', 'Oro di Parma', 'Windeln', 'Pampers', 'Kaffee')"
|
||||
},
|
||||
"city": {
|
||||
"type": "string",
|
||||
"description": "City name for regional offers (e.g. 'Regensburg', 'München', 'Nürnberg'). Defaults to Regensburg."
|
||||
},
|
||||
"lat": {
|
||||
"type": "number",
|
||||
"description": "Optional latitude coordinate override"
|
||||
},
|
||||
"lng": {
|
||||
"type": "number",
|
||||
"description": "Optional longitude coordinate override"
|
||||
}
|
||||
},
|
||||
"required": ["query"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "supermarket_compare_staples",
|
||||
"description": "Batch search multiple staple goods (e.g. ['Mutti', 'Oro di Parma'] or ['Windeln', 'Pampers']) to compare discounts across all local supermarkets in one call.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"staples": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "List of staple brands or product categories to search, e.g. ['Mutti', 'Oro di Parma']"
|
||||
},
|
||||
"city": {
|
||||
"type": "string",
|
||||
"description": "City name for regional offers. Defaults to Regensburg."
|
||||
}
|
||||
},
|
||||
"required": ["staples"]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def run_mcp_server():
|
||||
client = SupermarketClient()
|
||||
|
||||
while True:
|
||||
line = sys.stdin.readline()
|
||||
if not line:
|
||||
break
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
req = json.loads(line)
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"Invalid JSON received: {e}\n")
|
||||
continue
|
||||
|
||||
msg_id = req.get("id")
|
||||
method = req.get("method")
|
||||
params = req.get("params", {})
|
||||
|
||||
if method == "initialize":
|
||||
resp = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": msg_id,
|
||||
"result": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {"tools": {}},
|
||||
"serverInfo": {"name": "supermarket-deals-mcp", "version": "1.0.0"}
|
||||
}
|
||||
}
|
||||
sys.stdout.write(json.dumps(resp) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
elif method == "notifications/initialized":
|
||||
pass
|
||||
|
||||
elif method == "ping":
|
||||
resp = {"jsonrpc": "2.0", "id": msg_id, "result": {}}
|
||||
sys.stdout.write(json.dumps(resp) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
elif method == "tools/list":
|
||||
resp = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": msg_id,
|
||||
"result": {"tools": MCP_TOOLS}
|
||||
}
|
||||
sys.stdout.write(json.dumps(resp) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
elif method == "tools/call":
|
||||
tool_name = params.get("name")
|
||||
args = params.get("arguments", {})
|
||||
try:
|
||||
if tool_name == "supermarket_search":
|
||||
data = client.search(
|
||||
query=args.get("query", ""),
|
||||
city=args.get("city"),
|
||||
lat=args.get("lat"),
|
||||
lng=args.get("lng")
|
||||
)
|
||||
elif tool_name == "supermarket_compare_staples":
|
||||
data = client.compare_staples(
|
||||
staples=args.get("staples", []),
|
||||
city=args.get("city")
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unknown tool: {tool_name}")
|
||||
|
||||
text_result = json.dumps(data, indent=2, ensure_ascii=False)
|
||||
resp = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": msg_id,
|
||||
"result": {
|
||||
"content": [{"type": "text", "text": text_result}]
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
resp = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": msg_id,
|
||||
"result": {
|
||||
"isError": True,
|
||||
"content": [{"type": "text", "text": f"Error executing {tool_name}: {str(e)}"}]
|
||||
}
|
||||
}
|
||||
sys.stdout.write(json.dumps(resp) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
else:
|
||||
if msg_id is not None:
|
||||
resp = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": msg_id,
|
||||
"error": {"code": -32601, "message": f"Method not found: {method}"}
|
||||
}
|
||||
sys.stdout.write(json.dumps(resp) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def run_cli():
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description="Supermarket Deals Tool & MCP Server")
|
||||
subparsers = parser.add_subparsers(dest="command")
|
||||
|
||||
sp_search = subparsers.add_parser("search", help="Search supermarket offers")
|
||||
sp_search.add_argument("query", help="Search query (e.g. Mutti, Pampers, Windeln)")
|
||||
sp_search.add_argument("--city", default="Regensburg", help="City name (default: Regensburg)")
|
||||
|
||||
sp_comp = subparsers.add_parser("compare", help="Compare multiple staple items")
|
||||
sp_comp.add_argument("items", help="Comma-separated items, e.g. 'Mutti,Oro di Parma'")
|
||||
sp_comp.add_argument("--city", default="Regensburg", help="City name (default: Regensburg)")
|
||||
|
||||
args = parser.parse_args()
|
||||
client = SupermarketClient()
|
||||
|
||||
if args.command == "search":
|
||||
print(json.dumps(client.search(args.query, city=args.city), indent=2, ensure_ascii=False))
|
||||
elif args.command == "compare":
|
||||
items = [i.strip() for i in args.items.split(",") if i.strip()]
|
||||
print(json.dumps(client.compare_staples(items, city=args.city), indent=2, ensure_ascii=False))
|
||||
else:
|
||||
parser.print_help()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) == 1 or (len(sys.argv) == 2 and sys.argv[1] == "--stdio"):
|
||||
run_mcp_server()
|
||||
else:
|
||||
run_cli()
|
||||
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# dependencies = [
|
||||
# "beautifulsoup4>=4.12.0",
|
||||
# "requests>=2.31.0",
|
||||
# ]
|
||||
# ///
|
||||
"""
|
||||
Universal E-Commerce Product Extractor (Tier 2 JSON-LD + OpenGraph + Microdata)
|
||||
Extracts structured product data (title, brand, price, currency, availability, sku)
|
||||
from arbitrary online stores in < 200ms using Schema.org metadata, bypassing heavy browser
|
||||
overhead and bot detection. Falls back to Chromium CDP if JS rendering is strictly required.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import re
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
from typing import Dict, Any, Optional, List
|
||||
|
||||
DEFAULT_HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
|
||||
"Accept-Language": "de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7",
|
||||
}
|
||||
|
||||
|
||||
class UniversalProductExtractor:
|
||||
def __init__(self, cdp_url: Optional[str] = None):
|
||||
self.cdp_url = cdp_url or os.environ.get("OPENCLAW_CHROMIUM_CDP", "http://127.0.0.1:9222")
|
||||
|
||||
def fetch_html(self, url: str, timeout: int = 15) -> str:
|
||||
req = urllib.request.Request(url, headers=DEFAULT_HEADERS)
|
||||
with urllib.request.urlopen(req, timeout=timeout) as response:
|
||||
encoding = response.headers.get_content_charset() or "utf-8"
|
||||
return response.read().decode(encoding, errors="replace")
|
||||
|
||||
def extract_from_jsonld(self, html: str) -> List[Dict[str, Any]]:
|
||||
"""Extract all Schema.org Product objects from application/ld+json scripts."""
|
||||
products = []
|
||||
pattern = re.compile(r'<script[^>]*type=[\'"]application/ld\+json[\'"][^>]*>(.*?)</script>', re.DOTALL | re.IGNORECASE)
|
||||
for match in pattern.finditer(html):
|
||||
raw_json = match.group(1).strip()
|
||||
if not raw_json:
|
||||
continue
|
||||
try:
|
||||
data = json.loads(raw_json)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
items = data if isinstance(data, list) else [data]
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
# Support @graph structures
|
||||
if "@graph" in item and isinstance(item["@graph"], list):
|
||||
for sub in item["@graph"]:
|
||||
if isinstance(sub, dict) and sub.get("@type") in ("Product", "IndividualProduct"):
|
||||
products.append(self._normalize_schema_product(sub))
|
||||
elif item.get("@type") in ("Product", "IndividualProduct"):
|
||||
products.append(self._normalize_schema_product(item))
|
||||
|
||||
return products
|
||||
|
||||
def _normalize_schema_product(self, raw: dict) -> dict:
|
||||
brand = raw.get("brand")
|
||||
if isinstance(brand, dict):
|
||||
brand = brand.get("name", "")
|
||||
|
||||
offers = raw.get("offers", {})
|
||||
if isinstance(offers, list) and offers:
|
||||
offers = offers[0]
|
||||
|
||||
price = None
|
||||
currency = "EUR"
|
||||
availability = "Unknown"
|
||||
|
||||
if isinstance(offers, dict):
|
||||
price = offers.get("price") or offers.get("lowPrice")
|
||||
currency = offers.get("priceCurrency", "EUR")
|
||||
raw_avail = str(offers.get("availability", ""))
|
||||
if "InStock" in raw_avail:
|
||||
availability = "InStock"
|
||||
elif "OutOfStock" in raw_avail:
|
||||
availability = "OutOfStock"
|
||||
elif "PreOrder" in raw_avail:
|
||||
availability = "PreOrder"
|
||||
|
||||
return {
|
||||
"title": raw.get("name", "").strip(),
|
||||
"brand": str(brand or "").strip(),
|
||||
"price": float(price) if price and str(price).replace(".", "", 1).isdigit() else price,
|
||||
"currency": currency,
|
||||
"availability": availability,
|
||||
"sku": str(raw.get("sku") or raw.get("gtin13") or raw.get("gtin") or "").strip(),
|
||||
"image": raw.get("image") if isinstance(raw.get("image"), str) else (raw.get("image", [None])[0] if isinstance(raw.get("image"), list) else None),
|
||||
"description": raw.get("description", "").strip()[:500] if raw.get("description") else None,
|
||||
"extraction_tier": "json-ld"
|
||||
}
|
||||
|
||||
def extract_from_opengraph(self, html: str) -> Dict[str, Any]:
|
||||
"""Fallback extraction from OpenGraph / Twitter Cards / Meta tags."""
|
||||
title_m = re.search(r'<meta[^>]*property=[\'"]og:title[\'"][^>]*content=[\'"](.*?)[\'"]', html, re.IGNORECASE)
|
||||
price_m = re.search(r'<meta[^>]*property=[\'"]product:price:amount[\'"][^>]*content=[\'"](.*?)[\'"]', html, re.IGNORECASE) or \
|
||||
re.search(r'<meta[^>]*property=[\'"]og:price:amount[\'"][^>]*content=[\'"](.*?)[\'"]', html, re.IGNORECASE)
|
||||
curr_m = re.search(r'<meta[^>]*property=[\'"]product:price:currency[\'"][^>]*content=[\'"](.*?)[\'"]', html, re.IGNORECASE) or \
|
||||
re.search(r'<meta[^>]*property=[\'"]og:price:currency[\'"][^>]*content=[\'"](.*?)[\'"]', html, re.IGNORECASE)
|
||||
img_m = re.search(r'<meta[^>]*property=[\'"]og:image[\'"][^>]*content=[\'"](.*?)[\'"]', html, re.IGNORECASE)
|
||||
|
||||
title = title_m.group(1).strip() if title_m else ""
|
||||
price = price_m.group(1).strip() if price_m else None
|
||||
currency = curr_m.group(1).strip() if curr_m else "EUR"
|
||||
image = img_m.group(1).strip() if img_m else None
|
||||
|
||||
if title or price:
|
||||
return {
|
||||
"title": title,
|
||||
"price": float(price) if price and price.replace(".", "", 1).isdigit() else price,
|
||||
"currency": currency,
|
||||
"image": image,
|
||||
"extraction_tier": "opengraph"
|
||||
}
|
||||
return {}
|
||||
|
||||
def extract(self, url: str) -> Dict[str, Any]:
|
||||
"""Main entry point: fetches URL, extracts JSON-LD, falls back to OpenGraph."""
|
||||
html = self.fetch_html(url)
|
||||
jsonld_products = self.extract_from_jsonld(html)
|
||||
if jsonld_products:
|
||||
res = jsonld_products[0]
|
||||
res["source_url"] = url
|
||||
return res
|
||||
|
||||
og_product = self.extract_from_opengraph(html)
|
||||
if og_product:
|
||||
og_product["source_url"] = url
|
||||
return og_product
|
||||
|
||||
return {
|
||||
"error": "No structured product metadata found (JSON-LD or OpenGraph)",
|
||||
"source_url": url,
|
||||
"extraction_tier": "failed"
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: universal_product_extractor.py <product_url>")
|
||||
sys.exit(1)
|
||||
|
||||
url = sys.argv[1]
|
||||
extractor = UniversalProductExtractor()
|
||||
result = extractor.extract(url)
|
||||
print(json.dumps(result, indent=2, ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
name: "sonos-media-playback"
|
||||
description: "Play music on Sonos speakers via Home Assistant; when Spotify search or play_media fails, use Sonos source_list favorites."
|
||||
---
|
||||
|
||||
# Sonos Media Playback
|
||||
|
||||
Play music (genre, playlist, artist, "50% volume") on a room Sonos via Home Assistant MCP.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Find the room player: `homeassistant__ha_search_entities` with `domain_filter="media_player"` and the room name (German room names match, e.g. "bad" -> `media_player.bad`). Done when one media_player entity is found.
|
||||
2. Set volume first with `ha_call_service(domain="media_player", service="volume_set", data={"volume_level": <0-1>})` — this works even while the player is paused/idle. Percent requested / 100.
|
||||
3. If the player is `paused` or `idle`, wake it with `media_player.media_play` so Spotify Connect may register it.
|
||||
4. Resolve the requested music. Prefer, in order:
|
||||
- `spotify__search` for playlist/track URIs, then play via the Spotify MCP `play` tool. If `play` returns 404 "No active device found": call `spotify__get_devices`, pick the room-matching device, and retry `play` with that `device_id` explicitly.
|
||||
- If Spotify search errors or no Spotify device is active: get the player state with `ha_get_state` and read its `source_list` (Sonos favorites). Pick the favorite closest to the requested genre (e.g. dark techno -> `ChilloutTechno`, sets -> `Fusion 2026 Sets`), then `ha_call_service(domain="media_player", service="select_source", data={"source": "<favorite>"})`.
|
||||
5. Verify: `ha_get_state` on the entity shows `playing` with `media_title`/`media_playlist` and the requested `volume_level`. Report title and volume to the user; offer the nearest alternative favorite if the genre match is loose.
|
||||
|
||||
## Do not retry
|
||||
|
||||
- `ha_call_service(media_player, play_media)` with any `spotify:` URI on Sonos: returns HTTP 500 regardless of `media_content_type` (`music`/`playlist`) — skip it and go straight to `source_list` favorites.
|
||||
- Repeated retries of a failing `spotify__search` with different arguments: the failure is server-side; fall back once, then use favorites.
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
name: spotify
|
||||
description: Full Spotify music intelligence, catalog search, playback control, playlist curation, party queueing, and personal listening history. Use when the user asks to search for music/podcasts/audiobooks, control Spotify playback, queue tracks, check what is currently playing, create or curate playlists (including collaborative mixes for friends), or inspect listening history and top tracks.
|
||||
---
|
||||
|
||||
# 🎵 Spotify Skill for OpenClaw
|
||||
|
||||
Full integration with the **Spotify Web API** powered by `mcp-server-spotify`.
|
||||
|
||||
## 🛠️ MCP Tools & Capabilities
|
||||
|
||||
You have access to 35+ dedicated Spotify MCP tools:
|
||||
|
||||
### 1. Catalog Search
|
||||
- `search(query, types, limit)`: Search for tracks, artists, albums, playlists, shows, episodes, or audiobooks.
|
||||
- `types`: array containing `"track"`, `"artist"`, `"album"`, `"playlist"`, `"show"`, `"episode"`, `"audiobook"`.
|
||||
- Returns Spotify URIs (`spotify:track:...`), titles, artist names, album info, and web URLs (`https://open.spotify.com/...`).
|
||||
|
||||
### 2. Playback & Device Control
|
||||
- `get_playback_state()`: Returns current player status, active device, shuffle/repeat states, and track progress.
|
||||
- `get_currently_playing()`: Quick status of the currently playing track or episode.
|
||||
- `play(device_id, context_uri, uris, position_ms)`: Start or resume playback. Can play a specific playlist/album URI (`context_uri`) or a list of track URIs (`uris`).
|
||||
- `pause(device_id)`: Pause active playback.
|
||||
- `next_track(device_id)` / `previous_track(device_id)`: Skip tracks.
|
||||
- `seek(position_ms, device_id)`: Seek to a position in milliseconds.
|
||||
- `set_volume(volume_percent, device_id)`: Set volume (0-100).
|
||||
- `toggle_shuffle(state, device_id)`: Enable or disable shuffle.
|
||||
- `transfer_playback(device_ids, play)`: Transfer active playback to another Spotify Connect device.
|
||||
- `get_devices()`: List available Spotify Connect devices (TVs, laptops, speakers, mobile).
|
||||
- `add_to_queue(uri, device_id)`: Add a track/episode URI to the user's active playback queue.
|
||||
- `get_queue()`: Inspect currently playing item and upcoming queued items.
|
||||
|
||||
### 3. Playlists & Collaboration
|
||||
- `create_playlist(name, description, public)`: Create a new Spotify playlist for the user.
|
||||
- `get_playlist(playlist_id)`: Retrieve playlist metadata and owner.
|
||||
- `get_playlist_items(playlist_id, limit, offset)`: List tracks inside a playlist.
|
||||
- `add_playlist_items(playlist_id, uris, position)`: Add track URIs to an existing playlist (supports batch addition).
|
||||
- `remove_playlist_items(playlist_id, uris)`: Remove tracks from a playlist.
|
||||
- `reorder_playlist_items(playlist_id, range_start, insert_before)`: Reorder songs in a playlist.
|
||||
- `get_my_playlists(limit, offset)`: List current user's saved and created playlists.
|
||||
|
||||
### 4. Personal History, Taste & Social
|
||||
- `get_recently_played(limit)`: History of recently played tracks with timestamps.
|
||||
- `get_my_top_items(type, time_range, limit)`: Fetch top tracks or top artists over short (`"short_term"` = 4 weeks), medium (`"medium_term"` = 6 months), or long term (`"long_term"` = years).
|
||||
- `get_my_profile()`: User's profile details and country.
|
||||
- `whoami()`: Diagnostic helper returning authentication state, active device, and granted scopes.
|
||||
|
||||
---
|
||||
|
||||
## 📱 Telegram Presentation Guidelines
|
||||
|
||||
When formatting Spotify results or music recommendations for Telegram:
|
||||
- **Clean Markdown Links**: Always format track and playlist links nicely:
|
||||
- `▶️ [Track Title - Artist Name](https://open.spotify.com/track/...)`
|
||||
- `📁 [Playlist Name](https://open.spotify.com/playlist/...)`
|
||||
- **Current Playing Card**: When asked *"what's playing?"*:
|
||||
- Display: `🎶 *Track Title* by *Artist*\n💿 *Album Title* (Year)\n⏱️ mm:ss / mm:ss [■■■□□□□□□□]\n🔗 [Open in Spotify](url)`
|
||||
- **Friend Requests / Party Aux DJ**:
|
||||
- When a friend in a Telegram group asks to add a song:
|
||||
1. Search Spotify for the best match via `search`.
|
||||
2. Add to active queue (`add_to_queue`) or the group's party playlist (`add_playlist_items`).
|
||||
3. Confirm with a friendly message: *"Added 🎵 [Song - Artist](url) to the queue! Next up."*
|
||||
|
||||
---
|
||||
|
||||
## 🏡 Home Assistant (Sonos & Hue) Synergy
|
||||
|
||||
OpenClaw has access to both **Spotify MCP** and **Home Assistant MCP (`ha-mcp`)**:
|
||||
- **Spotify MCP is the Music Brain**: Use it to search the global catalog, find Spotify URIs, build playlists, and inspect listening history.
|
||||
- **Home Assistant is the Physical Hardware Controller**:
|
||||
- Home Assistant's `media_player.play_media` requires a Spotify URI (`spotify:track:...`, `spotify:playlist:...`).
|
||||
- When asked to *"Play X in the living room / on Sonos"*:
|
||||
1. Use `search` from Spotify MCP to get the exact `spotify:track:...` or `spotify:playlist:...` URI.
|
||||
2. Call `ha-mcp:ha_call_service`:
|
||||
- Domain: `media_player`
|
||||
- Service: `play_media`
|
||||
- Service Data: `{"media_content_id": "<spotify_uri>", "media_content_type": "music"}`
|
||||
- Target: `{"entity_id": "media_player.sonos_..."}`
|
||||
3. If asked for ambience or party lighting, call `ha-mcp:ha_call_service` on `light.turn_on` or activate Hue scenes simultaneously.
|
||||
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Spotify MCP Server Launcher for OpenClaw.
|
||||
Ensures credentials.json is configured from SPOTIFY_REFRESH_TOKEN in a writable HOME,
|
||||
pins mcp<2 to avoid FastMCP renaming breakage in mcp 2.x, then executes uvx mcp-server-spotify.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_writable_home() -> Path:
|
||||
candidates = [
|
||||
Path("/home/node/.openclaw/spotify-home"),
|
||||
Path("/tmp/spotify-home"),
|
||||
]
|
||||
for p in candidates:
|
||||
try:
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
test_file = p / ".write_test"
|
||||
test_file.write_text("ok")
|
||||
test_file.unlink()
|
||||
return p
|
||||
except Exception:
|
||||
continue
|
||||
return Path("/tmp")
|
||||
|
||||
|
||||
def main():
|
||||
refresh_token = os.environ.get("SPOTIFY_REFRESH_TOKEN")
|
||||
|
||||
spotify_home = get_writable_home()
|
||||
os.environ["HOME"] = str(spotify_home)
|
||||
|
||||
creds_dir = spotify_home / ".spotify-mcp"
|
||||
creds_file = creds_dir / "credentials.json"
|
||||
|
||||
if refresh_token:
|
||||
try:
|
||||
creds_dir.mkdir(parents=True, exist_ok=True)
|
||||
creds = {
|
||||
"access_token": "init",
|
||||
"refresh_token": refresh_token,
|
||||
"expires_at": 0,
|
||||
}
|
||||
creds_file.write_text(json.dumps(creds, indent=2))
|
||||
try:
|
||||
creds_file.chmod(0o600)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"[run_spotify_mcp] Warning: Could not write credentials file: {e}", file=sys.stderr)
|
||||
|
||||
# Launch mcp-server-spotify via uvx with pinned mcp<2
|
||||
os.execvp("uvx", ["uvx", "--with", "mcp<2", "mcp-server-spotify"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,224 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Spotify OAuth Helper for OpenClaw
|
||||
Generates a permanent refresh_token for OpenClaw's Spotify MCP Server.
|
||||
|
||||
Usage:
|
||||
python3 spotify_auth.py --client-id <ID> --client-secret <SECRET>
|
||||
Or simply:
|
||||
python3 spotify_auth.py
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import webbrowser
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
DEFAULT_PORT = 8888
|
||||
DEFAULT_REDIRECT_URI = f"http://127.0.0.1:{DEFAULT_PORT}/callback"
|
||||
|
||||
SPOTIFY_AUTH_URL = "https://accounts.spotify.com/authorize"
|
||||
SPOTIFY_TOKEN_URL = "https://accounts.spotify.com/api/token"
|
||||
SPOTIFY_ME_URL = "https://api.spotify.com/v1/me"
|
||||
|
||||
SCOPES = [
|
||||
# User profile & account
|
||||
"user-read-private",
|
||||
"user-read-email",
|
||||
# Playback & Devices
|
||||
"user-read-playback-state",
|
||||
"user-modify-playback-state",
|
||||
"user-read-currently-playing",
|
||||
"user-read-recently-played",
|
||||
# Library
|
||||
"user-library-read",
|
||||
"user-library-modify",
|
||||
# Top stats & history
|
||||
"user-top-read",
|
||||
"user-read-playback-position",
|
||||
# Playlists
|
||||
"playlist-read-private",
|
||||
"playlist-read-collaborative",
|
||||
"playlist-modify-public",
|
||||
"playlist-modify-private",
|
||||
# Follow
|
||||
"user-follow-read",
|
||||
"user-follow-modify",
|
||||
]
|
||||
|
||||
|
||||
class OAuthCallbackHandler(BaseHTTPRequestHandler):
|
||||
auth_code = None
|
||||
error = None
|
||||
|
||||
def do_GET(self):
|
||||
parsed_url = urllib.parse.urlparse(self.path)
|
||||
if parsed_url.path == "/callback":
|
||||
params = urllib.parse.parse_qs(parsed_url.query)
|
||||
if "code" in params:
|
||||
OAuthCallbackHandler.auth_code = params["code"][0]
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.end_headers()
|
||||
html = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Spotify Auth Success</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #121212; color: #fff; text-align: center; }
|
||||
.card { background: #181818; padding: 40px; border-radius: 12px; box-shadow: 0 8px 24px rgba(0,0,0,0.5); border: 1px solid #282828; }
|
||||
h1 { color: #1DB954; margin-bottom: 12px; }
|
||||
p { color: #b3b3b3; font-size: 16px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>✅ Authentication Successful!</h1>
|
||||
<p>OpenClaw has successfully connected to your Spotify account.</p>
|
||||
<p>You can now close this browser window and check your terminal.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
self.wfile.write(html.encode("utf-8"))
|
||||
else:
|
||||
err_msg = params.get("error", ["Unknown error"])[0]
|
||||
OAuthCallbackHandler.error = err_msg
|
||||
self.send_response(400)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.end_headers()
|
||||
self.wfile.write(f"<h1>Authentication Failed</h1><p>{err_msg}</p>".encode("utf-8"))
|
||||
else:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
|
||||
def log_message(self, format, *args):
|
||||
# Silence standard HTTP access logging
|
||||
pass
|
||||
|
||||
|
||||
def exchange_code_for_tokens(client_id: str, client_secret: str, code: str, redirect_uri: str) -> dict:
|
||||
auth_header = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
|
||||
data = urllib.parse.urlencode({
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": redirect_uri,
|
||||
}).encode("utf-8")
|
||||
|
||||
req = urllib.request.Request(
|
||||
SPOTIFY_TOKEN_URL,
|
||||
data=data,
|
||||
headers={
|
||||
"Authorization": f"Basic {auth_header}",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
method="POST"
|
||||
)
|
||||
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
|
||||
def fetch_user_profile(access_token: str) -> dict:
|
||||
req = urllib.request.Request(
|
||||
SPOTIFY_ME_URL,
|
||||
headers={"Authorization": f"Bearer {access_token}"}
|
||||
)
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Spotify OAuth Helper for OpenClaw")
|
||||
parser.add_argument("--client-id", help="Spotify Developer App Client ID")
|
||||
parser.add_argument("--client-secret", help="Spotify Developer App Client Secret")
|
||||
parser.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"Local callback port (default: {DEFAULT_PORT})")
|
||||
args = parser.parse_args()
|
||||
|
||||
print("\n🎵 ===========================================")
|
||||
print(" OpenClaw Spotify OAuth Token Generator")
|
||||
print("===========================================\n")
|
||||
|
||||
client_id = args.client_id or os.environ.get("SPOTIFY_CLIENT_ID")
|
||||
client_secret = args.client_secret or os.environ.get("SPOTIFY_CLIENT_SECRET")
|
||||
|
||||
if not client_id:
|
||||
client_id = input("Enter Spotify Client ID: ").strip()
|
||||
if not client_secret:
|
||||
client_secret = input("Enter Spotify Client Secret: ").strip()
|
||||
|
||||
if not client_id or not client_secret:
|
||||
print("❌ Error: Client ID and Client Secret are required.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
redirect_uri = f"http://127.0.0.1:{args.port}/callback"
|
||||
|
||||
# Start local HTTP server for OAuth callback
|
||||
try:
|
||||
server = HTTPServer(("127.0.0.1", args.port), OAuthCallbackHandler)
|
||||
except OSError as e:
|
||||
print(f"❌ Error: Port {args.port} is already in use. Try specifying another port with --port.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Build auth URL
|
||||
auth_params = {
|
||||
"client_id": client_id,
|
||||
"response_type": "code",
|
||||
"redirect_uri": redirect_uri,
|
||||
"scope": " ".join(SCOPES),
|
||||
"show_dialog": "true",
|
||||
}
|
||||
auth_url = f"{SPOTIFY_AUTH_URL}?{urllib.parse.urlencode(auth_params)}"
|
||||
|
||||
print(f"\n🌐 Opening browser to authorize Spotify account...")
|
||||
print(f"If the browser doesn't open automatically, visit this URL:\n\n{auth_url}\n")
|
||||
webbrowser.open(auth_url)
|
||||
|
||||
print(f"Waiting for authorization on {redirect_uri} ...")
|
||||
while OAuthCallbackHandler.auth_code is None and OAuthCallbackHandler.error is None:
|
||||
server.handle_request()
|
||||
|
||||
if OAuthCallbackHandler.error:
|
||||
print(f"\n❌ Authorization rejected or failed: {OAuthCallbackHandler.error}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
code = OAuthCallbackHandler.auth_code
|
||||
print("🔄 Exchanging authorization code for permanent tokens...")
|
||||
|
||||
try:
|
||||
token_data = exchange_code_for_tokens(client_id, client_secret, code, redirect_uri)
|
||||
except urllib.error.HTTPError as e:
|
||||
print(f"\n❌ Failed to exchange code for tokens: {e.read().decode('utf-8')}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
access_token = token_data.get("access_token")
|
||||
refresh_token = token_data.get("refresh_token")
|
||||
|
||||
# Fetch user profile to display confirmation
|
||||
user_name = "Spotify User"
|
||||
try:
|
||||
profile = fetch_user_profile(access_token)
|
||||
user_name = f"{profile.get('display_name', 'User')} ({profile.get('id', '')})"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print("\n🎉 ===================================================")
|
||||
print(f" Successfully Authenticated as: {user_name}")
|
||||
print("===================================================\n")
|
||||
print("Add these lines to your k8s/openclaw/openclaw.secret.yaml:")
|
||||
print("---------------------------------------------------")
|
||||
print(f" spotify-client-id: \"{client_id}\"")
|
||||
print(f" spotify-client-secret: \"{client_secret}\"")
|
||||
print(f" spotify-refresh-token: \"{refresh_token}\"")
|
||||
print("---------------------------------------------------\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,90 @@
|
||||
---
|
||||
name: text-to-audio
|
||||
description: Transformiert Quelltexte, Recherchen, Dossiers, Artikel oder Notizen in ein hochwertiges Audio-Dossier. Erstellt standardmäßig ein Solo-Briefing mit natürlicher Stimme (Google Gemini 3.1 Flash TTS). Schaltet nur dann auf einen Zwei-Sprecher-Dialog um, wenn der Begriff "Podcast" explizit genannt wird.
|
||||
---
|
||||
|
||||
# 🎙️ Text-to-Audio Dossier & Podcast Producer
|
||||
|
||||
Dieser Skill transformiert beliebige Quelltexte (Recherchen, Restaurant-Infos, Artikel, Notizen, Reports) in ein professionelles Audio-Dossier mit **Google Gemini 3.1 Flash TTS**.
|
||||
|
||||
---
|
||||
|
||||
## 🧭 Kern-Regeln für das Format
|
||||
|
||||
### 1. Standard-Modus: Solo-Monolog (DEFAULT)
|
||||
* **Immer Standard für:** Audio-Dossiers, Audio-Briefings, Zusammenfassungen, "Lies mir das vor", "Erstelle ein Audio zu X", tägliche News.
|
||||
* **Ein einzelner, charismatischer Sprecher:** Kein künstlicher Dialog, keine erfundenen Zwischenfragen. Ein flüssiger, strukturierter, gut erzählter Vortrag.
|
||||
* **Vorausgewählte Stimmen:**
|
||||
* **Deutsch:** `Puck` (lebendig, warm, dynamisch) oder `Charon` (tief, ruhig, sachlich).
|
||||
* **Englisch:** `Zephyr` (natürlich, ausgewogen, klar).
|
||||
|
||||
### 2. Dialog-Modus: Nur bei explizitem "Podcast"-Wunsch
|
||||
* **NUR verwenden, wenn:** Der User explizit das Wort **"Podcast"** (oder "Zwei Moderatoren", "Dialog") verwendet!
|
||||
* **Zwei Sprecher (Host & Co-Host):**
|
||||
* **Host:** `Puck` (moderiert an, leitet durch die Themen).
|
||||
* **Co-Host / Expertin:** `Aoede` (bringt Details, Einschätzungen und Praxisbeispiele).
|
||||
|
||||
---
|
||||
|
||||
## ⏱️ Zieldauer & Pacing (~130 Wörter / Minute)
|
||||
|
||||
Ermittle die gewünschte Dauer oder nutze den Standard:
|
||||
* **Kurz-Dossier (`2m`):** ca. 250–300 Wörter.
|
||||
* **Standard-Dossier (`3m`–`4m`, Default):** ca. 400–550 Wörter.
|
||||
* **Ausführlicher Deep Dive (`5m`–`8m`):** ca. 650–1.000 Wörter.
|
||||
|
||||
### Redaktionelles Skripting
|
||||
* **Wichtigkeit vor Quantität:** Kernaussagen und Aha-Momente priorisieren, Füllmaterial und Wiederholungen streichen.
|
||||
* **Für das Ohr schreiben:** Keine Markdown-Artefakte (Sterne, Listen, rohe URLs, Tabellen). Klare akustische Übergänge nutzen.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Ausführung & Synthese
|
||||
|
||||
Nutze das Skript `scripts/text_to_audio.py` (bzw. `/home/node/mop-skills/mop-skills/skills/text-to-audio/scripts/text_to_audio.py` im Cluster).
|
||||
|
||||
### A. Solo-Modus (Standard)
|
||||
Schreibe das Skript am besten in eine Datei, um Shell-Escaping-Probleme zu vermeiden:
|
||||
|
||||
```bash
|
||||
# 1. Skript in Datei schreiben
|
||||
cat << 'TXT_EOF' > /tmp/dossier_script.txt
|
||||
Dein aufbereitetes Skript ohne Markdown-Artefakte...
|
||||
TXT_EOF
|
||||
|
||||
# 2. Synthese starten (läuft parallel & fällt bei Gemini-Timeouts automatisch auf MiniMax zurück)
|
||||
python3 /home/node/mop-skills/mop-skills/skills/text-to-audio/scripts/text_to_audio.py \
|
||||
--file /tmp/dossier_script.txt \
|
||||
--voice Puck \
|
||||
--lang de \
|
||||
--title "Titel des Dossiers" \
|
||||
--output /tmp/audio-dossier.mp3
|
||||
```
|
||||
|
||||
### B. Dialog-Modus (NUR wenn "Podcast" explizit verlangt)
|
||||
```bash
|
||||
cat << 'JSON_EOF' > /tmp/podcast_dialog.json
|
||||
[
|
||||
{"speaker": "host", "text": "Willkommen zum Podcast. Heute sprechen wir über..."},
|
||||
{"speaker": "cohost", "text": "Hi! Ja, das Thema ist super spannend, weil..."}
|
||||
]
|
||||
JSON_EOF
|
||||
|
||||
python3 /home/node/mop-skills/mop-skills/skills/text-to-audio/scripts/text_to_audio.py \
|
||||
--dialog-file /tmp/podcast_dialog.json \
|
||||
--lang de \
|
||||
--title "Podcast Titel" \
|
||||
--output /tmp/audio-dossier.mp3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📱 Auslieferung an den User
|
||||
|
||||
Das Skript gibt am Ende `MEDIA:/tmp/...mp3` aus. Antworte in Telegram:
|
||||
|
||||
```markdown
|
||||
Hier ist dein Audio-Dossier zu **[Thema]** ([Dauer] Min.):
|
||||
|
||||
MEDIA:/tmp/audio-dossier.mp3
|
||||
```
|
||||
@@ -0,0 +1,595 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
text_to_audio.py - High-Quality Text-to-Speech & Podcast Synthesizer for OpenClaw
|
||||
|
||||
Features:
|
||||
- Primary TTS: Google Gemini 3.1 Flash TTS (natural, expressive, low cost).
|
||||
- Automatic Fast Fallback: MiniMax Speech 02 HD (instant, 100% reliable studio quality).
|
||||
- Parallel Synthesis: Multi-threaded chunk processing (ThreadPoolExecutor) for fast generation.
|
||||
- Full Voice Normalization: Maps any voice name or role (Puck, Julius, Lara, Host, etc.) safely.
|
||||
- Live Progress: Unbuffered stdout logging so OpenClaw process watchdogs never timeout.
|
||||
- Format Guarantee: Standard monologue by default; multi-speaker dialog only when requested.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import wave
|
||||
from pathlib import Path
|
||||
|
||||
OPENROUTER_SPEECH_URL = "https://openrouter.ai/api/v1/audio/speech"
|
||||
DEFAULT_PRIMARY_MODEL = "google/gemini-3.1-flash-tts-preview"
|
||||
DEFAULT_FALLBACK_MODEL = "minimax/speech-2.8-hd"
|
||||
|
||||
VOICE_CONFIG = {
|
||||
"de": {
|
||||
"gemini": {
|
||||
"solo": "Puck",
|
||||
"host": "Puck",
|
||||
"cohost": "Aoede",
|
||||
"deep": "Charon",
|
||||
},
|
||||
"minimax": {
|
||||
"solo": "presenter_male",
|
||||
"host": "presenter_male",
|
||||
"cohost": "presenter_female",
|
||||
"deep": "audiobook_male_1",
|
||||
},
|
||||
},
|
||||
"en": {
|
||||
"gemini": {
|
||||
"solo": "Zephyr",
|
||||
"host": "Zephyr",
|
||||
"cohost": "Aoede",
|
||||
"deep": "Charon",
|
||||
},
|
||||
"minimax": {
|
||||
"solo": "presenter_male",
|
||||
"host": "presenter_male",
|
||||
"cohost": "presenter_female",
|
||||
"deep": "audiobook_male_1",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def log_progress(msg: str) -> None:
|
||||
"""Print immediate progress line with flush for OpenClaw process watchers."""
|
||||
print(f"[text-to-audio] {msg}", flush=True)
|
||||
|
||||
|
||||
def find_ffmpeg() -> str:
|
||||
"""Locate the ffmpeg binary across common container and host locations."""
|
||||
env_path = os.environ.get("FFMPEG_PATH")
|
||||
if env_path and os.path.exists(env_path):
|
||||
return env_path
|
||||
|
||||
which_path = shutil.which("ffmpeg")
|
||||
if which_path:
|
||||
return which_path
|
||||
|
||||
candidates = [
|
||||
"/home/node/.openclaw/bin/ffmpeg",
|
||||
"/opt/homebrew/bin/ffmpeg",
|
||||
"/usr/local/bin/ffmpeg",
|
||||
"/usr/bin/ffmpeg",
|
||||
]
|
||||
for c in candidates:
|
||||
if os.path.exists(c) and os.access(c, os.X_OK):
|
||||
return c
|
||||
|
||||
raise RuntimeError("ffmpeg binary not found. Please ensure ffmpeg is installed or set FFMPEG_PATH.")
|
||||
|
||||
|
||||
def normalize_role(role_or_voice: str) -> str:
|
||||
"""Normalize any role, speaker name, or legacy voice string into a canonical role key."""
|
||||
v = (role_or_voice or "").strip().lower()
|
||||
if any(x in v for x in ["cohost", "lara", "female", "expert", "aoede", "guest", "shaonv"]):
|
||||
return "cohost"
|
||||
if any(x in v for x in ["deep", "charon", "doku", "news", "audiobook"]):
|
||||
return "deep"
|
||||
return "solo"
|
||||
|
||||
|
||||
def resolve_model_voice(role_or_voice: str, model: str, lang: str = "de") -> str:
|
||||
"""Resolve a speaker role/name into the exact valid voice parameter for a specific TTS model."""
|
||||
lang_key = "en" if lang.startswith("en") else "de"
|
||||
is_gemini = "gemini" in model.lower()
|
||||
model_family = "gemini" if is_gemini else "minimax"
|
||||
|
||||
# Direct passthrough if already a valid exact model voice
|
||||
if is_gemini and role_or_voice in ["Puck", "Aoede", "Charon", "Zephyr", "Kore", "Fenrir"]:
|
||||
return role_or_voice
|
||||
if not is_gemini and role_or_voice in [
|
||||
"presenter_male",
|
||||
"presenter_female",
|
||||
"audiobook_male_1",
|
||||
"audiobook_female_1",
|
||||
"male-qn-qingse",
|
||||
"female-shaonv",
|
||||
]:
|
||||
return role_or_voice
|
||||
|
||||
role = normalize_role(role_or_voice)
|
||||
return VOICE_CONFIG[lang_key][model_family][role]
|
||||
|
||||
|
||||
def split_text_into_chunks(text: str, max_chars: int = 1200) -> list[str]:
|
||||
"""Split text into natural sentence-level chunks to satisfy API limits and natural pacing."""
|
||||
text = text.strip()
|
||||
if not text:
|
||||
return []
|
||||
if len(text) <= max_chars:
|
||||
return [text]
|
||||
|
||||
sentences = re.split(r"(?<=[.?!;:\n])\s+", text)
|
||||
chunks = []
|
||||
current = []
|
||||
current_len = 0
|
||||
|
||||
for s in sentences:
|
||||
s = s.strip()
|
||||
if not s:
|
||||
continue
|
||||
if current_len + len(s) + 1 > max_chars and current:
|
||||
chunks.append(" ".join(current))
|
||||
current = [s]
|
||||
current_len = len(s)
|
||||
else:
|
||||
current.append(s)
|
||||
current_len += len(s) + 1
|
||||
|
||||
if current:
|
||||
chunks.append(" ".join(current))
|
||||
|
||||
return chunks
|
||||
|
||||
|
||||
def _call_openrouter_speech(
|
||||
model: str,
|
||||
text: str,
|
||||
voice: str,
|
||||
response_format: str,
|
||||
api_key: str,
|
||||
timeout_sec: float,
|
||||
) -> bytes:
|
||||
"""Single HTTP call to OpenRouter /api/v1/audio/speech."""
|
||||
payload = {
|
||||
"model": model,
|
||||
"input": text,
|
||||
"voice": voice,
|
||||
"response_format": response_format,
|
||||
}
|
||||
req = urllib.request.Request(
|
||||
OPENROUTER_SPEECH_URL,
|
||||
data=json.dumps(payload).encode("utf-8"),
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "OpenClaw-TextToAudio/2.1",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=timeout_sec) as resp:
|
||||
content = resp.read()
|
||||
if not content or len(content) < 100:
|
||||
raise RuntimeError("Received empty audio stream from TTS provider.")
|
||||
return content
|
||||
|
||||
|
||||
def synthesize_chunk_to_wav(
|
||||
chunk_idx: int,
|
||||
total_chunks: int,
|
||||
text: str,
|
||||
speaker_or_voice: str,
|
||||
output_wav_path: str,
|
||||
ffmpeg_bin: str,
|
||||
preferred_model: str = DEFAULT_PRIMARY_MODEL,
|
||||
fallback_model: str = DEFAULT_FALLBACK_MODEL,
|
||||
lang: str = "de",
|
||||
api_key: str | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Synthesizes a text chunk to a standardized 24kHz mono 16-bit WAV file.
|
||||
Tries preferred_model first; on timeout/error, immediately falls back to fallback_model.
|
||||
"""
|
||||
key = api_key or os.environ.get("OPENROUTER_API_KEY")
|
||||
if not key:
|
||||
raise ValueError("OPENROUTER_API_KEY environment variable is not set.")
|
||||
|
||||
t0 = time.time()
|
||||
voice_pref = resolve_model_voice(speaker_or_voice, model=preferred_model, lang=lang)
|
||||
pref_is_gemini = "gemini" in preferred_model.lower()
|
||||
pref_format = "pcm" if pref_is_gemini else "mp3"
|
||||
|
||||
log_progress(f"Chunk {chunk_idx + 1}/{total_chunks}: synthesizing ({len(text)} chars, {preferred_model} / {voice_pref})...")
|
||||
|
||||
# Try preferred model (timeout: 16 seconds)
|
||||
try:
|
||||
content = _call_openrouter_speech(
|
||||
model=preferred_model,
|
||||
text=text,
|
||||
voice=voice_pref,
|
||||
response_format=pref_format,
|
||||
api_key=key,
|
||||
timeout_sec=16.0,
|
||||
)
|
||||
if pref_is_gemini:
|
||||
# Wrap raw 24kHz s16le PCM into standard WAV
|
||||
with wave.open(output_wav_path, "wb") as w:
|
||||
w.setnchannels(1)
|
||||
w.setsampwidth(2)
|
||||
w.setframerate(24000)
|
||||
w.writeframes(content)
|
||||
else:
|
||||
# Re-encode MP3 to uniform 24kHz mono WAV
|
||||
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as tf:
|
||||
tf.write(content)
|
||||
tmp_mp3 = tf.name
|
||||
try:
|
||||
subprocess.run(
|
||||
[ffmpeg_bin, "-y", "-i", tmp_mp3, "-ar", "24000", "-ac", "1", output_wav_path],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=True,
|
||||
)
|
||||
finally:
|
||||
if os.path.exists(tmp_mp3):
|
||||
os.remove(tmp_mp3)
|
||||
|
||||
dt = time.time() - t0
|
||||
log_progress(f"Chunk {chunk_idx + 1}/{total_chunks}: generated via {preferred_model} in {dt:.1f}s")
|
||||
return
|
||||
except Exception as err:
|
||||
log_progress(f"Chunk {chunk_idx + 1}/{total_chunks}: primary {preferred_model} failed ({err}). Falling back to {fallback_model}...")
|
||||
|
||||
# Immediate fallback
|
||||
t1 = time.time()
|
||||
voice_fb = resolve_model_voice(speaker_or_voice, model=fallback_model, lang=lang)
|
||||
fb_is_gemini = "gemini" in fallback_model.lower()
|
||||
fb_format = "pcm" if fb_is_gemini else "mp3"
|
||||
|
||||
try:
|
||||
content = _call_openrouter_speech(
|
||||
model=fallback_model,
|
||||
text=text,
|
||||
voice=voice_fb,
|
||||
response_format=fb_format,
|
||||
api_key=key,
|
||||
timeout_sec=15.0,
|
||||
)
|
||||
if fb_is_gemini:
|
||||
with wave.open(output_wav_path, "wb") as w:
|
||||
w.setnchannels(1)
|
||||
w.setsampwidth(2)
|
||||
w.setframerate(24000)
|
||||
w.writeframes(content)
|
||||
else:
|
||||
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as tf:
|
||||
tf.write(content)
|
||||
tmp_mp3 = tf.name
|
||||
try:
|
||||
subprocess.run(
|
||||
[ffmpeg_bin, "-y", "-i", tmp_mp3, "-ar", "24000", "-ac", "1", output_wav_path],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=True,
|
||||
)
|
||||
finally:
|
||||
if os.path.exists(tmp_mp3):
|
||||
os.remove(tmp_mp3)
|
||||
|
||||
dt = time.time() - t1
|
||||
log_progress(f"Chunk {chunk_idx + 1}/{total_chunks}: generated via fallback {fallback_model} in {dt:.1f}s")
|
||||
return
|
||||
except Exception as fb_err:
|
||||
raise RuntimeError(
|
||||
f"Chunk {chunk_idx + 1}/{total_chunks} synthesis failed on both primary ({err}) and fallback ({fb_err})"
|
||||
)
|
||||
|
||||
|
||||
def generate_silence_wav(duration_sec: float, output_path: str, sample_rate: int = 24000) -> None:
|
||||
"""Generate a clean silence WAV file."""
|
||||
num_frames = int(duration_sec * sample_rate)
|
||||
silence_bytes = b"\x00\x00" * num_frames
|
||||
with wave.open(output_path, "wb") as w:
|
||||
w.setnchannels(1)
|
||||
w.setsampwidth(2)
|
||||
w.setframerate(sample_rate)
|
||||
w.writeframes(silence_bytes)
|
||||
|
||||
|
||||
def stitch_audio_segments(
|
||||
ffmpeg_bin: str,
|
||||
wav_files: list[str],
|
||||
output_file: str,
|
||||
title: str = "Audio Dossier",
|
||||
artist: str = "OpenClaw",
|
||||
album: str = "OpenClaw Audio Briefings",
|
||||
) -> None:
|
||||
"""Concatenate uniform WAV segments and produce high-quality 128k MP3 with metadata."""
|
||||
if not wav_files:
|
||||
raise ValueError("No audio segments to stitch.")
|
||||
|
||||
log_progress(f"Stitching {len(wav_files)} segment(s) into MP3 with metadata...")
|
||||
|
||||
if len(wav_files) == 1:
|
||||
cmd = [
|
||||
ffmpeg_bin,
|
||||
"-y",
|
||||
"-i",
|
||||
wav_files[0],
|
||||
"-metadata",
|
||||
f"title={title}",
|
||||
"-metadata",
|
||||
f"artist={artist}",
|
||||
"-metadata",
|
||||
f"album={album}",
|
||||
"-c:a",
|
||||
"libmp3lame",
|
||||
"-b:a",
|
||||
"128k",
|
||||
output_file,
|
||||
]
|
||||
res = subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True)
|
||||
if res.returncode != 0:
|
||||
raise RuntimeError(f"FFmpeg export failed: {res.stderr}")
|
||||
return
|
||||
|
||||
# Concat demuxer with single-quoted file paths
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False) as list_file:
|
||||
for f in wav_files:
|
||||
safe_path = f.replace("'", "'\\''")
|
||||
list_file.write(f"file '{safe_path}'\n")
|
||||
list_file_path = list_file.name
|
||||
|
||||
try:
|
||||
cmd = [
|
||||
ffmpeg_bin,
|
||||
"-y",
|
||||
"-f",
|
||||
"concat",
|
||||
"-safe",
|
||||
"0",
|
||||
"-i",
|
||||
list_file_path,
|
||||
"-metadata",
|
||||
f"title={title}",
|
||||
"-metadata",
|
||||
f"artist={artist}",
|
||||
"-metadata",
|
||||
f"album={album}",
|
||||
"-c:a",
|
||||
"libmp3lame",
|
||||
"-b:a",
|
||||
"128k",
|
||||
output_file,
|
||||
]
|
||||
res = subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True)
|
||||
if res.returncode != 0:
|
||||
raise RuntimeError(f"FFmpeg concatenation failed: {res.stderr}")
|
||||
finally:
|
||||
if os.path.exists(list_file_path):
|
||||
os.remove(list_file_path)
|
||||
|
||||
|
||||
def process_solo_text(
|
||||
text: str,
|
||||
ffmpeg_bin: str,
|
||||
voice_or_role: str,
|
||||
primary_model: str,
|
||||
fallback_model: str,
|
||||
lang: str,
|
||||
output_file: str,
|
||||
title: str,
|
||||
max_workers: int = 4,
|
||||
) -> None:
|
||||
"""Generate audio for a single-speaker text in parallel (Default for all audio dossiers)."""
|
||||
chunks = split_text_into_chunks(text)
|
||||
if not chunks:
|
||||
raise ValueError("No text provided to synthesize.")
|
||||
|
||||
log_progress(f"Solo text synthesis started: {len(chunks)} chunk(s), ~{len(text.split())} words, lang={lang}")
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="audio_solo_") as tmpdir:
|
||||
wav_tasks = []
|
||||
for i, chunk in enumerate(chunks):
|
||||
chunk_wav = os.path.join(tmpdir, f"chunk_{i:04d}.wav")
|
||||
wav_tasks.append((i, len(chunks), chunk, voice_or_role, chunk_wav))
|
||||
|
||||
with ThreadPoolExecutor(max_workers=min(len(chunks), max_workers)) as pool:
|
||||
futures = [
|
||||
pool.submit(
|
||||
synthesize_chunk_to_wav,
|
||||
idx,
|
||||
tot,
|
||||
chk,
|
||||
v,
|
||||
path,
|
||||
ffmpeg_bin,
|
||||
primary_model,
|
||||
fallback_model,
|
||||
lang,
|
||||
)
|
||||
for idx, tot, chk, v, path in wav_tasks
|
||||
]
|
||||
for fut in futures:
|
||||
fut.result()
|
||||
|
||||
wav_files = [task[4] for task in wav_tasks]
|
||||
stitch_audio_segments(
|
||||
ffmpeg_bin=ffmpeg_bin,
|
||||
wav_files=wav_files,
|
||||
output_file=output_file,
|
||||
title=title,
|
||||
)
|
||||
|
||||
|
||||
def process_dialog(
|
||||
dialog_items: list[dict],
|
||||
ffmpeg_bin: str,
|
||||
primary_model: str,
|
||||
fallback_model: str,
|
||||
lang: str,
|
||||
output_file: str,
|
||||
title: str,
|
||||
pause_duration: float = 0.35,
|
||||
max_workers: int = 4,
|
||||
) -> None:
|
||||
"""Generate audio for multi-speaker dialog turns in parallel (used only when 'podcast' requested)."""
|
||||
if not dialog_items:
|
||||
raise ValueError("Dialog list is empty.")
|
||||
|
||||
log_progress(f"Dialog synthesis started: {len(dialog_items)} turn(s), lang={lang}")
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="audio_dialog_") as tmpdir:
|
||||
silence_file = os.path.join(tmpdir, "pause.wav")
|
||||
generate_silence_wav(pause_duration, silence_file)
|
||||
|
||||
# Flatten dialog items into chunks if any turn exceeds max_chars
|
||||
flattened_items = []
|
||||
for d in dialog_items:
|
||||
speaker = d.get("speaker", "host")
|
||||
text = d.get("text", "").strip()
|
||||
if not text:
|
||||
continue
|
||||
chunks = split_text_into_chunks(text)
|
||||
for c in chunks:
|
||||
flattened_items.append((speaker, c))
|
||||
|
||||
wav_tasks = []
|
||||
for i, (speaker, chunk_text) in enumerate(flattened_items):
|
||||
turn_wav = os.path.join(tmpdir, f"turn_{i:04d}.wav")
|
||||
wav_tasks.append((i, len(flattened_items), chunk_text, speaker, turn_wav))
|
||||
|
||||
with ThreadPoolExecutor(max_workers=min(len(flattened_items), max_workers)) as pool:
|
||||
futures = [
|
||||
pool.submit(
|
||||
synthesize_chunk_to_wav,
|
||||
idx,
|
||||
tot,
|
||||
chk,
|
||||
spk,
|
||||
path,
|
||||
ffmpeg_bin,
|
||||
primary_model,
|
||||
fallback_model,
|
||||
lang,
|
||||
)
|
||||
for idx, tot, chk, spk, path in wav_tasks
|
||||
]
|
||||
for fut in futures:
|
||||
fut.result()
|
||||
|
||||
# Interleave silence between dialog turns
|
||||
stitched_wavs = []
|
||||
for task in wav_tasks:
|
||||
stitched_wavs.append(task[4])
|
||||
stitched_wavs.append(silence_file)
|
||||
|
||||
if stitched_wavs and stitched_wavs[-1] == silence_file:
|
||||
stitched_wavs.pop()
|
||||
|
||||
stitch_audio_segments(
|
||||
ffmpeg_bin=ffmpeg_bin,
|
||||
wav_files=stitched_wavs,
|
||||
output_file=output_file,
|
||||
title=title,
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="OpenClaw Text-to-Audio Dossier Generator")
|
||||
parser.add_argument("--text", help="Plaintext to synthesize (default solo mode)")
|
||||
parser.add_argument("--file", help="Path to plaintext file (default solo mode)")
|
||||
parser.add_argument("--dialog-json", help="JSON string of dialog turns (only if 'podcast' requested)")
|
||||
parser.add_argument("--dialog-file", help="Path to JSON file with dialog turns")
|
||||
parser.add_argument("--output", "-o", help="Target output MP3 path")
|
||||
parser.add_argument("--title", default="Audio Dossier", help="Audio title for ID3 metadata")
|
||||
parser.add_argument("--voice", help="Voice or speaker role (e.g. Puck, Zephyr, Aoede, host, solo)")
|
||||
parser.add_argument("--lang", default="de", choices=["de", "en"], help="Language preset (de/en)")
|
||||
parser.add_argument("--model", default=DEFAULT_PRIMARY_MODEL, help=f"Primary model (default: {DEFAULT_PRIMARY_MODEL})")
|
||||
parser.add_argument("--fallback-model", default=DEFAULT_FALLBACK_MODEL, help=f"Fallback model (default: {DEFAULT_FALLBACK_MODEL})")
|
||||
parser.add_argument("--pause", type=float, default=0.35, help="Pause in seconds between dialog turns")
|
||||
parser.add_argument("--workers", type=int, default=4, help="Max concurrent synthesis threads")
|
||||
parser.add_argument("--media-tag", action="store_true", default=True, help="Print MEDIA:<path> tag to stdout")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
ffmpeg_bin = find_ffmpeg()
|
||||
|
||||
if args.output:
|
||||
out_path = Path(args.output).expanduser().resolve()
|
||||
else:
|
||||
ts = int(time.time())
|
||||
out_path = Path(f"/tmp/audio-dossier-{ts}.mp3")
|
||||
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
t_start = time.time()
|
||||
|
||||
dialog_data = None
|
||||
if args.dialog_file:
|
||||
with open(args.dialog_file, "r", encoding="utf-8") as f:
|
||||
dialog_data = json.load(f)
|
||||
elif args.dialog_json:
|
||||
dialog_data = json.loads(args.dialog_json)
|
||||
|
||||
if dialog_data:
|
||||
if not isinstance(dialog_data, list):
|
||||
raise ValueError("Dialog input must be a JSON array of objects with 'speaker' and 'text'.")
|
||||
process_dialog(
|
||||
dialog_items=dialog_data,
|
||||
ffmpeg_bin=ffmpeg_bin,
|
||||
primary_model=args.model,
|
||||
fallback_model=args.fallback_model,
|
||||
lang=args.lang,
|
||||
output_file=str(out_path),
|
||||
title=args.title,
|
||||
pause_duration=args.pause,
|
||||
max_workers=args.workers,
|
||||
)
|
||||
else:
|
||||
# Solo monologue mode (Strict DEFAULT)
|
||||
raw_text = ""
|
||||
if args.file:
|
||||
with open(args.file, "r", encoding="utf-8") as f:
|
||||
raw_text = f.read()
|
||||
elif args.text:
|
||||
raw_text = args.text
|
||||
elif not sys.stdin.isatty():
|
||||
raw_text = sys.stdin.read()
|
||||
else:
|
||||
parser.error("Must provide either --text, --file, --dialog-json, --dialog-file, or pipe text to stdin.")
|
||||
|
||||
chosen_voice = args.voice or "solo"
|
||||
process_solo_text(
|
||||
text=raw_text,
|
||||
ffmpeg_bin=ffmpeg_bin,
|
||||
voice_or_role=chosen_voice,
|
||||
primary_model=args.model,
|
||||
fallback_model=args.fallback_model,
|
||||
lang=args.lang,
|
||||
output_file=str(out_path),
|
||||
title=args.title,
|
||||
max_workers=args.workers,
|
||||
)
|
||||
|
||||
total_time = time.time() - t_start
|
||||
log_progress(f"Finished in {total_time:.1f}s: {out_path} ({out_path.stat().st_size} bytes)")
|
||||
|
||||
if args.media_tag:
|
||||
print(f"MEDIA:{out_path}", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
name: youtube
|
||||
description: Search YouTube videos, retrieve video metadata, and extract full transcripts/subtitles with timestamps for instant AI summarization, recipes, or research.
|
||||
---
|
||||
|
||||
# 📺 YouTube Integration (Search & Transcripts)
|
||||
|
||||
Full YouTube search and transcript extraction engine for OpenClaw:
|
||||
|
||||
1. 🔍 **`youtube_search(query, max_results=5, video_duration="any")`**:
|
||||
- Searches YouTube via YouTube Data API v3.
|
||||
- Returns title, channel, video ID, formatted duration, view count, published date, and direct link.
|
||||
- Durations: `any`, `short` (< 4 min), `medium` (4-20 min), `long` (> 20 min).
|
||||
|
||||
2. 📜 **`youtube_get_transcript(video_url_or_id, languages=["de", "en"], include_timestamps=True)`**:
|
||||
- **Unlimited & 100% Free**: Powered by `youtube-transcript-api` (zero GCP quota consumption).
|
||||
- Fetches formatted subtitles and speech transcripts with `[MM:SS]` timestamps.
|
||||
- Automatically prioritizes German (`de`) and English (`en`), including auto-generated subtitles.
|
||||
- Perfect for:
|
||||
- Summarizing interviews, podcasts, tech talks, and keynotes.
|
||||
- Extracting cooking steps and recipes from YouTube food videos for `koch`.
|
||||
- Extracting command-line instructions, code, or homelab setup guides for `itguy`.
|
||||
|
||||
3. ℹ️ **`youtube_video_details(video_url_or_id)`**:
|
||||
- Fetches full description, tags, view count, like count, and publication details.
|
||||
|
||||
---
|
||||
|
||||
## 🧭 Prompt Routing & Best Practices
|
||||
|
||||
- **When the user pastes a YouTube URL** (e.g. *"Was wird in diesem Video erklärt? https://youtu.be/..."* or *"Fass mir das Video kurz zusammen"*):
|
||||
- Do NOT waste a turn searching. Directly call `youtube_get_transcript` with the URL!
|
||||
- Provide a concise executive summary with key takeaways, timestamps for notable moments, and actionable conclusions.
|
||||
- **For Koch / Recipes**:
|
||||
- When the user asks for a recipe from a video, pull the transcript via `youtube_get_transcript`, extract the ingredients and steps, and offer to add missing items to the Bring! shopping list.
|
||||
- **For Research / Recherche**:
|
||||
- Search relevant videos via `youtube_search` to find relevant lectures, documentaries, or news reports, then extract the transcript of the top hit for deep grounded analysis.
|
||||
@@ -0,0 +1,568 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
YouTube MCP Server & CLI Tool for OpenClaw.
|
||||
|
||||
Integrates YouTube search, video metadata, and free transcript extraction
|
||||
(subtitles) via YouTube Data API v3 and youtube-transcript-api.
|
||||
|
||||
Features:
|
||||
- youtube_search: Search videos by keyword with title, channel, duration, and views
|
||||
- youtube_get_transcript: Fetch German and English subtitles with timestamps (100% free, zero quota)
|
||||
- youtube_video_details: Fetch full video metadata and description
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
import json
|
||||
import argparse
|
||||
from typing import Dict, Any, List, Optional
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
# Environment
|
||||
YOUTUBE_API_KEY = os.environ.get("YOUTUBE_API_KEY", "").strip()
|
||||
|
||||
|
||||
def extract_video_id(url_or_id: str) -> Optional[str]:
|
||||
"""Extract 11-character YouTube video ID from various URL formats or raw ID."""
|
||||
if not url_or_id:
|
||||
return None
|
||||
url_or_id = url_or_id.strip()
|
||||
# If already an 11-char alphanumeric ID
|
||||
if re.match(r"^[a-zA-Z0-9_-]{11}$", url_or_id):
|
||||
return url_or_id
|
||||
|
||||
# Common URL formats:
|
||||
# https://www.youtube.com/watch?v=VIDEO_ID
|
||||
# https://youtu.be/VIDEO_ID
|
||||
# https://www.youtube.com/shorts/VIDEO_ID
|
||||
# https://www.youtube.com/embed/VIDEO_ID
|
||||
parsed = urlparse(url_or_id)
|
||||
if "youtube.com" in parsed.netloc:
|
||||
if parsed.path == "/watch":
|
||||
qs = parse_qs(parsed.query)
|
||||
if "v" in qs and qs["v"]:
|
||||
return qs["v"][0]
|
||||
elif parsed.path.startswith("/shorts/"):
|
||||
parts = parsed.path.split("/")
|
||||
if len(parts) >= 3 and len(parts[2]) == 11:
|
||||
return parts[2]
|
||||
elif parsed.path.startswith("/embed/"):
|
||||
parts = parsed.path.split("/")
|
||||
if len(parts) >= 3 and len(parts[2]) == 11:
|
||||
return parts[2]
|
||||
elif "youtu.be" in parsed.netloc:
|
||||
path = parsed.path.lstrip("/")
|
||||
# Path might have query params or /
|
||||
video_id = path.split("?")[0].split("/")[0]
|
||||
if len(video_id) == 11:
|
||||
return video_id
|
||||
|
||||
# Regex fallback
|
||||
match = re.search(r"[a-zA-Z0-9_-]{11}", url_or_id)
|
||||
if match:
|
||||
return match.group(0)
|
||||
return None
|
||||
|
||||
|
||||
def parse_iso_duration(duration_str: str) -> str:
|
||||
"""Convert ISO 8601 duration (PT1H2M30S or PT5M12S) to human readable (01:02:30 or 05:12)."""
|
||||
if not duration_str or not duration_str.startswith("PT"):
|
||||
return duration_str or ""
|
||||
hours = 0
|
||||
minutes = 0
|
||||
seconds = 0
|
||||
|
||||
pattern = re.compile(r"PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?")
|
||||
match = pattern.match(duration_str)
|
||||
if match:
|
||||
h, m, s = match.groups()
|
||||
hours = int(h) if h else 0
|
||||
minutes = int(m) if m else 0
|
||||
seconds = int(s) if s else 0
|
||||
if hours > 0:
|
||||
return f"{hours}:{minutes:02d}:{seconds:02d}"
|
||||
return f"{minutes:02d}:{seconds:02d}"
|
||||
return duration_str
|
||||
|
||||
|
||||
def format_seconds(seconds: float) -> str:
|
||||
"""Format seconds into HH:MM:SS or MM:SS."""
|
||||
s = int(seconds)
|
||||
hours = s // 3600
|
||||
minutes = (s % 3600) // 60
|
||||
secs = s % 60
|
||||
if hours > 0:
|
||||
return f"{hours:02d}:{minutes:02d}:{secs:02d}"
|
||||
return f"{minutes:02d}:{secs:02d}"
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# YouTube Operations
|
||||
# ==============================================================================
|
||||
|
||||
def search_videos(
|
||||
query: str,
|
||||
max_results: int = 5,
|
||||
video_duration: str = "any",
|
||||
api_key: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Search YouTube videos using YouTube Data API v3."""
|
||||
key = api_key or YOUTUBE_API_KEY
|
||||
if not key:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "YOUTUBE_API_KEY is not set. Please provide a valid Google Cloud API key.",
|
||||
"videos": [],
|
||||
}
|
||||
|
||||
try:
|
||||
from googleapiclient.discovery import build
|
||||
youtube = build("youtube", "v3", developerKey=key, cache_discovery=False)
|
||||
|
||||
search_kwargs = {
|
||||
"q": query,
|
||||
"part": "snippet",
|
||||
"type": "video",
|
||||
"maxResults": min(max(1, max_results), 20),
|
||||
}
|
||||
if video_duration in ["short", "medium", "long"]:
|
||||
search_kwargs["videoDuration"] = video_duration
|
||||
|
||||
search_resp = youtube.search().list(**search_kwargs).execute()
|
||||
items = search_resp.get("items", [])
|
||||
if not items:
|
||||
return {"success": True, "query": query, "count": 0, "videos": []}
|
||||
|
||||
video_ids = [it["id"]["videoId"] for it in items if "videoId" in it.get("id", {})]
|
||||
|
||||
# Batch fetch details (duration, view count, stats) - only 1 unit!
|
||||
details_map = {}
|
||||
if video_ids:
|
||||
videos_resp = youtube.videos().list(
|
||||
id=",".join(video_ids),
|
||||
part="contentDetails,statistics,snippet"
|
||||
).execute()
|
||||
for v in videos_resp.get("items", []):
|
||||
details_map[v["id"]] = v
|
||||
|
||||
videos = []
|
||||
for item in items:
|
||||
vid = item["id"].get("videoId")
|
||||
if not vid:
|
||||
continue
|
||||
snip = item.get("snippet", {})
|
||||
v_detail = details_map.get(vid, {})
|
||||
cd = v_detail.get("contentDetails", {})
|
||||
stats = v_detail.get("statistics", {})
|
||||
|
||||
duration_raw = cd.get("duration", "")
|
||||
duration_formatted = parse_iso_duration(duration_raw)
|
||||
|
||||
videos.append({
|
||||
"video_id": vid,
|
||||
"title": snip.get("title", ""),
|
||||
"channel": snip.get("channelTitle", ""),
|
||||
"published_at": snip.get("publishedAt", ""),
|
||||
"duration": duration_formatted,
|
||||
"view_count": stats.get("viewCount", "N/A"),
|
||||
"url": f"https://www.youtube.com/watch?v={vid}",
|
||||
"description_snippet": snip.get("description", "")[:200],
|
||||
"thumbnail": snip.get("thumbnails", {}).get("high", {}).get("url", ""),
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"query": query,
|
||||
"count": len(videos),
|
||||
"videos": videos,
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"YouTube search failed: {str(e)}",
|
||||
"videos": [],
|
||||
}
|
||||
|
||||
|
||||
def get_transcript(
|
||||
video_url_or_id: str,
|
||||
languages: Optional[List[str]] = None,
|
||||
include_timestamps: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
"""Fetch video transcript/subtitles using youtube-transcript-api."""
|
||||
video_id = extract_video_id(video_url_or_id)
|
||||
if not video_id:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Could not extract a valid YouTube video ID from '{video_url_or_id}'.",
|
||||
}
|
||||
|
||||
lang_pref = languages or ["de", "en"]
|
||||
|
||||
try:
|
||||
from youtube_transcript_api import YouTubeTranscriptApi
|
||||
|
||||
api = YouTubeTranscriptApi()
|
||||
|
||||
# Try fetching with preferred languages
|
||||
transcript_obj = None
|
||||
detected_lang = None
|
||||
is_generated = False
|
||||
|
||||
# Use .list(video_id) to inspect available tracks and find best match
|
||||
try:
|
||||
transcript_list = api.list(video_id)
|
||||
# Find best match or auto-generated
|
||||
try:
|
||||
transcript_obj = transcript_list.find_transcript(lang_pref)
|
||||
except Exception:
|
||||
# If no direct match in lang_pref, try any available transcript and translate if possible
|
||||
for t in transcript_list:
|
||||
transcript_obj = t
|
||||
break
|
||||
|
||||
if transcript_obj:
|
||||
detected_lang = transcript_obj.language_code
|
||||
is_generated = transcript_obj.is_generated
|
||||
snippets = transcript_obj.fetch()
|
||||
else:
|
||||
# Fallback to direct fetch
|
||||
snippets = api.fetch(video_id, languages=lang_pref)
|
||||
except Exception:
|
||||
# Fallback to direct fetch
|
||||
snippets = api.fetch(video_id, languages=lang_pref)
|
||||
|
||||
formatted_lines = []
|
||||
full_text_chunks = []
|
||||
total_duration = 0.0
|
||||
|
||||
for item in snippets:
|
||||
# item may be an object (FetchedTranscriptSnippet) or dict
|
||||
if hasattr(item, "text"):
|
||||
text = item.text.strip()
|
||||
start = getattr(item, "start", 0.0)
|
||||
duration = getattr(item, "duration", 0.0)
|
||||
elif isinstance(item, dict):
|
||||
text = item.get("text", "").strip()
|
||||
start = item.get("start", 0.0)
|
||||
duration = item.get("duration", 0.0)
|
||||
else:
|
||||
text = str(item).strip()
|
||||
start = 0.0
|
||||
duration = 0.0
|
||||
|
||||
if not text:
|
||||
continue
|
||||
|
||||
timestamp = format_seconds(start)
|
||||
formatted_lines.append(f"[{timestamp}] {text}")
|
||||
full_text_chunks.append(text)
|
||||
total_duration = max(total_duration, start + duration)
|
||||
|
||||
full_text = " ".join(full_text_chunks)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"video_id": video_id,
|
||||
"url": f"https://www.youtube.com/watch?v={video_id}",
|
||||
"language": detected_lang or "auto",
|
||||
"is_generated": is_generated,
|
||||
"duration_seconds": round(total_duration, 1),
|
||||
"duration_formatted": format_seconds(total_duration),
|
||||
"snippet_count": len(formatted_lines),
|
||||
"formatted_transcript": "\n".join(formatted_lines) if include_timestamps else full_text,
|
||||
"full_text": full_text,
|
||||
}
|
||||
except Exception as e:
|
||||
err_msg = str(e)
|
||||
if "TranscriptsDisabled" in err_msg or "Subtitles are disabled" in err_msg:
|
||||
return {
|
||||
"success": False,
|
||||
"video_id": video_id,
|
||||
"error": "Transcripts are disabled for this video by the uploader.",
|
||||
}
|
||||
elif "NoTranscriptFound" in err_msg:
|
||||
return {
|
||||
"success": False,
|
||||
"video_id": video_id,
|
||||
"error": f"No transcript found in requested languages ({lang_pref}).",
|
||||
}
|
||||
return {
|
||||
"success": False,
|
||||
"video_id": video_id,
|
||||
"error": f"Failed to retrieve transcript: {err_msg}",
|
||||
}
|
||||
|
||||
|
||||
def get_video_details(
|
||||
video_url_or_id: str,
|
||||
api_key: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Fetch complete video details and description."""
|
||||
video_id = extract_video_id(video_url_or_id)
|
||||
if not video_id:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Could not extract a valid YouTube video ID from '{video_url_or_id}'.",
|
||||
}
|
||||
|
||||
key = api_key or YOUTUBE_API_KEY
|
||||
if not key:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "YOUTUBE_API_KEY is not set.",
|
||||
}
|
||||
|
||||
try:
|
||||
from googleapiclient.discovery import build
|
||||
youtube = build("youtube", "v3", developerKey=key, cache_discovery=False)
|
||||
resp = youtube.videos().list(
|
||||
id=video_id,
|
||||
part="snippet,contentDetails,statistics"
|
||||
).execute()
|
||||
|
||||
items = resp.get("items", [])
|
||||
if not items:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Video '{video_id}' not found.",
|
||||
}
|
||||
|
||||
item = items[0]
|
||||
snip = item.get("snippet", {})
|
||||
cd = item.get("contentDetails", {})
|
||||
stats = item.get("statistics", {})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"video_id": video_id,
|
||||
"title": snip.get("title", ""),
|
||||
"channel": snip.get("channelTitle", ""),
|
||||
"published_at": snip.get("publishedAt", ""),
|
||||
"description": snip.get("description", ""),
|
||||
"duration": parse_iso_duration(cd.get("duration", "")),
|
||||
"view_count": stats.get("viewCount", "0"),
|
||||
"like_count": stats.get("likeCount", "0"),
|
||||
"comment_count": stats.get("commentCount", "0"),
|
||||
"tags": snip.get("tags", []),
|
||||
"url": f"https://www.youtube.com/watch?v={video_id}",
|
||||
"thumbnail": snip.get("thumbnails", {}).get("maxres", snip.get("thumbnails", {}).get("high", {})).get("url", ""),
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Failed to get video details: {str(e)}",
|
||||
}
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# MCP Protocol Handlers (JSON-RPC 2.0 stdio)
|
||||
# ==============================================================================
|
||||
|
||||
TOOLS_DEFINITIONS = [
|
||||
{
|
||||
"name": "youtube_search",
|
||||
"description": "Search YouTube for videos matching a query. Returns video title, channel, video ID, formatted duration, view count, and direct link.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["query"],
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "The search term or topic (e.g. 'Ninja Creami Deluxe Rezepte', 'Kubernetes Helm Tutorial').",
|
||||
},
|
||||
"max_results": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of videos to return (default 5, max 20).",
|
||||
"default": 5,
|
||||
},
|
||||
"video_duration": {
|
||||
"type": "string",
|
||||
"description": "Filter by duration: 'any' (default), 'short' (< 4m), 'medium' (4m-20m), 'long' (> 20m).",
|
||||
"enum": ["any", "short", "medium", "long"],
|
||||
"default": "any",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "youtube_get_transcript",
|
||||
"description": "Fetch video transcript / subtitles with timestamps. Free and unlimited. Essential for summarizing YouTube videos, extracting recipes, interview takeaways, or step-by-step instructions.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["video_url_or_id"],
|
||||
"properties": {
|
||||
"video_url_or_id": {
|
||||
"type": "string",
|
||||
"description": "YouTube URL (e.g. 'https://youtu.be/...' or 'https://www.youtube.com/watch?v=...') or 11-char video ID.",
|
||||
},
|
||||
"languages": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Preferred language codes in priority order (default ['de', 'en']).",
|
||||
"default": ["de", "en"],
|
||||
},
|
||||
"include_timestamps": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to include [HH:MM:SS] timestamps for each segment (default True).",
|
||||
"default": True,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "youtube_video_details",
|
||||
"description": "Fetch complete YouTube video details including full description, channel, publish date, duration, tags, and stats.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["video_url_or_id"],
|
||||
"properties": {
|
||||
"video_url_or_id": {
|
||||
"type": "string",
|
||||
"description": "YouTube video URL or 11-character video ID.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def run_mcp_server():
|
||||
"""Run JSON-RPC 2.0 stdio loop for OpenClaw."""
|
||||
while True:
|
||||
try:
|
||||
line = sys.stdin.readline()
|
||||
if not line:
|
||||
break
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
req = json.loads(line)
|
||||
req_id = req.get("id")
|
||||
method = req.get("method")
|
||||
params = req.get("params", {})
|
||||
|
||||
if method == "initialize":
|
||||
res = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
"result": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {"tools": {}},
|
||||
"serverInfo": {
|
||||
"name": "openclaw-youtube",
|
||||
"version": "1.0.0",
|
||||
},
|
||||
},
|
||||
}
|
||||
print(json.dumps(res), flush=True)
|
||||
|
||||
elif method == "notifications/initialized":
|
||||
pass
|
||||
|
||||
elif method == "tools/list":
|
||||
res = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
"result": {"tools": TOOLS_DEFINITIONS},
|
||||
}
|
||||
print(json.dumps(res), flush=True)
|
||||
|
||||
elif method == "tools/call":
|
||||
tool_name = params.get("name")
|
||||
args = params.get("arguments", {})
|
||||
content = []
|
||||
|
||||
if tool_name == "youtube_search":
|
||||
result = search_videos(
|
||||
query=args.get("query", ""),
|
||||
max_results=args.get("max_results", 5),
|
||||
video_duration=args.get("video_duration", "any"),
|
||||
)
|
||||
content.append({"type": "text", "text": json.dumps(result, indent=2, ensure_ascii=False)})
|
||||
|
||||
elif tool_name == "youtube_get_transcript":
|
||||
result = get_transcript(
|
||||
video_url_or_id=args.get("video_url_or_id", ""),
|
||||
languages=args.get("languages", ["de", "en"]),
|
||||
include_timestamps=args.get("include_timestamps", True),
|
||||
)
|
||||
content.append({"type": "text", "text": json.dumps(result, indent=2, ensure_ascii=False)})
|
||||
|
||||
elif tool_name == "youtube_video_details":
|
||||
result = get_video_details(
|
||||
video_url_or_id=args.get("video_url_or_id", "")
|
||||
)
|
||||
content.append({"type": "text", "text": json.dumps(result, indent=2, ensure_ascii=False)})
|
||||
|
||||
else:
|
||||
content.append({"type": "text", "text": f"Error: Unknown tool '{tool_name}'"})
|
||||
|
||||
res = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
"result": {"content": content},
|
||||
}
|
||||
print(json.dumps(res), flush=True)
|
||||
|
||||
else:
|
||||
res = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
"error": {"code": -32601, "message": f"Method '{method}' not found"},
|
||||
}
|
||||
print(json.dumps(res), flush=True)
|
||||
|
||||
except Exception as e:
|
||||
err_res = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": None,
|
||||
"error": {"code": -32603, "message": f"Internal error: {str(e)}"},
|
||||
}
|
||||
print(json.dumps(err_res), flush=True)
|
||||
|
||||
|
||||
def run_cli():
|
||||
"""CLI testing interface."""
|
||||
parser = argparse.ArgumentParser(description="YouTube MCP Server & CLI Tool")
|
||||
subparsers = parser.add_subparsers(dest="command", help="Subcommand")
|
||||
|
||||
# Search
|
||||
p_search = subparsers.add_parser("search", help="Search videos")
|
||||
p_search.add_argument("query", help="Search query")
|
||||
p_search.add_argument("--limit", "-n", type=int, default=5, help="Max results")
|
||||
p_search.add_argument("--duration", choices=["any", "short", "medium", "long"], default="any")
|
||||
|
||||
# Transcript
|
||||
p_trans = subparsers.add_parser("transcript", help="Get transcript")
|
||||
p_trans.add_argument("video", help="URL or video ID")
|
||||
p_trans.add_argument("--languages", "-l", default="de,en", help="Comma separated language preferences")
|
||||
p_trans.add_argument("--no-timestamps", action="store_true", help="Omit timestamps")
|
||||
|
||||
# Details
|
||||
p_details = subparsers.add_parser("details", help="Get video details")
|
||||
p_details.add_argument("video", help="URL or video ID")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command == "search":
|
||||
res = search_videos(query=args.query, max_results=args.limit, video_duration=args.duration)
|
||||
print(json.dumps(res, indent=2, ensure_ascii=False))
|
||||
elif args.command == "transcript":
|
||||
langs = [l.strip() for l in args.languages.split(",") if l.strip()]
|
||||
res = get_transcript(video_url_or_id=args.video, languages=langs, include_timestamps=not args.no_timestamps)
|
||||
print(json.dumps(res, indent=2, ensure_ascii=False))
|
||||
elif args.command == "details":
|
||||
res = get_video_details(video_url_or_id=args.video)
|
||||
print(json.dumps(res, indent=2, ensure_ascii=False))
|
||||
else:
|
||||
parser.print_help()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) == 1 or (len(sys.argv) == 2 and sys.argv[1] == "--stdio"):
|
||||
run_mcp_server()
|
||||
else:
|
||||
run_cli()
|
||||
@@ -0,0 +1,110 @@
|
||||
---
|
||||
name: zwift-workout
|
||||
description: Create, preview, edit, validate and publish Zwift .zwo workout files for cycling (bike-only). Use when the user asks to build a Zwift workout, generate a .zwo file, convert a training idea into a Zwift session, preview/adjust an existing workout, or publish a workout for import into Zwift.
|
||||
---
|
||||
|
||||
# 🚴 Zwift Workout (.zwo) Builder
|
||||
|
||||
Builds valid Zwift `.zwo` files from a small JSON spec, renders a human-readable
|
||||
preview for confirmation, and publishes the finished file. **Bike-only.**
|
||||
|
||||
## When to use
|
||||
|
||||
- "Bau mir ein Zwift-Workout …" / "Create a .zwo for 5×3min VO2max"
|
||||
- "Zeig mir das Workout" / "änder die Intervalle auf 4×5min"
|
||||
- "Lade das Workout in Zwift" (publish to hosting for import)
|
||||
|
||||
## Core workflow (always follow this order)
|
||||
|
||||
1. **Draft the spec.** Either pick a deterministic template
|
||||
(`zwo.py templates` → `endurance | sweetspot | vo2max | sprint`) or write a
|
||||
JSON spec from the user's request. Keep power as FTP fractions
|
||||
(`0.88` = 88 %, `1.15` = 115 %); durations in **seconds**.
|
||||
2. **Render the preview and ask for confirmation.** Run
|
||||
`python3 {baseDir}/scripts/zwo.py render <spec.json>` (or `build … --preview`)
|
||||
and show the table to the user. **Do not publish before the user confirms.**
|
||||
3. **Apply changes.** Edit the JSON spec and re-render; repeat until confirmed.
|
||||
4. **Build + publish on confirmation.** Run
|
||||
`python3 {baseDir}/scripts/zwo.py build <spec.json> -o <name>.zwo --publish`.
|
||||
This validates, writes the file, and uploads it via the `hosting` skill
|
||||
(`https://hosting.haumdaucher.de/docs/…`).
|
||||
5. **Deliver.** Post the hosting link (and the `.zwo` as `MEDIA:<path>` if the
|
||||
user wants the file directly). Optionally offer a QR code (hosting skill rule).
|
||||
6. **File the result into the wiki** under `syntheses/` or `concepts/` per the
|
||||
Knowledge Wiki protocol (see below).
|
||||
|
||||
## JSON spec format
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "VO2 Max 5x3",
|
||||
"author": "IT Guy",
|
||||
"description": "5 × 3 min @ 115 % FTP, 3 min Pause.",
|
||||
"sportType": "bike",
|
||||
"tags": ["vo2max", "structured"],
|
||||
"steps": [
|
||||
{"type": "Warmup", "duration": 600, "powerLow": 0.50, "powerHigh": 0.75},
|
||||
{"type": "IntervalsT", "repeat": 5, "onDuration": 180, "offDuration": 180,
|
||||
"onPower": 1.15, "offPower": 0.55},
|
||||
{"type": "Cooldown", "duration": 300, "powerLow": 0.75, "powerHigh": 0.40}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Supported step types (bike)
|
||||
|
||||
| `type` | Required fields | Optional |
|
||||
|---|---|---|
|
||||
| `Warmup` | `duration`, `powerLow`, `powerHigh` | `cadence` |
|
||||
| `Cooldown` | `duration`, `powerLow`, `powerHigh` | `cadence` |
|
||||
| `SteadyState` | `duration`, `power` | `cadence` |
|
||||
| `Ramp` | `duration`, `powerLow`, `powerHigh` | — |
|
||||
| `IntervalsT` | `repeat`, `onDuration`, `offDuration`, `onPower`, `offPower` | `cadence`, `cadenceResting` |
|
||||
| `FreeRide` | `duration` | `flatRoad` |
|
||||
| `MaxEffort` | `duration` | — |
|
||||
| `SolidState` | `duration`, `power` | — |
|
||||
| `RestDay` | — | — |
|
||||
|
||||
Power values are **fractions of FTP**; duration is in **seconds**. See
|
||||
`references/zwo-format.md` for the full tag/attribute reference.
|
||||
|
||||
## CLI
|
||||
|
||||
```bash
|
||||
python3 {baseDir}/scripts/zwo.py templates # list templates
|
||||
python3 {baseDir}/scripts/zwo.py from-template vo2max --title "VO2 Max 5x3" --publish
|
||||
python3 {baseDir}/scripts/zwo.py render my.json # preview only
|
||||
python3 {baseDir}/scripts/zwo.py build my.json -o out.zwo --preview --publish
|
||||
python3 {baseDir}/scripts/zwo.py validate existing.zwo # check a file
|
||||
python3 {baseDir}/scripts/zwo.py parse existing.zwo --json # .zwo -> spec (editable)
|
||||
```
|
||||
|
||||
## Guardrails
|
||||
|
||||
- **Power range:** `0.20 – 1.50` FTP. Outside → hard error (catches unit mistakes).
|
||||
- **Durations:** integer seconds, `> 0`; `< 5 s` and `> 4 h` produce warnings.
|
||||
- **Structure:** exactly one `<workout>`, root `<workout_file>`, `name` required,
|
||||
`sportType` must be `bike`.
|
||||
- **Round-trip check:** output XML is re-parsed with `xml.etree` before writing;
|
||||
text fields are escaped, so umlauts/quotes never corrupt the file.
|
||||
- **Never publish without explicit user confirmation** of the rendered preview.
|
||||
|
||||
## Reference & fixtures
|
||||
|
||||
- `references/zwo-format.md` — condensed tag/attribute reference (offline copy).
|
||||
- `assets/example-vo2max.zwo` — known-good fixture; use for regression tests.
|
||||
|
||||
## Knowledge Wiki filing
|
||||
|
||||
After a workout is confirmed and published, file it per the Karpathy LLM Wiki
|
||||
protocol: create/update a `concepts/` or `syntheses/` page recording the workout
|
||||
name, structure, power targets, hosting URL, and rationale, then append a
|
||||
`log.md` entry (`ingest`/`synthesis`). Cross-link the Fitness agent page.
|
||||
|
||||
## Inter-agent synergy
|
||||
|
||||
`agents/fitness.md` owns training intent (Tacx Flux 2, FTP 275 W). This skill is
|
||||
the execution layer for structured cycling sessions. For evidence-based
|
||||
protocols (e.g. SIT 30 s / 4 min), consult the Knowledge Wiki first via
|
||||
`wiki_search`. A future refactor can share one spec module with `garmin-workout`
|
||||
(one source spec → Garmin workout + `.zwo`).
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<workout_file>
|
||||
<author>IT Guy</author>
|
||||
<name>VO2 Max 5x3</name>
|
||||
<description>5 x 3 min @ 115 % FTP, 3 min Pause.</description>
|
||||
<sportType>bike</sportType>
|
||||
<tags>
|
||||
<tag name="vo2max"/>
|
||||
<tag name="zwift"/>
|
||||
</tags>
|
||||
<workout>
|
||||
<Warmup Duration="600" PowerLow="0.5" PowerHigh="0.75"/>
|
||||
<IntervalsT Repeat="5" OnDuration="180" OffDuration="180" OnPower="1.15" OffPower="0.55"/>
|
||||
<Cooldown Duration="300" PowerLow="0.75" PowerHigh="0.4"/>
|
||||
</workout>
|
||||
</workout_file>
|
||||
@@ -0,0 +1,74 @@
|
||||
# Zwift .zwo Format Reference (condensed)
|
||||
|
||||
Offline copy of the essentials from
|
||||
<https://github.com/h4l/zwift-workout-file-reference>. A `.zwo` file is XML; there
|
||||
is **no official Zwift schema**. This is the subset Zwift reliably accepts.
|
||||
|
||||
## Document structure
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<workout_file>
|
||||
<author>IT Guy</author>
|
||||
<name>VO2 Max 5x3</name>
|
||||
<description>5 × 3 min @ 115 % FTP</description>
|
||||
<sportType>bike</sportType>
|
||||
<tags><tag name="vo2max"/></tags>
|
||||
<workout>
|
||||
<Warmup Duration="600" PowerLow="0.50" PowerHigh="0.75"/>
|
||||
<IntervalsT Repeat="5" OnDuration="180" OffDuration="180"
|
||||
OnPower="1.15" OffPower="0.55"/>
|
||||
<Cooldown Duration="300" PowerLow="0.75" PowerHigh="0.40"/>
|
||||
</workout>
|
||||
</workout_file>
|
||||
```
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Power** is a fraction of FTP (`0.50` = 50 %, `1.15` = 115 %). `units="power"`
|
||||
is the implicit default.
|
||||
- **Duration** is always in **seconds**.
|
||||
- `<sportType>` values seen: `bike` (61 %), `run` (37 %), `swim`, `ride`. We use `bike`.
|
||||
- Text cues are children of a step: `<textevent timeoffset="30" message="Push!"/>`.
|
||||
|
||||
## Step elements (child of `<workout>`)
|
||||
|
||||
| Element | Attributes (relevant) | Meaning |
|
||||
|---|---|---|
|
||||
| `Warmup` | `Duration`, `PowerLow`, `PowerHigh`, `Cadence` | linear ramp Low→High |
|
||||
| `Cooldown` | `Duration`, `PowerLow`, `PowerHigh`, `Cadence` | linear ramp High→Low |
|
||||
| `SteadyState` | `Duration`, `Power`, (`PowerLow`,`PowerHigh`), `Cadence` | constant target |
|
||||
| `SolidState` | `Duration`, `Power` | constant power, no ERG variation |
|
||||
| `Ramp` | `Duration`, `PowerLow`, `PowerHigh` | explicit ramp |
|
||||
| `IntervalsT` | `Repeat`, `OnDuration`, `OffDuration`, `OnPower`, `OffPower`, `Cadence`, `CadenceResting`, `OverUnder` | N repeated on/off pairs |
|
||||
| `FreeRide` | `Duration`, `FlatRoad` | ERG off, rider's choice |
|
||||
| `MaxEffort` | `Duration` | all-out, ERG off |
|
||||
| `RestDay` | — | placeholder |
|
||||
| `textevent` | `timeoffset`, `message`, `duration` | on-screen cue |
|
||||
|
||||
## Top-level metadata elements (child of `<workout_file>`)
|
||||
|
||||
`activitySaveName`, `author`, `author_alias`, `authorIcon`, `category`,
|
||||
`categoryIndex`, `description`, `durationType`, `entid`, `ftpOverride`,
|
||||
`ftpMaleOverride`, `ftpFemaleOverride`, `name`, `nameImperial`, `nameMetric`,
|
||||
`painIndex`, `setFtpAtPercentage`, `ShowCP20`, `Skippable`, `sportType`,
|
||||
`subcategory`, `tags`, `test_details`, `visibleAfterTime`, `visibleOutsidePlan`,
|
||||
`workout`, `workoutLength`, `WorkoutPlan`.
|
||||
|
||||
**Minimal viable set we emit:** `author`, `name`, `description`, `sportType`,
|
||||
`tags`, `workout`.
|
||||
|
||||
## Importing a .zwo into Zwift
|
||||
|
||||
1. Copy the file into `~/Documents/Zwift/Workouts/<userID>/` (folder name is your
|
||||
numeric Zwift ID), **or**
|
||||
2. Use the Custom Workouts import on zwift.com, **or**
|
||||
3. Drop it into the Zwift folder sync (Dropbox/OneDrive) if configured.
|
||||
|
||||
Then restart Zwift; the workout appears under **Custom Workouts**.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Malformed XML or unescaped `&`/quotes → Zwift silently ignores the file.
|
||||
- Power outside ~0.20–1.50 FTP is almost always a unit mistake.
|
||||
- Duplicate/unknown attributes are tolerated but ignored; keep it minimal.
|
||||
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env python3
|
||||
"""zwo.py — CLI for creating, validating, previewing and publishing Zwift .zwo workouts.
|
||||
|
||||
Commands
|
||||
--------
|
||||
build <spec.json> [-o out.zwo] [--preview] [--publish]
|
||||
from-template <name> [--title T] [-o out.zwo] [--preview] [--publish]
|
||||
validate <file.zwo>
|
||||
parse <file.zwo> [--json]
|
||||
render <file.zwo|spec.json>
|
||||
templates
|
||||
|
||||
Exit codes: 0 = ok, 1 = validation/usage error.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import zwo_builder as zb # noqa: E402
|
||||
|
||||
HOSTING_SCRIPT = Path("/home/node/mop-skills/mop-skills/skills/hosting/scripts/publish_file.py")
|
||||
|
||||
|
||||
def _slug(text: str) -> str:
|
||||
s = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
|
||||
return s or "workout"
|
||||
|
||||
|
||||
def _load_spec(path: str) -> dict:
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
return json.load(fh)
|
||||
|
||||
|
||||
def _publish(zwo_path: Path, title: str) -> str | None:
|
||||
"""Publish the .zwo via the hosting skill. Returns the public URL or None."""
|
||||
if not HOSTING_SCRIPT.exists():
|
||||
print(f"⚠️ hosting publisher not found at {HOSTING_SCRIPT}", file=sys.stderr)
|
||||
return None
|
||||
cmd = [sys.executable, str(HOSTING_SCRIPT), "--file", str(zwo_path),
|
||||
"--category", "docs", "--slug", _slug(title)]
|
||||
try:
|
||||
out = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"⚠️ hosting publish failed: {exc}", file=sys.stderr)
|
||||
return None
|
||||
url = None
|
||||
for line in out.stdout.splitlines():
|
||||
if line.startswith("URL:"):
|
||||
url = line.split("URL:", 1)[1].strip()
|
||||
if not url:
|
||||
print(out.stdout, out.stderr, file=sys.stderr)
|
||||
return url
|
||||
|
||||
|
||||
def cmd_build(args: argparse.Namespace) -> int:
|
||||
spec = _load_spec(args.spec)
|
||||
warnings = zb.validate_spec(spec)
|
||||
out = Path(args.output or f"{_slug(spec['name'])}.zwo")
|
||||
zb.write_zwo(spec, str(out))
|
||||
print(f"✅ {out} ({out.stat().st_size} bytes)")
|
||||
for w in warnings:
|
||||
print(f"⚠️ {w}")
|
||||
if args.preview:
|
||||
print()
|
||||
print(zb.render_text(spec))
|
||||
if args.publish:
|
||||
url = _publish(out, spec["name"])
|
||||
if url:
|
||||
print(f"🔗 {url}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_from_template(args: argparse.Namespace) -> int:
|
||||
spec = zb.from_template(args.name, args.title)
|
||||
out = Path(args.output or f"{_slug(spec['name'])}.zwo")
|
||||
zb.write_zwo(spec, str(out))
|
||||
print(f"✅ {out} ({out.stat().st_size} bytes)")
|
||||
print()
|
||||
print(zb.render_text(spec))
|
||||
if args.publish:
|
||||
url = _publish(out, spec["name"])
|
||||
if url:
|
||||
print(f"🔗 {url}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_validate(args: argparse.Namespace) -> int:
|
||||
ok, messages = zb.validate_file(args.file)
|
||||
if ok:
|
||||
print(f"✅ {args.file} is a valid .zwo file")
|
||||
for m in messages:
|
||||
print(f"⚠️ {m}")
|
||||
return 0
|
||||
print(f"❌ {args.file} is invalid:")
|
||||
for m in messages:
|
||||
print(f" - {m}")
|
||||
return 1
|
||||
|
||||
|
||||
def cmd_parse(args: argparse.Namespace) -> int:
|
||||
spec = zb.parse_file(args.file)
|
||||
if args.json:
|
||||
print(json.dumps(spec, ensure_ascii=False, indent=2))
|
||||
else:
|
||||
print(zb.render_text(spec))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_render(args: argparse.Namespace) -> int:
|
||||
if args.file.endswith(".json"):
|
||||
spec = _load_spec(args.file)
|
||||
else:
|
||||
spec = zb.parse_file(args.file)
|
||||
print(zb.render_text(spec))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_templates(_: argparse.Namespace) -> int:
|
||||
for t in zb.templates():
|
||||
print(t)
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(prog="zwo.py", description="Zwift .zwo workout toolkit (bike-only)")
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
p = sub.add_parser("build", help="build a .zwo from a JSON spec")
|
||||
p.add_argument("spec")
|
||||
p.add_argument("-o", "--output")
|
||||
p.add_argument("--preview", action="store_true", help="print the human-readable preview")
|
||||
p.add_argument("--publish", action="store_true", help="upload via the hosting skill")
|
||||
p.set_defaults(func=cmd_build)
|
||||
|
||||
p = sub.add_parser("from-template", help="build from a built-in deterministic template")
|
||||
p.add_argument("name")
|
||||
p.add_argument("--title")
|
||||
p.add_argument("-o", "--output")
|
||||
p.add_argument("--publish", action="store_true")
|
||||
p.set_defaults(func=cmd_from_template)
|
||||
|
||||
p = sub.add_parser("validate", help="validate an existing .zwo file")
|
||||
p.add_argument("file")
|
||||
p.set_defaults(func=cmd_validate)
|
||||
|
||||
p = sub.add_parser("parse", help="parse a .zwo into a JSON spec")
|
||||
p.add_argument("file")
|
||||
p.add_argument("--json", action="store_true")
|
||||
p.set_defaults(func=cmd_parse)
|
||||
|
||||
p = sub.add_parser("render", help="render a human-readable preview")
|
||||
p.add_argument("file")
|
||||
p.set_defaults(func=cmd_render)
|
||||
|
||||
p = sub.add_parser("templates", help="list built-in templates")
|
||||
p.set_defaults(func=cmd_templates)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
return args.func(args)
|
||||
except zb.ZwoValidationError as exc:
|
||||
print(f"❌ validation error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
print(f"❌ {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,484 @@
|
||||
"""zwo_builder.py — Build, validate, parse and render Zwift ``.zwo`` workout files.
|
||||
|
||||
Bike-only implementation following the community format reference
|
||||
(https://github.com/h4l/zwift-workout-file-reference). No official Zwift XSD
|
||||
exists; this module encodes the subset that Zwift reliably accepts.
|
||||
|
||||
Public API
|
||||
----------
|
||||
build_spec(spec) -> xml.etree.ElementTree.Element (raises on error)
|
||||
spec_to_xml(spec) -> str
|
||||
validate_spec(spec) -> list[str] (non-fatal warnings)
|
||||
validate_file(path) -> (ok: bool, messages: list[str])
|
||||
parse_file(path) -> dict (spec)
|
||||
render_text(spec) -> str (human-readable preview)
|
||||
estimate(spec) -> dict (duration / NP / IF / TSS)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import Any
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Constants / guardrails
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
VALID_SPORT_TYPES = {"bike"}
|
||||
VALID_DURATION_TYPES = {"time"}
|
||||
|
||||
POWER_MIN = 0.20 # 20 % FTP — below this is almost certainly a mistake
|
||||
POWER_MAX = 1.50 # 150 % FTP — above this is very unusual for a *.zwo target
|
||||
DURATION_MIN = 5 # seconds; shorter steps are usually noise
|
||||
DURATION_MAX = 4 * 3600 # 4 h single step sanity cap
|
||||
|
||||
# spec key -> (XML attribute name, coercion)
|
||||
_STEP_SPECS: dict[str, dict[str, tuple[str, type]]] = {
|
||||
"Warmup": {
|
||||
"duration": ("Duration", int),
|
||||
"powerLow": ("PowerLow", float),
|
||||
"powerHigh": ("PowerHigh", float),
|
||||
"cadence": ("Cadence", int),
|
||||
},
|
||||
"Cooldown": {
|
||||
"duration": ("Duration", int),
|
||||
"powerLow": ("PowerLow", float),
|
||||
"powerHigh": ("PowerHigh", float),
|
||||
"cadence": ("Cadence", int),
|
||||
},
|
||||
"SteadyState": {
|
||||
"duration": ("Duration", int),
|
||||
"power": ("Power", float),
|
||||
"cadence": ("Cadence", int),
|
||||
},
|
||||
"Ramp": {
|
||||
"duration": ("Duration", int),
|
||||
"powerLow": ("PowerLow", float),
|
||||
"powerHigh": ("PowerHigh", float),
|
||||
},
|
||||
"IntervalsT": {
|
||||
"repeat": ("Repeat", int),
|
||||
"onDuration": ("OnDuration", int),
|
||||
"offDuration": ("OffDuration", int),
|
||||
"onPower": ("OnPower", float),
|
||||
"offPower": ("OffPower", float),
|
||||
"cadence": ("Cadence", int),
|
||||
"cadenceResting": ("CadenceResting", int),
|
||||
},
|
||||
"FreeRide": {
|
||||
"duration": ("Duration", int),
|
||||
"flatRoad": ("FlatRoad", int),
|
||||
},
|
||||
"MaxEffort": {
|
||||
"duration": ("Duration", int),
|
||||
},
|
||||
"SolidState": {
|
||||
"duration": ("Duration", int),
|
||||
"power": ("Power", float),
|
||||
},
|
||||
"RestDay": {},
|
||||
"TextEvent": {
|
||||
"timeoffset": ("timeoffset", int),
|
||||
"message": ("message", str),
|
||||
"duration": ("duration", int),
|
||||
},
|
||||
}
|
||||
|
||||
# Elements that carry a target power (used for the TSS estimate)
|
||||
_POWER_STEPS = {"Warmup", "Cooldown", "SteadyState", "Ramp", "IntervalsT", "SolidState"}
|
||||
|
||||
REQUIRED_STEP_ATTRS: dict[str, tuple[str, ...]] = {
|
||||
"Warmup": ("duration", "powerLow", "powerHigh"),
|
||||
"Cooldown": ("duration", "powerLow", "powerHigh"),
|
||||
"SteadyState": ("duration", "power"),
|
||||
"Ramp": ("duration", "powerLow", "powerHigh"),
|
||||
"IntervalsT": ("repeat", "onDuration", "offDuration", "onPower", "offPower"),
|
||||
"FreeRide": ("duration",),
|
||||
"MaxEffort": ("duration",),
|
||||
"SolidState": ("duration", "power"),
|
||||
"RestDay": (),
|
||||
"TextEvent": ("timeoffset", "message"),
|
||||
}
|
||||
|
||||
|
||||
class ZwoValidationError(ValueError):
|
||||
"""Raised when a spec cannot produce a valid .zwo file."""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Validation
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _check_power(value: float, where: str, errors: list[str], warnings: list[str]) -> None:
|
||||
if value < POWER_MIN:
|
||||
errors.append(f"{where}: power {value:.2f} (< {POWER_MIN:.2f} FTP) is implausible")
|
||||
elif value > POWER_MAX:
|
||||
errors.append(f"{where}: power {value:.2f} (> {POWER_MAX:.2f} FTP) is implausible")
|
||||
|
||||
|
||||
def _check_duration(value: int, where: str, errors: list[str], warnings: list[str]) -> None:
|
||||
if value <= 0:
|
||||
errors.append(f"{where}: duration must be > 0 seconds")
|
||||
elif value < DURATION_MIN:
|
||||
warnings.append(f"{where}: duration {value}s is very short")
|
||||
elif value > DURATION_MAX:
|
||||
warnings.append(f"{where}: duration {value}s exceeds the {DURATION_MAX}s single-step cap")
|
||||
|
||||
|
||||
def validate_spec(spec: dict[str, Any]) -> list[str]:
|
||||
"""Validate a workout spec. Raise on fatal errors, return warnings."""
|
||||
errors: list[str] = []
|
||||
warnings: list[str] = []
|
||||
|
||||
if not isinstance(spec, dict):
|
||||
raise ZwoValidationError("spec must be a JSON object")
|
||||
|
||||
name = spec.get("name")
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
errors.append("missing required field: name")
|
||||
|
||||
sport = spec.get("sportType", "bike")
|
||||
if sport not in VALID_SPORT_TYPES:
|
||||
errors.append(f"sportType {sport!r} not supported (bike-only): {sorted(VALID_SPORT_TYPES)}")
|
||||
|
||||
steps = spec.get("steps")
|
||||
if not isinstance(steps, list) or not steps:
|
||||
errors.append("spec must contain a non-empty 'steps' list")
|
||||
steps = []
|
||||
|
||||
for i, step in enumerate(steps, start=1):
|
||||
where = f"step {i}"
|
||||
if not isinstance(step, dict):
|
||||
errors.append(f"{where}: must be an object")
|
||||
continue
|
||||
stype = step.get("type")
|
||||
if stype not in _STEP_SPECS:
|
||||
errors.append(f"{where}: unknown step type {stype!r}")
|
||||
continue
|
||||
for key in REQUIRED_STEP_ATTRS.get(stype, ()):
|
||||
if step.get(key) is None:
|
||||
errors.append(f"{where} ({stype}): missing required field {key!r}")
|
||||
# power sanity
|
||||
for key in ("power", "powerLow", "powerHigh", "onPower", "offPower"):
|
||||
if key in step and step[key] is not None:
|
||||
try:
|
||||
_check_power(float(step[key]), f"{where} ({stype}).{key}", errors, warnings)
|
||||
except (TypeError, ValueError):
|
||||
errors.append(f"{where} ({stype}).{key}: not a number")
|
||||
# ramp direction is only a hint
|
||||
if stype in ("Warmup",) and step.get("powerHigh") is not None and step.get("powerLow") is not None:
|
||||
if float(step["powerHigh"]) < float(step["powerLow"]):
|
||||
warnings.append(f"{where} (Warmup): powerHigh < powerLow")
|
||||
for key in ("duration", "onDuration", "offDuration"):
|
||||
if key in step and step[key] is not None:
|
||||
try:
|
||||
_check_duration(int(step[key]), f"{where} ({stype}).{key}", errors, warnings)
|
||||
except (TypeError, ValueError):
|
||||
errors.append(f"{where} ({stype}).{key}: not an integer")
|
||||
if stype == "IntervalsT" and step.get("repeat") is not None:
|
||||
try:
|
||||
if int(step["repeat"]) < 1:
|
||||
errors.append(f"{where} (IntervalsT): repeat must be >= 1")
|
||||
except (TypeError, ValueError):
|
||||
errors.append(f"{where} (IntervalsT).repeat: not an integer")
|
||||
|
||||
if errors:
|
||||
raise ZwoValidationError("; ".join(errors))
|
||||
return warnings
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Build
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _coerce(value: Any, typ: type) -> str:
|
||||
if typ is int:
|
||||
return str(int(value))
|
||||
if typ is float:
|
||||
# Zwift accepts plain decimals; trim trailing zeros
|
||||
return f"{float(value):g}"
|
||||
return str(value)
|
||||
|
||||
|
||||
def build_spec(spec: dict[str, Any]) -> ET.Element:
|
||||
"""Build and re-validate the XML element tree for ``spec``."""
|
||||
validate_spec(spec)
|
||||
|
||||
root = ET.Element("workout_file")
|
||||
ET.SubElement(root, "author").text = str(spec.get("author", "IT Guy"))
|
||||
ET.SubElement(root, "name").text = str(spec["name"])
|
||||
if spec.get("description"):
|
||||
ET.SubElement(root, "description").text = str(spec["description"])
|
||||
ET.SubElement(root, "sportType").text = spec.get("sportType", "bike")
|
||||
|
||||
tags = spec.get("tags") or []
|
||||
if tags:
|
||||
tag_el = ET.SubElement(root, "tags")
|
||||
for t in tags:
|
||||
ET.SubElement(tag_el, "tag", {"name": str(t)})
|
||||
|
||||
workout = ET.SubElement(root, "workout")
|
||||
for step in spec["steps"]:
|
||||
stype = step["type"]
|
||||
attrs: dict[str, str] = {}
|
||||
for key, (attr, typ) in _STEP_SPECS[stype].items():
|
||||
if step.get(key) is not None:
|
||||
attrs[attr] = _coerce(step[key], typ)
|
||||
ET.SubElement(workout, stype, attrs)
|
||||
|
||||
return root
|
||||
|
||||
|
||||
def spec_to_xml(spec: dict[str, Any]) -> str:
|
||||
"""Serialize a spec to a pretty-printed .zwo XML string (with declaration)."""
|
||||
root = build_spec(spec)
|
||||
ET.indent(root, space=" ")
|
||||
body = ET.tostring(root, encoding="unicode")
|
||||
return '<?xml version="1.0" encoding="UTF-8"?>\n' + body + "\n"
|
||||
|
||||
|
||||
def write_zwo(spec: dict[str, Any], path: str) -> str:
|
||||
"""Write ``spec`` to ``path`` after a round-trip parse check. Returns path."""
|
||||
xml = spec_to_xml(spec)
|
||||
# round-trip: refuse to emit XML that cannot be re-parsed
|
||||
try:
|
||||
ET.fromstring(xml)
|
||||
except ET.ParseError as exc: # pragma: no cover - defensive
|
||||
raise ZwoValidationError(f"generated XML is not well-formed: {exc}") from exc
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
fh.write(xml)
|
||||
return path
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Parse / validate files
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _step_from_element(el: ET.Element) -> dict[str, Any]:
|
||||
stype = el.tag
|
||||
spec = {"type": stype}
|
||||
for key, (attr, typ) in _STEP_SPECS.get(stype, {}).items():
|
||||
if attr in el.attrib:
|
||||
try:
|
||||
spec[key] = typ(el.attrib[attr])
|
||||
except ValueError:
|
||||
spec[key] = el.attrib[attr]
|
||||
return spec
|
||||
|
||||
|
||||
def parse_file(path: str) -> dict[str, Any]:
|
||||
"""Parse an existing .zwo file into our spec representation."""
|
||||
tree = ET.parse(path)
|
||||
root = tree.getroot()
|
||||
if root.tag != "workout_file":
|
||||
raise ZwoValidationError(f"root element is <{root.tag}>, expected <workout_file>")
|
||||
|
||||
def text(tag: str) -> str | None:
|
||||
el = root.find(tag)
|
||||
return el.text.strip() if el is not None and el.text else None
|
||||
|
||||
tags: list[str] = []
|
||||
tags_el = root.find("tags")
|
||||
if tags_el is not None:
|
||||
tags = [t.get("name", "") for t in tags_el.findall("tag")]
|
||||
|
||||
workout = root.find("workout")
|
||||
steps = [_step_from_element(el) for el in (workout or [])]
|
||||
|
||||
return {
|
||||
"name": text("name") or "Unnamed",
|
||||
"author": text("author") or "",
|
||||
"description": text("description") or "",
|
||||
"sportType": text("sportType") or "bike",
|
||||
"tags": tags,
|
||||
"steps": steps,
|
||||
}
|
||||
|
||||
|
||||
def validate_file(path: str) -> tuple[bool, list[str]]:
|
||||
"""Validate an existing .zwo file. Returns (ok, messages)."""
|
||||
try:
|
||||
spec = parse_file(path)
|
||||
except (ET.ParseError, ZwoValidationError, OSError) as exc:
|
||||
return False, [f"parse error: {exc}"]
|
||||
try:
|
||||
warnings = validate_spec(spec)
|
||||
except ZwoValidationError as exc:
|
||||
return False, str(exc).split("; ")
|
||||
return True, warnings
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Render (human-readable preview)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _fmt_time(seconds: int) -> str:
|
||||
m, s = divmod(int(seconds), 60)
|
||||
return f"{m}:{s:02d}"
|
||||
|
||||
|
||||
def _pct(value: float) -> str:
|
||||
return f"{value * 100:g}%"
|
||||
|
||||
|
||||
def estimate(spec: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Estimate total duration, normalised power fraction and TSS."""
|
||||
total = 0
|
||||
weighted = 0.0 # sum(sec * f^2)
|
||||
untracked = 0
|
||||
for step in spec.get("steps", []):
|
||||
stype = step.get("type")
|
||||
if stype == "IntervalsT":
|
||||
reps = int(step.get("repeat", 1))
|
||||
on_d, off_d = int(step["onDuration"]), int(step["offDuration"])
|
||||
total += reps * (on_d + off_d)
|
||||
weighted += reps * (on_d * float(step["onPower"]) ** 2 + off_d * float(step["offPower"]) ** 2)
|
||||
elif stype in _POWER_STEPS:
|
||||
dur = int(step["duration"])
|
||||
if stype in ("Warmup", "Cooldown", "Ramp"):
|
||||
frac = (float(step["powerLow"]) + float(step["powerHigh"])) / 2
|
||||
else:
|
||||
frac = float(step["power"])
|
||||
total += dur
|
||||
weighted += dur * frac ** 2
|
||||
elif stype in ("FreeRide", "MaxEffort"):
|
||||
total += int(step["duration"])
|
||||
untracked += int(step["duration"])
|
||||
tss = (100 * weighted / 3600) if total else 0.0
|
||||
np_frac = (weighted / total) ** 0.5 if total else 0.0
|
||||
return {
|
||||
"duration_s": total,
|
||||
"duration": _fmt_time(total),
|
||||
"np_frac": round(np_frac, 3),
|
||||
"if": round(np_frac, 3),
|
||||
"tss": round(tss, 1),
|
||||
"untracked_s": untracked,
|
||||
}
|
||||
|
||||
|
||||
def _bar(frac: float, width: int = 40, scale: float = 1.5) -> str:
|
||||
filled = max(1, min(width, round(frac / scale * width)))
|
||||
return "█" * filled
|
||||
|
||||
|
||||
def render_text(spec: dict[str, Any]) -> str:
|
||||
"""Human-readable workout preview for confirmation before publishing."""
|
||||
est = estimate(spec)
|
||||
lines: list[str] = []
|
||||
lines.append(f"🏋️ {spec['name']}")
|
||||
if spec.get("description"):
|
||||
lines.append(f" {spec['description']}")
|
||||
lines.append(f" Sport: {spec.get('sportType', 'bike')} Autor: {spec.get('author', '')}")
|
||||
lines.append("")
|
||||
lines.append(f" # {'Step':<13}{'Dauer':>7} Details")
|
||||
lines.append(" " + "-" * 62)
|
||||
|
||||
for i, step in enumerate(spec["steps"], start=1):
|
||||
stype = step["type"]
|
||||
if stype == "IntervalsT":
|
||||
reps = int(step["repeat"])
|
||||
total = reps * (int(step["onDuration"]) + int(step["offDuration"]))
|
||||
detail = (f"{reps}× {_fmt_time(step['onDuration'])} @{_pct(step['onPower'])}"
|
||||
f" / {_fmt_time(step['offDuration'])} @{_pct(step['offPower'])}")
|
||||
dur = _fmt_time(total)
|
||||
elif stype in ("Warmup", "Cooldown", "Ramp"):
|
||||
detail = f"{_pct(step['powerLow'])} → {_pct(step['powerHigh'])}"
|
||||
dur = _fmt_time(int(step["duration"]))
|
||||
elif stype in ("SteadyState", "SolidState"):
|
||||
detail = f"konstant @{_pct(step['power'])}"
|
||||
dur = _fmt_time(int(step["duration"]))
|
||||
elif stype == "FreeRide":
|
||||
detail = "freie Fahrt (ERG aus)"
|
||||
dur = _fmt_time(int(step["duration"]))
|
||||
elif stype == "MaxEffort":
|
||||
detail = "All-out (ERG aus)"
|
||||
dur = _fmt_time(int(step["duration"]))
|
||||
elif stype == "RestDay":
|
||||
detail, dur = "Ruhetag", "—"
|
||||
elif stype == "TextEvent":
|
||||
detail, dur = f"Text: {step.get('message', '')}", "—"
|
||||
else:
|
||||
detail, dur = "", "—"
|
||||
lines.append(f" {i:<3}{stype:<13}{dur:>7} {detail}")
|
||||
|
||||
lines.append("")
|
||||
lines.append(" Power-Profil:")
|
||||
for step in spec["steps"]:
|
||||
stype = step["type"]
|
||||
if stype == "IntervalsT":
|
||||
lines.append(f" ON {_bar(float(step['onPower']))} {_pct(step['onPower'])}")
|
||||
lines.append(f" OFF {_bar(float(step['offPower']))} {_pct(step['offPower'])}")
|
||||
elif stype in ("Warmup", "Cooldown", "Ramp"):
|
||||
lines.append(f" {_bar((float(step['powerLow']) + float(step['powerHigh'])) / 2):<40} "
|
||||
f"{_pct(step['powerLow'])}→{_pct(step['powerHigh'])}")
|
||||
elif stype in ("SteadyState", "SolidState"):
|
||||
lines.append(f" {_bar(float(step['power'])):<40} {_pct(step['power'])}")
|
||||
|
||||
lines.append("")
|
||||
lines.append(f" Gesamt: {est['duration']} NP ≈ {est['np_frac'] * 100:g}% FTP "
|
||||
f"IF {est['if']:.2f} TSS ≈ {est['tss']}")
|
||||
if est["untracked_s"]:
|
||||
lines.append(f" ⚠️ {_fmt_time(est['untracked_s'])} FreeRide/MaxEffort sind nicht in der TSS-Schätzung enthalten.")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Deterministic templates ("from-text")
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
_TEMPLATES: dict[str, dict[str, Any]] = {
|
||||
"endurance": {
|
||||
"description": "Ruhige Grundlagenfahrt (Z2).",
|
||||
"steps": [
|
||||
{"type": "Warmup", "duration": 600, "powerLow": 0.50, "powerHigh": 0.65},
|
||||
{"type": "SteadyState", "duration": 3000, "power": 0.68},
|
||||
{"type": "Cooldown", "duration": 300, "powerLow": 0.65, "powerHigh": 0.45},
|
||||
],
|
||||
},
|
||||
"sweetspot": {
|
||||
"description": "3 × 12 min Sweet Spot (88 % FTP).",
|
||||
"steps": [
|
||||
{"type": "Warmup", "duration": 600, "powerLow": 0.50, "powerHigh": 0.75},
|
||||
{"type": "IntervalsT", "repeat": 3, "onDuration": 720, "offDuration": 240,
|
||||
"onPower": 0.88, "offPower": 0.55},
|
||||
{"type": "Cooldown", "duration": 300, "powerLow": 0.65, "powerHigh": 0.45},
|
||||
],
|
||||
},
|
||||
"vo2max": {
|
||||
"description": "5 × 3 min VO2max (115 % FTP).",
|
||||
"steps": [
|
||||
{"type": "Warmup", "duration": 600, "powerLow": 0.50, "powerHigh": 0.75},
|
||||
{"type": "IntervalsT", "repeat": 5, "onDuration": 180, "offDuration": 180,
|
||||
"onPower": 1.15, "offPower": 0.55},
|
||||
{"type": "Cooldown", "duration": 300, "powerLow": 0.65, "powerHigh": 0.45},
|
||||
],
|
||||
},
|
||||
"sprint": {
|
||||
"description": "SIT: 6 × 30 s all-out, 4 min Pause (wiki-verankertes Protokoll).",
|
||||
"steps": [
|
||||
{"type": "Warmup", "duration": 900, "powerLow": 0.50, "powerHigh": 0.80},
|
||||
{"type": "IntervalsT", "repeat": 6, "onDuration": 30, "offDuration": 240,
|
||||
"onPower": 1.50, "offPower": 0.40},
|
||||
{"type": "Cooldown", "duration": 420, "powerLow": 0.60, "powerHigh": 0.40},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def from_template(name: str, title: str | None = None) -> dict[str, Any]:
|
||||
"""Return a deterministic workout spec from the built-in template set."""
|
||||
key = name.strip().lower()
|
||||
if key not in _TEMPLATES:
|
||||
raise ZwoValidationError(f"unknown template {name!r}; available: {sorted(_TEMPLATES)}")
|
||||
spec = {k: (v.copy() if isinstance(v, list) else v) for k, v in _TEMPLATES[key].items()}
|
||||
spec.update({"name": title or key.title(), "author": "IT Guy", "sportType": "bike",
|
||||
"tags": ["zwift", key]})
|
||||
return spec
|
||||
|
||||
|
||||
def templates() -> list[str]:
|
||||
return sorted(_TEMPLATES)
|
||||
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Focused tests for the zwo builder. Run: python3 tests/test_zwo.py (or pytest)."""
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
|
||||
SKILL_DIR = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(SKILL_DIR / "scripts"))
|
||||
|
||||
import zwo_builder as zb # noqa: E402
|
||||
|
||||
SPEC = {
|
||||
"name": "VO2 Max 5x3",
|
||||
"author": "IT Guy",
|
||||
"description": "5 × 3 min @ 115 % FTP",
|
||||
"sportType": "bike",
|
||||
"tags": ["vo2max"],
|
||||
"steps": [
|
||||
{"type": "Warmup", "duration": 600, "powerLow": 0.50, "powerHigh": 0.75},
|
||||
{"type": "IntervalsT", "repeat": 5, "onDuration": 180, "offDuration": 180,
|
||||
"onPower": 1.15, "offPower": 0.55},
|
||||
{"type": "Cooldown", "duration": 300, "powerLow": 0.75, "powerHigh": 0.40},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_build_is_wellformed_and_roundtrips():
|
||||
xml = zb.spec_to_xml(SPEC)
|
||||
root = ET.fromstring(xml)
|
||||
assert root.tag == "workout_file"
|
||||
assert root.findtext("sportType") == "bike"
|
||||
workout = root.find("workout")
|
||||
assert [c.tag for c in workout] == ["Warmup", "IntervalsT", "Cooldown"]
|
||||
|
||||
parsed = zb.parse_file(_write_tmp(xml))
|
||||
assert parsed["name"] == "VO2 Max 5x3"
|
||||
assert parsed["steps"][1]["repeat"] == 5
|
||||
assert parsed["steps"][1]["onPower"] == 1.15
|
||||
|
||||
|
||||
def test_power_out_of_range_raises():
|
||||
bad = dict(SPEC, steps=[{"type": "SteadyState", "duration": 600, "power": 3.0}])
|
||||
try:
|
||||
zb.build_spec(bad)
|
||||
except zb.ZwoValidationError as exc:
|
||||
assert "implausible" in str(exc)
|
||||
else:
|
||||
raise AssertionError("expected ZwoValidationError")
|
||||
|
||||
|
||||
def test_missing_required_field_raises():
|
||||
bad = dict(SPEC, steps=[{"type": "IntervalsT", "repeat": 5, "onDuration": 180,
|
||||
"offDuration": 180, "onPower": 1.0}]) # offPower missing
|
||||
try:
|
||||
zb.build_spec(bad)
|
||||
except zb.ZwoValidationError as exc:
|
||||
assert "offPower" in str(exc)
|
||||
else:
|
||||
raise AssertionError("expected ZwoValidationError")
|
||||
|
||||
|
||||
def test_sport_type_bike_only():
|
||||
bad = dict(SPEC, sportType="run")
|
||||
try:
|
||||
zb.build_spec(bad)
|
||||
except zb.ZwoValidationError as exc:
|
||||
assert "sportType" in str(exc)
|
||||
else:
|
||||
raise AssertionError("expected ZwoValidationError")
|
||||
|
||||
|
||||
def test_escaping_special_chars():
|
||||
spec = dict(SPEC, name='Push & Pull "hart"', steps=[
|
||||
{"type": "SteadyState", "duration": 300, "power": 0.9}])
|
||||
xml = zb.spec_to_xml(spec)
|
||||
parsed = ET.fromstring(xml)
|
||||
assert parsed.findtext("name") == 'Push & Pull "hart"'
|
||||
|
||||
|
||||
def test_estimate_tss_and_duration():
|
||||
est = zb.estimate(SPEC)
|
||||
assert est["duration_s"] == 600 + 5 * 360 + 300
|
||||
assert 40 < est["tss"] < 120
|
||||
assert abs(est["np_frac"] - est["if"]) < 1e-9
|
||||
|
||||
|
||||
def test_templates_available():
|
||||
assert set(zb.templates()) >= {"endurance", "sweetspot", "vo2max", "sprint"}
|
||||
spec = zb.from_template("vo2max", "Test")
|
||||
assert spec["name"] == "Test"
|
||||
zb.validate_spec(spec)
|
||||
|
||||
|
||||
def test_asset_fixture_valid():
|
||||
asset = SKILL_DIR / "assets" / "example-vo2max.zwo"
|
||||
ok, messages = zb.validate_file(str(asset))
|
||||
assert ok, messages
|
||||
|
||||
|
||||
def test_render_contains_key_lines():
|
||||
text = zb.render_text(SPEC)
|
||||
assert "VO2 Max 5x3" in text
|
||||
assert "IntervalsT" in text
|
||||
assert "TSS" in text
|
||||
|
||||
|
||||
def _write_tmp(xml: str) -> str:
|
||||
fd = tempfile.NamedTemporaryFile("w", suffix=".zwo", delete=False, encoding="utf-8")
|
||||
fd.write(xml)
|
||||
fd.close()
|
||||
return fd.name
|
||||
|
||||
|
||||
def _run_all():
|
||||
tests = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
|
||||
failed = 0
|
||||
for t in tests:
|
||||
try:
|
||||
t()
|
||||
print(f" ✅ {t.__name__}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
failed += 1
|
||||
print(f" ❌ {t.__name__}: {exc}")
|
||||
print(f"\n{len(tests) - failed}/{len(tests)} tests passed")
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(_run_all())
|
||||