Compare commits

64 Commits
Author SHA1 Message Date
Moritz Graf fc6a32697f fix(garmin): format sync payload with canonical list keys and verify synced counts 2026-10-18 20:31:45 +02:00
Moritz Graf 9f2c2c33d3 fix(datastore): increase backend probe timeout to 3s and rebase before push 2026-10-17 07:39:40 +02:00
Moritz Graf 6b3fbfdbb5 feat(datastore): migrate to single-writer DuckDB backend HTTP proxy and remove legacy HTML renderers 2026-10-16 23:33:02 +02:00
Moritz Graf 8bdd06e598 feat(skills): promote untracked pod skills into mop-skills 2026-10-16 21:10:26 +02:00
Moritz Graf 4f72e4d71b chore(datastore): update Mila live dashboard link to family.haumdaucher.de/tracking/mila/ 2026-10-14 21:39:49 +02:00
Moritz Graf 7b5d039323 feat: consolidate datastore into sleep, nutrition, mila databases 2026-10-13 09:28:07 +02:00
Moritz Graf fe4edcc217 fix(pwa): serve Mila app icons from public /icons/ path for iOS SpringBoard and PWA manifests 2026-10-12 08:48:55 +02:00
Moritz Graf 3e9e12bbc5 fix(pwa): version icon filenames to -v3 and bust iOS Safari WebClip cache 2026-10-12 08:36:02 +02:00
Moritz Graf 7b9b3ff0eb feat(growth): add WHO percentile growth curves, Chart.js collapsible section, and tests 2026-10-12 08:32:38 +02:00
Moritz Graf 191c8e9143 feat(pwa): optimize Mila app icons with safe-zone padding to prevent Android/iOS zoom crop 2026-10-12 08:25:27 +02:00
Moritz Graf f5b5deaae7 fix(pwa): enable crossorigin=use-credentials for manifest, add id and header install trigger 2026-10-12 08:16:03 +02:00
Moritz Graf 545752fa60 feat(mila): update app icon, favicon, and header artwork to pop-art baby 2026-10-12 07:36:23 +02:00
Moritz Graf e9d2c59eed feat(pwa): add auto-update on reopen, live tickers, and manual refresh to dashboards 2026-10-11 22:25:37 +02:00
Moritz Graf 63497765b0 fix(datastore): include morning-ending night sleep in dashboard, pass data-dir, and auto git-sync on writes 2026-10-11 22:14:17 +02:00
Moritz Graf 88c42aa1e5 fix(datastore): support retroactive date logging and fix Garmin balance display logic 2026-10-11 21:59:44 +02:00
Moritz Graf bcb04711c0 feat(security): enforce ADR 0005 hosting boundary segregation strictly to family/ 2026-10-11 21:24:06 +02:00
Moritz Graf 694d0c5072 feat(pwa): make Mila app installable with PWA manifest, service worker and high-res icons 2026-10-11 20:57:50 +02:00
Moritz Graf 1ccab5e522 feat(datastore): expose delete command in CLI 2026-10-11 20:48:07 +02:00
Moritz Graf fe21d01a3f fix(datastore): fix item_count column name in update_moritz_website.py 2026-10-11 20:39:35 +02:00
Moritz Graf 1babaab1e2 feat(datastore): add Moritz Health light-mode PWA dashboard generator and hook 2026-10-11 20:37:18 +02:00
Moritz Graf 3abfa18182 feat(datastore): support --since in stop command, trigger website update from render_views, update docs 2026-10-11 15:07:35 +02:00
Moritz Graf 967909c3a7 feat(datastore): add diaper and feeding tracking, automated update_website posthook, and morning_briefing 2026-10-11 12:16:24 +02:00
Moritz Graf 80e38ab64a fix(datastore): add PEP 723 dependencies to garmin_sync.py for automated uv execution 2026-10-10 15:48:55 +02:00
Moritz Graf 786ce47bd4 fix(datastore): alert on open sessions without silent deckeln, support stop by ID 2026-10-09 19:14:01 +02:00
Moritz Graf 88d49f85e5 fix(datastore): auto-close stale sleep sessions and validate event kind in CLI 2026-10-09 18:46:39 +02:00
Moritz Graf c9cbc56441 docs(google-calendar): document OpenClaw calendar__ prefix, CLI fallback, and no-fake guardrails 2026-10-09 12:29:43 +02:00
Moritz Graf 6a93539a97 feat(datastore): support --kind flag and add 15m duplicate suppression 2026-10-09 10:28:11 +02:00
Moritz Graf 77a0e56d96 feat(datastore): add relative --since duration flag and overnight sleep test 2026-10-09 09:25:04 +02:00
Moritz Graf 0489ef7d62 feat(skills): add datastore skill (DuckDB engine, JSONL partitioning, Garmin mirror, and view renderers) 2026-10-06 18:17:52 +02:00
Moritz Graf 2bde04617f feat(skills): add create-website skill for zero-build responsive SPAs and showcases 2026-09-28 07:37:36 +02:00
moritz 7336f7bfff Merge pull request 'feat(skills): add zwift-workout skill (.zwo builder, validator, preview, publishing)' (#1) from feat/zwift-workout-skill into main
Reviewed-on: #1
2026-09-20 13:43:44 +00:00
IT Guy d044eba97a docs(skills): register zwift-workout skill in AGENTS index 2026-09-20 12:31:15 +00:00
IT Guy 109162889c feat(skills): add zwift-workout skill (.zwo builder, validator, preview, publishing)
Bike-only Zwift .zwo generator with human-readable preview + confirmation loop.

- scripts/zwo_builder.py: spec<->.zwo build/validate/parse/render/TSS estimate
- scripts/zwo.py: CLI (build, from-template, validate, parse, render, templates)
- references/zwo-format.md: condensed offline format reference
- assets/example-vo2max.zwo + tests/test_zwo.py (9 tests)
- Publishes via hosting skill; files result into Knowledge Wiki

Format ground truth: h4l/zwift-workout-file-reference
2026-09-20 07:47:05 +00:00
Moritz Graf 42f37a89f8 feat(skills): add garmin-workout skill with sportType validation and schema reference 2026-09-20 08:04:55 +02:00
Moritz Graf be96b1234f feat(skills): add chart-to-chat skill for dbt-charts and quickchart 2026-09-19 21:04:42 +02:00
Moritz Graf 39441067bc feat(skills): add openclaw-audit system diagnostic and commit correlation skill 2026-09-19 16:48:19 +02:00
Moritz Graf 6e98fd83b2 feat(skills): add YouTube and Google Maps Platform MCP skills 2026-09-18 11:30:35 +02:00
Moritz Graf ea079c9852 feat(google-calendar): add RRULE recurrence, transparency, reminders, move_event, and free-slots finder 2026-09-18 11:21:35 +02:00
Moritz Graf a9d386c2e9 feat(skills): add improved grill-me skill for plan stress-testing 2026-09-17 13:34:36 +02:00
Moritz Graf 2dfce6967c feat(skills): add garmin-auth skill with 2-turn Telegram MFA flow 2026-09-17 09:29:56 +02:00
Moritz Graf 0162f9dcd3 fix(brother-printer): support --thread-id for forum topics and prevent double ask_user prompts 2026-09-17 09:05:01 +02:00
Moritz Graf b104b2f6a2 feat(skills): add brother-printer skill with IPP duplex, color, N-up and Telegram live status 2026-09-17 08:08:31 +02:00
Moritz Graf bb31002e19 feat: add hosting and qr-code skills 2026-09-16 08:59:54 +02:00
Moritz Graf 5376ddbc20 feat(skills): add regensburg_ris_mcp.py and universal_product_extractor.py 2026-09-16 08:22:40 +02:00
Moritz Graf 7e82424ca2 feat(calendar): implement visual color-coding, conflict-guard, and prep checklists 2026-09-15 22:28:26 +02:00
Moritz Graf dc874415b4 fix(bring): properly extract items from BringItemsResponse 2026-09-15 22:14:18 +02:00
Moritz Graf 309f4e4856 fix(calendar): improve friendly error message when regensburg calendar is not configured 2026-09-15 22:03:54 +02:00
Moritz Graf 9a857ac23b feat(shopping): add Bring! shopping lists MCP server and protocol 2026-09-15 21:47:26 +02:00
Moritz Graf 505249748e feat(skills): add google-calendar skill and MCP server for family and regensburg calendars 2026-09-15 21:38:57 +02:00
Moritz Graf 71e8909251 feat(skills): add markitdown document converter and regensburg-stadtrat civic intelligence skills 2026-09-12 09:15:16 +02:00
Moritz Graf e2944efb3e fix(spotify): pin mcp<2 and set writable HOME for credentials 2026-09-11 21:37:50 +02:00
Moritz Graf 24e788ba6b feat(spotify): add run_spotify_mcp launcher 2026-09-11 21:15:27 +02:00
Moritz Graf 9c32d8e44c feat(spotify): add spotify skill and oauth generator for openclaw 2026-09-11 21:03:54 +02:00
Moritz Graf e8c0046c51 feat(skills): add shopping skill with supermarket deals, geizhals, and ebay MCP servers 2026-09-10 13:43:29 +02:00
Moritz Graf 3d78c94a58 fix(jelly-media): concatenate poster url string for Python < 3.12 compatibility 2026-09-10 10:33:26 +02:00
Moritz Graf 03a5d38672 fix(jelly-media): fix f-string syntax compatibility for Python < 3.12 2026-09-10 10:26:19 +02:00
Moritz Graf 7aca5dfe70 feat(skills): add jelly-media skill and MCP server for Jellyfin & Jellyseerr 2026-09-10 10:09:05 +02:00
Moritz Graf fbd934965d fix(text-to-audio): add parallel chunk synthesis, automatic MiniMax fallback, voice normalization, and streaming progress 2026-09-08 20:13:29 +02:00
Moritz Graf 9ac1ee5f35 feat(text-to-audio): switch to Gemini 3.1 Flash TTS, default to solo monologue, preselect DE/EN voices 2026-09-08 19:30:09 +02:00
Moritz Graf a610ec32d0 feat(skills): add modular text-to-audio podcast producer skill 2026-09-08 17:35:30 +02:00
Moritz Graf 2a6709d5bc refactor(memobird): switch to uv with inline script dependencies 2026-05-19 22:00:13 +02:00
Moritz Graf 64137350b4 refactor(memobird): improve portability with dependency checks and error handling 2026-05-19 20:19:42 +02:00
Moritz Graf 9af18e5879 feat: add memobird printing skill and integrated into AGENTS.md 2026-05-17 12:34:18 +02:00
Moritz Graf d445d22fda Adding recent changes 2026-04-01 17:27:09 +02:00
90 changed files with 17844 additions and 28 deletions
+83
View File
@@ -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.
BIN
View File
Binary file not shown.
+115
View File
@@ -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 |
+479
View File
@@ -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()
+100
View File
@@ -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
```
+456
View File
@@ -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()
+133
View File
@@ -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()
+150
View File
@@ -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`
+110
View File
@@ -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.
+161
View File
@@ -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
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 593 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 593 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 219 KiB

+48
View File
@@ -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"
}
]
}
+35
View File
@@ -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()
+1800
View File
File diff suppressed because it is too large Load Diff
+546
View File
@@ -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()
+534
View File
@@ -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()
+170
View File
@@ -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]"
+899
View File
@@ -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)
+70
View File
@@ -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`.
+533
View File
@@ -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()
+120
View File
@@ -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"
}
```
+131
View File
@@ -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`).
File diff suppressed because it is too large Load Diff
+36
View File
@@ -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.
+598
View File
@@ -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()
+97
View File
@@ -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.
+15
View File
@@ -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.
+72
View File
@@ -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.
+158
View File
@@ -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()
+65
View File
@@ -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?"*
+786
View File
@@ -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()
+45
View File
@@ -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.
+138
View File
@@ -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()
+32
View File
@@ -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&lt;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}" ]`).
+50
View File
@@ -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.
+136
View File
@@ -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()
+13
View File
@@ -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.
+25 -8
View File
@@ -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`.
+16 -16
View File
@@ -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)
```
+2 -2
View File
@@ -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.
+69
View File
@@ -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).
+622
View File
@@ -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()
+24
View File
@@ -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.
+40
View File
@@ -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
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 616 KiB

+122
View File
@@ -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()
+68
View File
@@ -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`.
+506
View File
@@ -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()
+233
View File
@@ -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()
+132
View File
@@ -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.
+553
View File
@@ -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()
+477
View File
@@ -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()
+399
View File
@@ -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()
+385
View File
@@ -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()
+160
View File
@@ -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()
+23
View File
@@ -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.
+79
View File
@@ -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.
+61
View File
@@ -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()
+224
View File
@@ -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()
+90
View File
@@ -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
```
+595
View File
@@ -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()
+37
View File
@@ -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.
+568
View File
@@ -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()
+110
View File
@@ -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.
+178
View File
@@ -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())
+484
View File
@@ -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)
+131
View File
@@ -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())