Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d044eba97a | ||
|
|
109162889c | ||
|
|
42f37a89f8 | ||
|
|
be96b1234f | ||
|
|
39441067bc | ||
|
|
6e98fd83b2 | ||
|
|
ea079c9852 | ||
|
|
a9d386c2e9 | ||
|
|
2dfce6967c | ||
|
|
0162f9dcd3 | ||
|
|
b104b2f6a2 | ||
|
|
bb31002e19 | ||
|
|
5376ddbc20 | ||
|
|
7e82424ca2 | ||
|
|
dc874415b4 | ||
|
|
309f4e4856 | ||
|
|
9a857ac23b | ||
|
|
505249748e | ||
|
|
71e8909251 | ||
|
|
e2944efb3e | ||
|
|
24e788ba6b | ||
|
|
9c32d8e44c | ||
|
|
e8c0046c51 | ||
|
|
3d78c94a58 | ||
|
|
03a5d38672 | ||
|
|
7aca5dfe70 | ||
|
|
fbd934965d | ||
|
|
9ac1ee5f35 | ||
|
|
a610ec32d0 | ||
|
|
2a6709d5bc | ||
|
|
64137350b4 | ||
|
|
9af18e5879 | ||
|
|
d445d22fda |
@@ -20,11 +20,81 @@ 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`.
|
||||
|
||||
### [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).
|
||||
|
||||
### [skill-creator](skills/skill-creator/SKILL.md)
|
||||
Guidelines and scripts to help developers build high-quality, token-efficient Claude skills.
|
||||
|
||||
|
||||
Binary file not shown.
@@ -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
@@ -0,0 +1,479 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# dependencies = [
|
||||
# "pyipp>=0.16.0",
|
||||
# "requests>=2.31.0",
|
||||
# "pypdf>=4.0.0",
|
||||
# "reportlab>=4.0.0",
|
||||
# ]
|
||||
# ///
|
||||
"""
|
||||
Brother HL-L8240CDW Network Printer Controller & IPP Driver for OpenClaw.
|
||||
Supports direct PDF printing, hardware N-Up, duplex, color modes,
|
||||
and live Telegram status updates via Bot API.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import json
|
||||
import argparse
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
import requests
|
||||
from pypdf import PdfReader
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
||||
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer
|
||||
from reportlab.lib.units import cm
|
||||
|
||||
import pyipp
|
||||
from pyipp import IPP
|
||||
from pyipp.enums import IppOperation
|
||||
from pyipp.tags import IppTag
|
||||
import pyipp.serializer
|
||||
|
||||
# Register extra IPP attribute tags for Brother HL-L8240CDW
|
||||
pyipp.serializer.ATTRIBUTE_TAG_MAP["print-color-mode"] = IppTag.KEYWORD
|
||||
pyipp.serializer.ATTRIBUTE_TAG_MAP["page-ranges"] = IppTag.RANGE
|
||||
|
||||
DEFAULT_PRINTER_HOST = os.environ.get("BROTHER_PRINTER_HOST", "192.168.10.91")
|
||||
DEFAULT_PRINTER_PORT = int(os.environ.get("BROTHER_PRINTER_PORT", "631"))
|
||||
DEFAULT_PRINTER_URI = f"ipp://{DEFAULT_PRINTER_HOST}:{DEFAULT_PRINTER_PORT}/ipp/print"
|
||||
TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Telegram Helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TelegramNotifier:
|
||||
def __init__(self, token: Optional[str] = None, chat_id: Optional[str] = None, thread_id: Optional[int] = None, message_id: Optional[int] = None):
|
||||
self.token = token or TELEGRAM_BOT_TOKEN
|
||||
self.chat_id = chat_id
|
||||
self.thread_id = int(thread_id) if thread_id else None
|
||||
self.message_id = int(message_id) if message_id else None
|
||||
|
||||
def can_notify(self) -> bool:
|
||||
return bool(self.token and self.chat_id)
|
||||
|
||||
def send_initial(self, text: str) -> Optional[int]:
|
||||
if not self.can_notify():
|
||||
return None
|
||||
url = f"https://api.telegram.org/bot{self.token}/sendMessage"
|
||||
payload = {
|
||||
"chat_id": self.chat_id,
|
||||
"text": text,
|
||||
"parse_mode": "Markdown",
|
||||
}
|
||||
if self.thread_id:
|
||||
payload["message_thread_id"] = self.thread_id
|
||||
try:
|
||||
resp = requests.post(url, json=payload, timeout=8)
|
||||
if resp.ok:
|
||||
data = resp.json()
|
||||
self.message_id = data.get("result", {}).get("message_id")
|
||||
return self.message_id
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"Telegram send error: {e}\n")
|
||||
return None
|
||||
|
||||
def update(self, text: str):
|
||||
if not self.can_notify() or not self.message_id:
|
||||
return
|
||||
url = f"https://api.telegram.org/bot{self.token}/editMessageText"
|
||||
payload = {
|
||||
"chat_id": self.chat_id,
|
||||
"message_id": self.message_id,
|
||||
"text": text,
|
||||
"parse_mode": "Markdown",
|
||||
}
|
||||
try:
|
||||
requests.post(url, json=payload, timeout=8)
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"Telegram edit error: {e}\n")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Text to PDF Converter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def convert_text_to_pdf(text: str, output_path: str, title: str = "Dokument") -> str:
|
||||
"""Convert plain text or simple markdown into a clean A4 PDF."""
|
||||
doc = SimpleDocTemplate(
|
||||
output_path,
|
||||
pagesize=A4,
|
||||
rightMargin=2 * cm,
|
||||
leftMargin=2 * cm,
|
||||
topMargin=2 * cm,
|
||||
bottomMargin=2 * cm,
|
||||
)
|
||||
styles = getSampleStyleSheet()
|
||||
normal_style = styles["Normal"]
|
||||
normal_style.fontSize = 11
|
||||
normal_style.leading = 14
|
||||
|
||||
heading_style = styles["Heading1"]
|
||||
heading_style.fontSize = 16
|
||||
heading_style.leading = 20
|
||||
|
||||
story = [
|
||||
Paragraph(title, heading_style),
|
||||
Spacer(1, 0.5 * cm),
|
||||
]
|
||||
|
||||
for paragraph in text.split("\n\n"):
|
||||
clean_p = paragraph.replace("\n", "<br/>")
|
||||
story.append(Paragraph(clean_p, normal_style))
|
||||
story.append(Spacer(1, 0.3 * cm))
|
||||
|
||||
doc.build(story)
|
||||
return output_path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Printer Service
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class BrotherPrinter:
|
||||
def __init__(self, host: str = DEFAULT_PRINTER_HOST, port: int = DEFAULT_PRINTER_PORT):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.uri = f"ipp://{self.host}:{self.port}/ipp/print"
|
||||
|
||||
async def get_status(self) -> Dict[str, Any]:
|
||||
"""Fetch status, markers, and printer details via IPP."""
|
||||
async with IPP(self.host, port=self.port) as ipp:
|
||||
printer = await ipp.printer()
|
||||
markers = {}
|
||||
for marker in printer.markers:
|
||||
markers[marker.name] = {
|
||||
"level": marker.level,
|
||||
"type": marker.marker_type,
|
||||
"color": marker.color,
|
||||
}
|
||||
return {
|
||||
"name": printer.info.name,
|
||||
"model": printer.info.model,
|
||||
"state": printer.state.printer_state,
|
||||
"reasons": printer.state.reasons,
|
||||
"markers": markers,
|
||||
"uri": self.uri,
|
||||
}
|
||||
|
||||
async def validate_job(
|
||||
self,
|
||||
color_mode: str = "monochrome",
|
||||
nup: int = 1,
|
||||
duplex: str = "two-sided-long-edge",
|
||||
copies: int = 1,
|
||||
) -> bool:
|
||||
"""Dry-run validation of job parameters without printing."""
|
||||
async with IPP(self.host, port=self.port) as ipp:
|
||||
msg = {
|
||||
"version": (2, 0),
|
||||
"operation": IppOperation.VALIDATE_JOB,
|
||||
"operation-attributes-tag": {
|
||||
"printer-uri": self.uri,
|
||||
"requesting-user-name": "openclaw",
|
||||
"document-format": "application/pdf",
|
||||
},
|
||||
"job-attributes-tag": {
|
||||
"sides": duplex,
|
||||
"number-up": nup,
|
||||
"print-color-mode": color_mode,
|
||||
"copies": copies,
|
||||
},
|
||||
}
|
||||
res = await ipp.execute(IppOperation.VALIDATE_JOB, msg)
|
||||
return res.get("status-code") == 0
|
||||
|
||||
async def send_print_job(
|
||||
self,
|
||||
pdf_bytes: bytes,
|
||||
job_name: str = "OpenClaw Print",
|
||||
color_mode: str = "monochrome",
|
||||
nup: int = 1,
|
||||
duplex: str = "two-sided-long-edge",
|
||||
copies: int = 1,
|
||||
) -> Dict[str, Any]:
|
||||
"""Send a Print-Job request directly with raw PDF data."""
|
||||
async with IPP(self.host, port=self.port) as ipp:
|
||||
msg = {
|
||||
"version": (2, 0),
|
||||
"operation": IppOperation.PRINT_JOB,
|
||||
"operation-attributes-tag": {
|
||||
"printer-uri": self.uri,
|
||||
"requesting-user-name": "openclaw",
|
||||
"job-name": job_name,
|
||||
"document-format": "application/pdf",
|
||||
},
|
||||
"job-attributes-tag": {
|
||||
"sides": duplex,
|
||||
"number-up": nup,
|
||||
"print-color-mode": color_mode,
|
||||
"copies": copies,
|
||||
},
|
||||
"data": pdf_bytes,
|
||||
}
|
||||
res = await ipp.execute(IppOperation.PRINT_JOB, msg)
|
||||
return res
|
||||
|
||||
async def get_job_attributes(self, job_id: int) -> Dict[str, Any]:
|
||||
"""Poll job status using GET_JOB_ATTRIBUTES."""
|
||||
async with IPP(self.host, port=self.port) as ipp:
|
||||
msg = {
|
||||
"version": (2, 0),
|
||||
"operation": IppOperation.GET_JOB_ATTRIBUTES,
|
||||
"operation-attributes-tag": {
|
||||
"printer-uri": self.uri,
|
||||
"job-id": job_id,
|
||||
"requesting-user-name": "openclaw",
|
||||
},
|
||||
}
|
||||
try:
|
||||
res = await ipp.execute(IppOperation.GET_JOB_ATTRIBUTES, msg)
|
||||
jobs = res.get("jobs", [])
|
||||
if jobs:
|
||||
return jobs[0]
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"Job query warning: {e}\n")
|
||||
return {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI Command Handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def handle_status(args):
|
||||
printer = BrotherPrinter(host=args.host, port=args.port)
|
||||
data = asyncio.run(printer.get_status())
|
||||
if args.json:
|
||||
print(json.dumps(data, indent=2))
|
||||
return
|
||||
|
||||
print("🖨️ Brother HL-L8240CDW Status")
|
||||
print("=" * 35)
|
||||
print(f"Modell: {data['model']}")
|
||||
print(f"Status: {data['state']}")
|
||||
print(f"URI: {data['uri']}")
|
||||
print("\n📊 Tonerstände:")
|
||||
for name, m in data["markers"].items():
|
||||
color_label = {"BK": "Schwarz", "C": "Cyan", "M": "Magenta", "Y": "Gelb"}.get(name, name)
|
||||
bar = "█" * (m["level"] // 10) + "░" * (10 - (m["level"] // 10))
|
||||
print(f" [{name:2}] {color_label:8}: {bar} {m['level']}%")
|
||||
|
||||
|
||||
def handle_validate(args):
|
||||
printer = BrotherPrinter(host=args.host, port=args.port)
|
||||
ok = asyncio.run(
|
||||
printer.validate_job(
|
||||
color_mode=args.color,
|
||||
nup=args.nup,
|
||||
duplex=args.duplex,
|
||||
copies=args.copies,
|
||||
)
|
||||
)
|
||||
if ok:
|
||||
print("✅ Konfiguration ist gültig und wird vom Drucker unterstützt.")
|
||||
else:
|
||||
print("❌ Konfiguration wurde vom Drucker abgewiesen.")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def handle_print(args):
|
||||
notifier = TelegramNotifier(chat_id=args.chat_id, thread_id=args.thread_id, message_id=args.message_id)
|
||||
|
||||
# 1. Resolve source document
|
||||
doc_title = args.title or "Dokument"
|
||||
pdf_path = None
|
||||
temp_files = []
|
||||
|
||||
if args.file:
|
||||
p = Path(args.file)
|
||||
if not p.exists():
|
||||
err = f"❌ Datei nicht gefunden: {args.file}"
|
||||
print(err)
|
||||
notifier.update(err)
|
||||
sys.exit(1)
|
||||
pdf_path = str(p)
|
||||
doc_title = args.title or p.name
|
||||
elif args.url:
|
||||
notifier.update(f"⏳ [1/4] Lade Dokument herunter: `{args.url[:40]}...`")
|
||||
resp = requests.get(args.url, timeout=30)
|
||||
tmp = f"/tmp/print_{int(time.time())}.pdf"
|
||||
with open(tmp, "wb") as f:
|
||||
f.write(resp.content)
|
||||
temp_files.append(tmp)
|
||||
pdf_path = tmp
|
||||
doc_title = args.title or Path(args.url).name
|
||||
elif args.text:
|
||||
notifier.update("⏳ [1/4] Formatiere Text als Druck-PDF...")
|
||||
tmp = f"/tmp/print_{int(time.time())}.pdf"
|
||||
convert_text_to_pdf(args.text, tmp, title=doc_title)
|
||||
temp_files.append(tmp)
|
||||
pdf_path = tmp
|
||||
else:
|
||||
err = "❌ Kein Dokument angegeben (--file, --url oder --text erforderlich)."
|
||||
print(err)
|
||||
notifier.update(err)
|
||||
sys.exit(1)
|
||||
|
||||
# 2. Inspect PDF properties
|
||||
try:
|
||||
reader = PdfReader(pdf_path)
|
||||
total_pages = len(reader.pages)
|
||||
except Exception as e:
|
||||
total_pages = 1
|
||||
|
||||
# Safety guardrail: check max pages
|
||||
if total_pages > args.max_pages and not args.force:
|
||||
err = f"⚠️ *Druck abgebrochen*: Dokument hat {total_pages} Seiten (Limit: {args.max_pages}). Verwende `--force` zum Überschreiben."
|
||||
print(err)
|
||||
notifier.update(err)
|
||||
sys.exit(1)
|
||||
|
||||
# Calculate estimated sheets
|
||||
sheets = (total_pages + (args.nup - 1)) // args.nup
|
||||
if args.duplex != "one-sided":
|
||||
sheets = (sheets + 1) // 2
|
||||
|
||||
color_desc = "Farbe 🎨" if args.color == "color" else "Schwarz-Weiß 🖤"
|
||||
duplex_desc = "Duplex (beidseitig) 🔄" if args.duplex != "one-sided" else "Simplex (einseitig) 📄"
|
||||
nup_desc = f"{args.nup} Seite(n) pro Blatt"
|
||||
|
||||
# Initial status notification
|
||||
status_header = (
|
||||
f"🖨️ *Druckauftrag: {doc_title}*\n"
|
||||
f"• *Umfang*: {total_pages} Seite(n) (~{sheets} Blatt Papier)\n"
|
||||
f"• *Modus*: {color_desc} | {nup_desc} | {duplex_desc}\n"
|
||||
)
|
||||
|
||||
if not notifier.message_id and notifier.can_notify():
|
||||
notifier.send_initial(f"{status_header}• *Status*: ⏳ [1/3] Bereite Übertragung vor...")
|
||||
else:
|
||||
notifier.update(f"{status_header}• *Status*: ⏳ [1/3] Bereite Übertragung vor...")
|
||||
|
||||
# 3. Read PDF bytes
|
||||
with open(pdf_path, "rb") as f:
|
||||
pdf_data = f.read()
|
||||
|
||||
# 4. Connect to printer and send job
|
||||
notifier.update(f"{status_header}• *Status*: 📡 [2/3] Sende an Brother HL-L8240CDW...")
|
||||
printer = BrotherPrinter(host=args.host, port=args.port)
|
||||
|
||||
try:
|
||||
res = asyncio.run(
|
||||
printer.send_print_job(
|
||||
pdf_bytes=pdf_data,
|
||||
job_name=doc_title,
|
||||
color_mode=args.color,
|
||||
nup=args.nup,
|
||||
duplex=args.duplex,
|
||||
copies=args.copies,
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
err_msg = f"{status_header}• *Status*: ❌ Verbindungsfehler zum Drucker: `{e}`"
|
||||
print(err_msg)
|
||||
notifier.update(err_msg)
|
||||
sys.exit(1)
|
||||
|
||||
# Check IPP status code
|
||||
status_code = res.get("status-code", -1)
|
||||
if status_code != 0:
|
||||
err_msg = f"{status_header}• *Status*: ❌ Drucker meldet Fehler-Code `{status_code}`."
|
||||
print(err_msg)
|
||||
notifier.update(err_msg)
|
||||
sys.exit(1)
|
||||
|
||||
# Extract Job ID
|
||||
jobs = res.get("jobs", [])
|
||||
job_id = jobs[0].get("job-id", "unbekannt") if jobs else "unbekannt"
|
||||
print(f"Print job queued. Job ID: {job_id}")
|
||||
|
||||
notifier.update(f"{status_header}• *Status*: 🖨️ [3/3] Auftrag übertragen (Job #{job_id}). Druckvorgang läuft...")
|
||||
|
||||
# 5. Monitor Job Progress (up to 30 seconds)
|
||||
if isinstance(job_id, int):
|
||||
for _ in range(15):
|
||||
time.sleep(2)
|
||||
try:
|
||||
attr = asyncio.run(printer.get_job_attributes(job_id))
|
||||
state = attr.get("job-state")
|
||||
# 9 = completed, 7 = canceled, 8 = aborted
|
||||
if state == 9:
|
||||
break
|
||||
elif state in (7, 8):
|
||||
notifier.update(f"{status_header}• *Status*: ⚠️ Auftrag #{job_id} wurde am Drucker abgebrochen.")
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Final success update
|
||||
final_msg = (
|
||||
f"{status_header}"
|
||||
f"• *Status*: ✅ **Erfolgreich gedruckt!**\n"
|
||||
f"• *Job-ID*: `#{job_id}`\n"
|
||||
f"• *Ausgabe*: Liegt im Ausgabefach bereit."
|
||||
)
|
||||
print("Druck erfolgreich abgeschlossen.")
|
||||
notifier.update(final_msg)
|
||||
|
||||
# Cleanup temp files
|
||||
for t in temp_files:
|
||||
try:
|
||||
os.remove(t)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main Entry Point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Brother HL-L8240CDW IPP Print Utility for OpenClaw")
|
||||
parser.add_argument("--host", default=DEFAULT_PRINTER_HOST, help="Printer IP or Hostname")
|
||||
parser.add_argument("--port", type=int, default=DEFAULT_PRINTER_PORT, help="IPP Port (default 631)")
|
||||
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
# Subcommand: status
|
||||
status_parser = subparsers.add_parser("status", help="Get printer status and toner levels")
|
||||
status_parser.add_argument("--json", action="store_true", help="Output as JSON")
|
||||
status_parser.set_defaults(func=handle_status)
|
||||
|
||||
# Subcommand: validate
|
||||
val_parser = subparsers.add_parser("validate", help="Dry-run validate print parameters")
|
||||
val_parser.add_argument("--color", choices=["monochrome", "color"], default="monochrome")
|
||||
val_parser.add_argument("--nup", type=int, choices=[1, 2, 4], default=1)
|
||||
val_parser.add_argument("--duplex", choices=["two-sided-long-edge", "two-sided-short-edge", "one-sided"], default="two-sided-long-edge")
|
||||
val_parser.add_argument("--copies", type=int, default=1)
|
||||
val_parser.set_defaults(func=handle_validate)
|
||||
|
||||
# Subcommand: print
|
||||
print_parser = subparsers.add_parser("print", help="Print a document")
|
||||
print_parser.add_argument("--file", help="Path to PDF file")
|
||||
print_parser.add_argument("--url", help="URL of PDF document")
|
||||
print_parser.add_argument("--text", help="Raw text to convert and print")
|
||||
print_parser.add_argument("--title", help="Job or document title")
|
||||
print_parser.add_argument("--color", choices=["monochrome", "color"], default="monochrome", help="Color mode (default: monochrome)")
|
||||
print_parser.add_argument("--nup", type=int, choices=[1, 2, 4], default=1, help="Pages per sheet (1, 2 or 4)")
|
||||
print_parser.add_argument("--duplex", choices=["two-sided-long-edge", "two-sided-short-edge", "one-sided"], default="two-sided-long-edge", help="Duplex mode (default: two-sided-long-edge)")
|
||||
print_parser.add_argument("--copies", type=int, default=1, help="Number of copies")
|
||||
print_parser.add_argument("--max-pages", type=int, default=30, help="Safety page limit (default 30)")
|
||||
print_parser.add_argument("--force", action="store_true", help="Bypass max pages guardrail")
|
||||
print_parser.add_argument("--chat-id", help="Telegram Chat ID for live updates")
|
||||
print_parser.add_argument("--thread-id", type=int, help="Telegram Thread / Topic ID (for forum supergroups)")
|
||||
print_parser.add_argument("--message-id", help="Telegram Message ID to edit (optional, created if omitted)")
|
||||
print_parser.set_defaults(func=handle_print)
|
||||
|
||||
args = parser.parse_args()
|
||||
args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,100 @@
|
||||
---
|
||||
name: chart-to-chat
|
||||
description: Generiert hochwertige Diagramme und Charts (Balken, Linien, Verlauf, Donut/Kuchen, KPI-Karten, Tabellen) aus strukturierten Chat-Daten und sendet sie direkt als Bild (PNG) nach Telegram. Basiert auf dbt-charts (lokales Rendering via dct) mit QuickChart.io-Fallback.
|
||||
---
|
||||
|
||||
# 📊 Chart-to-Chat: Diagramm- und Chart-Generator
|
||||
|
||||
Dieser Skill transformiert tabellarische Daten, Verläufe, Statistiken oder Kennzahlen direkt in fertige, publikationsreife **PNG-Grafiken**, die nativ per Telegram-Bildnachricht ausgeliefert werden.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Wann diesen Skill verwenden?
|
||||
|
||||
* Immer wenn der Nutzer nach einer Visualisierung fragt:
|
||||
* *„Zeig mir das als Diagramm / Chart / Balkendiagramm“*
|
||||
* *„Plotte mir den Verlauf von X über die letzten 7 Tage“*
|
||||
* *„Visualisiere meine Ausgaben / mein Training / meine Schritte“*
|
||||
* *„Erstelle eine Grafik / Kurve dazu“*
|
||||
* Bei Vergleichsdaten oder Verteilungen (z. B. Anteile, Kategorien, Monatsumsätze).
|
||||
* Für KPI-Zusammenfassungen mit Hauptwert und Delta.
|
||||
|
||||
---
|
||||
|
||||
## 📈 Welcher Chart-Typ passt wann?
|
||||
|
||||
| Chart-Typ (`--type`) | Bester Anwendungsfall | Beispiel |
|
||||
|---|---|---|
|
||||
| `bar` (Default) | Diskrete Kategorien, Rankings, Monatsvergleiche | Schritte pro Tag, Stunden pro Projekt |
|
||||
| `hbar` | Viele Kategorien mit langen Namen | Ranking der Supermärkte nach Ersparnis |
|
||||
| `line` | Zeitreihen, kontinuierliche Verläufe, Messwerte | Herzfrequenz, Temperaturverlauf, Gewicht, Watt |
|
||||
| `area` | Kumulierte Volumina oder Trends über Zeit | Datenverbrauch, kumulierter Regen, Trainingsvolumen |
|
||||
| `donut` / `pie` | Prozentuale Anteile an einem Ganzen (100 %) | Ausgaben nach Kategorie, Makronährstoff-Verteilung |
|
||||
| `kpi` | Einzelne prominente Kennzahl mit Kontext/Delta | Aktueller FTP, Monatsumsatz, Gesamtschritte |
|
||||
| `table` | Strukturierte Datentabelle mit formatierten Spalten | Vergleichstabelle von Produkten oder Tarifen |
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Ausführung
|
||||
|
||||
Führe das Skript `chart.py` via `uv run` aus (Cluster-Pfad: `/home/node/mop-skills/mop-skills/skills/chart-to-chat/scripts/chart.py`):
|
||||
|
||||
### 1. Daten direkt als JSON-String übergeben
|
||||
|
||||
```bash
|
||||
uv run /home/node/mop-skills/mop-skills/skills/chart-to-chat/scripts/chart.py \
|
||||
--title "Wöchentliche Laufdistanz" \
|
||||
--type bar \
|
||||
--data '[{"tag": "Mo", "km": 8.5}, {"tag": "Mi", "km": 12.0}, {"tag": "Fr", "km": 6.2}, {"tag": "So", "km": 16.5}]'
|
||||
```
|
||||
|
||||
### 2. Daten per Pipe (Standard-Eingabe) übergeben
|
||||
|
||||
Besonders empfohlen bei größeren Datenmengen oder um Shell-Escaping-Probleme zu vermeiden:
|
||||
|
||||
```bash
|
||||
cat << 'EOF' | uv run /home/node/mop-skills/mop-skills/skills/chart-to-chat/scripts/chart.py --title "Ruhepuls Trend" --type line --data -
|
||||
[
|
||||
{"datum": "12.09.", "puls": 54},
|
||||
{"datum": "13.09.", "puls": 52},
|
||||
{"datum": "14.09.", "puls": 55},
|
||||
{"datum": "15.09.", "puls": 53},
|
||||
{"datum": "16.09.", "puls": 51}
|
||||
]
|
||||
EOF
|
||||
```
|
||||
|
||||
### 3. Donut-Chart für Anteile / Verteilungen
|
||||
|
||||
```bash
|
||||
uv run /home/node/mop-skills/mop-skills/skills/chart-to-chat/scripts/chart.py \
|
||||
--title "Ausgaben September" \
|
||||
--type donut \
|
||||
--data '[{"kategorie": "Miete", "euro": 950}, {"kategorie": "Lebensmittel", "euro": 420}, {"kategorie": "Sport", "euro": 110}]'
|
||||
```
|
||||
|
||||
### 4. Optionen des CLI-Tools
|
||||
|
||||
- `--title "<Text>"`: Überschrift des Diagramms.
|
||||
- `--notes "<Text>"`: Optionale Unterzeile / Erläuterung.
|
||||
- `--type bar|hbar|line|area|donut|kpi|table`: Visualisierungstyp.
|
||||
- `--data "<JSON>"` oder `--data -` (Stdin): Eingabedaten.
|
||||
- `--x <Spaltenname>` / `--y <Spaltenname>`: Manuelle Angabe der Achsenspalten (wird ansonsten automatisch erkannt).
|
||||
- `--engine auto|dct|quickchart`: `auto` nutzt primär das lokale `dbt-charts` und fällt bei Bedarf transparent auf `QuickChart.io` zurück.
|
||||
|
||||
---
|
||||
|
||||
## 📱 Auslieferung an Telegram
|
||||
|
||||
Das Skript gibt als letzte Zeile den Pfad der erzeugten PNG-Datei im Format `MEDIA:<pfad>` aus, z. B.:
|
||||
```text
|
||||
MEDIA:/tmp/charts/chart_a1b2c3d4.png
|
||||
```
|
||||
|
||||
Schließe diese Zeile einfach in deine finale Antwort an den Nutzer ein. OpenClaw versendet das Diagramm dadurch automatisch als Bild im Chat:
|
||||
|
||||
```markdown
|
||||
Hier ist die Übersicht deiner Laufdistanzen für diese Woche:
|
||||
|
||||
MEDIA:/tmp/charts/chart_a1b2c3d4.png
|
||||
```
|
||||
Executable
+456
@@ -0,0 +1,456 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
chart.py — Universal Chart & Graph Generator for OpenClaw / Telegram
|
||||
|
||||
Generates clean, publication-grade chart images from JSON/CSV data using:
|
||||
1. Primary: dbt-charts (`dct render`) — local, offline, Vega-Lite/resvg based.
|
||||
2. Fallback: QuickChart.io — fast HTTP API (Chart.js), zero local dependencies.
|
||||
|
||||
Outputs `MEDIA:<path>` on stdout for native Telegram image delivery.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
|
||||
|
||||
KNOWN_DCT_PATHS = [
|
||||
Path("/home/node/.openclaw/.local/bin/dct"),
|
||||
Path("/home/openclaw/.local/bin/dct"),
|
||||
Path("/home/node/.openclaw/.local/share/uv/tools/dbt-charts/bin/dct"),
|
||||
Path("/home/openclaw/.local/share/uv/tools/dbt-charts/bin/dct"),
|
||||
Path.home() / ".local/bin/dct",
|
||||
]
|
||||
|
||||
|
||||
def find_dct_executable() -> Optional[str]:
|
||||
"""Locate the dct executable in PATH or well-known install locations."""
|
||||
# 1. System PATH
|
||||
found = shutil.which("dct")
|
||||
if found:
|
||||
return found
|
||||
|
||||
# 2. Well-known absolute container/local paths
|
||||
for candidate in KNOWN_DCT_PATHS:
|
||||
if candidate.is_file() and os.access(candidate, os.X_OK):
|
||||
return str(candidate)
|
||||
|
||||
# 3. Check if uv tool run dct works
|
||||
uv_path = shutil.which("uv") or "/home/node/.openclaw/bin/uv"
|
||||
if os.path.exists(uv_path) and os.access(uv_path, os.X_OK):
|
||||
try:
|
||||
res = subprocess.run(
|
||||
[uv_path, "tool", "run", "dct", "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if res.returncode == 0:
|
||||
return "uv_tool_run"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def parse_input_data(raw_data: str) -> Tuple[List[str], List[List[Any]], Optional[str], Optional[str]]:
|
||||
"""
|
||||
Parse input string (JSON or CSV) into (columns, rows, suggested_x, suggested_y).
|
||||
Supports:
|
||||
- List of dicts: [{"label": "A", "val": 10}, {"label": "B", "val": 20}]
|
||||
- Dict of values: {"A": 10, "B": 20}
|
||||
- Explicit table: {"columns": ["k", "v"], "values": [["A", 10], ["B", 20]]}
|
||||
- Single KPI: {"value": 1284, "label": "Revenue", "delta": "+12%"}
|
||||
"""
|
||||
raw_data = raw_data.strip()
|
||||
if not raw_data:
|
||||
raise ValueError("Input data is empty.")
|
||||
|
||||
# Try JSON
|
||||
parsed = json.loads(raw_data)
|
||||
|
||||
# Case 1: Dict with explicit columns & values
|
||||
if isinstance(parsed, dict) and "columns" in parsed and ("values" in parsed or "rows" in parsed):
|
||||
cols = [str(c) for c in parsed["columns"]]
|
||||
rows = parsed.get("values") or parsed.get("rows") or []
|
||||
x_col = cols[0] if len(cols) > 0 else None
|
||||
y_col = cols[1] if len(cols) > 1 else None
|
||||
return cols, rows, x_col, y_col
|
||||
|
||||
# Case 2: Dict of key-value pairs (e.g. {"Mo": 10, "Di": 15})
|
||||
if isinstance(parsed, dict) and not any(k in parsed for k in ["columns", "values", "rows", "type", "chart"]):
|
||||
cols = ["category", "value"]
|
||||
rows = [[k, v] for k, v in parsed.items()]
|
||||
return cols, rows, "category", "value"
|
||||
|
||||
# Case 3: List of dicts (most common agent format)
|
||||
if isinstance(parsed, list):
|
||||
if not parsed:
|
||||
raise ValueError("Data list is empty.")
|
||||
if isinstance(parsed[0], dict):
|
||||
# Collect unique keys maintaining insertion order
|
||||
cols: List[str] = []
|
||||
for item in parsed:
|
||||
for k in item.keys():
|
||||
if k not in cols:
|
||||
cols.append(k)
|
||||
|
||||
rows: List[List[Any]] = []
|
||||
for item in parsed:
|
||||
rows.append([item.get(c) for c in cols])
|
||||
|
||||
# Infer x (category/date) and y (measure)
|
||||
x_col = None
|
||||
y_col = None
|
||||
for c in cols:
|
||||
low = c.lower()
|
||||
if low in ["x", "label", "name", "date", "datum", "month", "monat", "day", "tag", "category", "kategorie", "typ", "woche", "week"]:
|
||||
x_col = c
|
||||
break
|
||||
if not x_col and len(cols) > 0:
|
||||
x_col = cols[0]
|
||||
|
||||
for c in cols:
|
||||
if c == x_col:
|
||||
continue
|
||||
low = c.lower()
|
||||
if low in ["y", "value", "wert", "count", "anzahl", "amount", "menge", "total", "summe", "preis", "price", "revenue", "umsatz", "km", "kmh", "watt", "hr", "puls"]:
|
||||
y_col = c
|
||||
break
|
||||
if not y_col and len(cols) > 1:
|
||||
y_col = cols[1]
|
||||
|
||||
return cols, rows, x_col, y_col
|
||||
|
||||
elif isinstance(parsed[0], list):
|
||||
# List of lists without explicit headers
|
||||
cols = [f"col_{i+1}" for i in range(len(parsed[0]))]
|
||||
return cols, parsed, cols[0], cols[1] if len(cols) > 1 else None
|
||||
|
||||
raise ValueError(f"Unrecognized data structure: {type(parsed)}")
|
||||
|
||||
|
||||
def render_dbt_chart(
|
||||
dct_cmd: str,
|
||||
title: str,
|
||||
chart_type: str,
|
||||
columns: List[str],
|
||||
rows: List[List[Any]],
|
||||
x_col: str,
|
||||
y_col: str,
|
||||
output_path: Path,
|
||||
notes: Optional[str] = None,
|
||||
theme: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Render a chart using dbt-charts (dct render)."""
|
||||
scratch_dir = Path(tempfile.mkdtemp(prefix="dct_render_"))
|
||||
try:
|
||||
# 1. dbt_charts.yml project file (mandatory for dct)
|
||||
(scratch_dir / "dbt_charts.yml").write_text("# dbt charts project\nversion: 1\n", encoding="utf-8")
|
||||
|
||||
# Normalize chart type
|
||||
ct = chart_type.lower()
|
||||
if ct in ["hbar", "horizontal_bar"]:
|
||||
ct = "bar"
|
||||
is_horizontal = True
|
||||
else:
|
||||
is_horizontal = False
|
||||
|
||||
if ct in ["donut", "pie"]:
|
||||
dbt_type = "donut"
|
||||
elif ct in ["line", "trend"]:
|
||||
dbt_type = "line"
|
||||
elif ct in ["area"]:
|
||||
dbt_type = "area"
|
||||
elif ct in ["kpi"]:
|
||||
dbt_type = "kpi"
|
||||
elif ct in ["table"]:
|
||||
dbt_type = "table"
|
||||
else:
|
||||
dbt_type = "bar"
|
||||
|
||||
# Build board YAML
|
||||
board: Dict[str, Any] = {
|
||||
"title": title or "Dashboard",
|
||||
"queries": {
|
||||
"dataset": {
|
||||
"columns": columns,
|
||||
"values": rows,
|
||||
}
|
||||
},
|
||||
"charts": {},
|
||||
"rows": ["main_chart"],
|
||||
}
|
||||
|
||||
if notes:
|
||||
board["notes"] = notes
|
||||
|
||||
chart_spec: Dict[str, Any] = {
|
||||
"type": dbt_type,
|
||||
"query": "dataset",
|
||||
}
|
||||
if title:
|
||||
chart_spec["title"] = title
|
||||
|
||||
if dbt_type in ["donut", "pie"]:
|
||||
chart_spec["theta"] = y_col
|
||||
chart_spec["color"] = x_col
|
||||
elif dbt_type == "kpi":
|
||||
chart_spec["value"] = y_col or columns[0]
|
||||
chart_spec["label"] = x_col or "Value"
|
||||
elif dbt_type == "table":
|
||||
# Table automatically uses query columns
|
||||
pass
|
||||
else:
|
||||
# Bar / Line / Area
|
||||
if is_horizontal:
|
||||
chart_spec["x"] = y_col
|
||||
chart_spec["y"] = x_col
|
||||
else:
|
||||
chart_spec["x"] = x_col
|
||||
chart_spec["y"] = y_col
|
||||
|
||||
board["charts"]["main_chart"] = chart_spec
|
||||
|
||||
import yaml # PyYAML is standard in python / uv environments
|
||||
board_file = scratch_dir / "board.yml"
|
||||
board_file.write_text(yaml.safe_dump(board, sort_keys=False), encoding="utf-8")
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Build command
|
||||
if dct_cmd == "uv_tool_run":
|
||||
cmd = ["uv", "tool", "run", "dct", "render", str(board_file), "--format", "png", "--output", str(output_path)]
|
||||
else:
|
||||
cmd = [dct_cmd, "render", str(board_file), "--format", "png", "--output", str(output_path)]
|
||||
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
cwd=str(scratch_dir),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
if proc.returncode == 0 and output_path.exists() and output_path.stat().st_size > 0:
|
||||
return True
|
||||
else:
|
||||
sys.stderr.write(f"[dct render warning] exit={proc.returncode} stderr={proc.stderr.strip()}\n")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"[dct render error] {e}\n")
|
||||
return False
|
||||
finally:
|
||||
shutil.rmtree(scratch_dir, ignore_errors=True)
|
||||
|
||||
|
||||
def render_quickchart(
|
||||
title: str,
|
||||
chart_type: str,
|
||||
columns: List[str],
|
||||
rows: List[List[Any]],
|
||||
x_col: str,
|
||||
y_col: str,
|
||||
output_path: Path,
|
||||
width: int = 800,
|
||||
height: int = 450,
|
||||
) -> bool:
|
||||
"""Render chart via QuickChart.io API as fallback."""
|
||||
try:
|
||||
x_idx = columns.index(x_col)
|
||||
y_idx = columns.index(y_col)
|
||||
|
||||
labels = [str(r[x_idx]) for r in rows]
|
||||
values = []
|
||||
for r in rows:
|
||||
v = r[y_idx]
|
||||
try:
|
||||
values.append(float(v) if v is not None else 0)
|
||||
except (ValueError, TypeError):
|
||||
values.append(0)
|
||||
|
||||
ct = chart_type.lower()
|
||||
if ct in ["donut", "pie"]:
|
||||
qc_type = "doughnut" if ct == "donut" else "pie"
|
||||
bg_colors = [
|
||||
"#3366cc", "#dc3912", "#ff9900", "#109618", "#990099",
|
||||
"#3b3eac", "#0099c6", "#dd4477", "#66aa00", "#b82e2e",
|
||||
]
|
||||
dataset = {
|
||||
"data": values,
|
||||
"backgroundColor": bg_colors[:len(values)],
|
||||
}
|
||||
elif ct in ["line", "trend"]:
|
||||
qc_type = "line"
|
||||
dataset = {
|
||||
"label": y_col,
|
||||
"data": values,
|
||||
"borderColor": "#3e64ff",
|
||||
"backgroundColor": "rgba(62, 100, 255, 0.1)",
|
||||
"fill": False,
|
||||
"tension": 0.2,
|
||||
}
|
||||
elif ct in ["area"]:
|
||||
qc_type = "line"
|
||||
dataset = {
|
||||
"label": y_col,
|
||||
"data": values,
|
||||
"borderColor": "#3e64ff",
|
||||
"backgroundColor": "rgba(62, 100, 255, 0.25)",
|
||||
"fill": True,
|
||||
"tension": 0.2,
|
||||
}
|
||||
else:
|
||||
qc_type = "bar"
|
||||
dataset = {
|
||||
"label": y_col,
|
||||
"data": values,
|
||||
"backgroundColor": "#3e64ff",
|
||||
}
|
||||
|
||||
chart_config = {
|
||||
"type": qc_type,
|
||||
"data": {
|
||||
"labels": labels,
|
||||
"datasets": [dataset],
|
||||
},
|
||||
"options": {
|
||||
"title": {
|
||||
"display": bool(title),
|
||||
"text": title,
|
||||
"fontSize": 18,
|
||||
},
|
||||
"legend": {
|
||||
"display": qc_type in ["pie", "doughnut"],
|
||||
"position": "top",
|
||||
},
|
||||
"plugins": {
|
||||
"datalabels": {
|
||||
"display": False,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
query_params = {
|
||||
"w": str(width),
|
||||
"h": str(height),
|
||||
"devicePixelRatio": "2.0",
|
||||
"c": json.dumps(chart_config),
|
||||
}
|
||||
url = "https://quickchart.io/chart?" + urllib.parse.urlencode(query_params)
|
||||
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "OpenClaw-Chart/1.0"})
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
if resp.status == 200:
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_bytes(resp.read())
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"[quickchart error] {e}\n")
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="OpenClaw Universal Chart Generator")
|
||||
parser.add_argument("--data", help="JSON data string, or '-' for stdin")
|
||||
parser.add_argument("--file", help="Path to JSON or CSV file")
|
||||
parser.add_argument("--type", default="bar", choices=["bar", "hbar", "line", "area", "donut", "pie", "kpi", "table"], help="Chart type")
|
||||
parser.add_argument("--title", default="Übersicht", help="Chart or Dashboard title")
|
||||
parser.add_argument("--notes", help="Subtitle or explanatory notes")
|
||||
parser.add_argument("--x", help="X-axis / category column name")
|
||||
parser.add_argument("--y", help="Y-axis / measure column name")
|
||||
parser.add_argument("--output", help="Target PNG path (default: /tmp/charts/chart_<hash>.png)")
|
||||
parser.add_argument("--engine", default="auto", choices=["auto", "dct", "quickchart"], help="Rendering engine")
|
||||
parser.add_argument("--width", type=int, default=800, help="Width in pixels (QuickChart)")
|
||||
parser.add_argument("--height", type=int, default=450, help="Height in pixels (QuickChart)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Read data
|
||||
raw_data = None
|
||||
if args.data and args.data != "-":
|
||||
raw_data = args.data
|
||||
elif args.file:
|
||||
raw_data = Path(args.file).read_text(encoding="utf-8")
|
||||
elif args.data == "-" or not sys.stdin.isatty():
|
||||
raw_data = sys.stdin.read()
|
||||
|
||||
if not raw_data:
|
||||
parser.error("No input data provided. Use --data, --file, or pipe data via STDIN.")
|
||||
|
||||
try:
|
||||
columns, rows, inferred_x, inferred_y = parse_input_data(raw_data)
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"Error parsing input data: {e}\n")
|
||||
sys.exit(1)
|
||||
|
||||
x_col = args.x or inferred_x or (columns[0] if columns else "x")
|
||||
y_col = args.y or inferred_y or (columns[1] if len(columns) > 1 else columns[0])
|
||||
|
||||
# Destination path
|
||||
if args.output:
|
||||
output_path = Path(args.output)
|
||||
else:
|
||||
content_hash = hashlib.md5(f"{args.title}_{args.type}_{raw_data}".encode("utf-8")).hexdigest()[:8]
|
||||
output_path = Path(f"/tmp/charts/chart_{content_hash}.png")
|
||||
|
||||
rendered = False
|
||||
|
||||
# Attempt 1: dbt Charts (unless engine is explicitly quickchart)
|
||||
if args.engine in ["auto", "dct"]:
|
||||
dct_bin = find_dct_executable()
|
||||
if dct_bin:
|
||||
rendered = render_dbt_chart(
|
||||
dct_cmd=dct_bin,
|
||||
title=args.title,
|
||||
chart_type=args.type,
|
||||
columns=columns,
|
||||
rows=rows,
|
||||
x_col=x_col,
|
||||
y_col=y_col,
|
||||
output_path=output_path,
|
||||
notes=args.notes,
|
||||
)
|
||||
elif args.engine == "dct":
|
||||
sys.stderr.write("Error: dct executable not found and --engine dct was requested.\n")
|
||||
sys.exit(1)
|
||||
|
||||
# Attempt 2: QuickChart Fallback
|
||||
if not rendered and args.engine in ["auto", "quickchart"]:
|
||||
rendered = render_quickchart(
|
||||
title=args.title,
|
||||
chart_type=args.type,
|
||||
columns=columns,
|
||||
rows=rows,
|
||||
x_col=x_col,
|
||||
y_col=y_col,
|
||||
output_path=output_path,
|
||||
width=args.width,
|
||||
height=args.height,
|
||||
)
|
||||
|
||||
if not rendered:
|
||||
sys.stderr.write("Error: Chart rendering failed on all available engines.\n")
|
||||
sys.exit(1)
|
||||
|
||||
# Deliver MEDIA line for Telegram/OpenClaw
|
||||
print(f"MEDIA:{output_path.resolve()}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,133 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Unit and integration tests for chart.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
# Add scripts directory to path
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
|
||||
from chart import parse_input_data
|
||||
|
||||
|
||||
class TestChartParser(unittest.TestCase):
|
||||
def test_list_of_dicts(self):
|
||||
raw = json.dumps([
|
||||
{"day": "Mo", "temp": 18},
|
||||
{"day": "Di", "temp": 22},
|
||||
{"day": "Mi", "temp": 19},
|
||||
])
|
||||
cols, rows, x, y = parse_input_data(raw)
|
||||
self.assertEqual(cols, ["day", "temp"])
|
||||
self.assertEqual(len(rows), 3)
|
||||
self.assertEqual(x, "day")
|
||||
self.assertEqual(y, "temp")
|
||||
|
||||
def test_dict_key_value(self):
|
||||
raw = json.dumps({"Bier": 4.5, "Pizza": 12.0, "Kaffee": 3.2})
|
||||
cols, rows, x, y = parse_input_data(raw)
|
||||
self.assertEqual(cols, ["category", "value"])
|
||||
self.assertEqual(len(rows), 3)
|
||||
self.assertEqual(x, "category")
|
||||
self.assertEqual(y, "value")
|
||||
|
||||
def test_explicit_columns_values(self):
|
||||
raw = json.dumps({
|
||||
"columns": ["Monat", "Umsatz"],
|
||||
"values": [["Jan", 100], ["Feb", 150]]
|
||||
})
|
||||
cols, rows, x, y = parse_input_data(raw)
|
||||
self.assertEqual(cols, ["Monat", "Umsatz"])
|
||||
self.assertEqual(len(rows), 2)
|
||||
self.assertEqual(x, "Monat")
|
||||
self.assertEqual(y, "Umsatz")
|
||||
|
||||
|
||||
class TestChartCLI(unittest.TestCase):
|
||||
def run_cli(self, args, stdin_input=None):
|
||||
cmd = [sys.executable, str(SCRIPTS_DIR / "chart.py")] + args
|
||||
res = subprocess.run(
|
||||
cmd,
|
||||
input=stdin_input,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
return res
|
||||
|
||||
def test_bar_chart_generation(self):
|
||||
data = json.dumps([
|
||||
{"label": "Projekt A", "stunden": 14},
|
||||
{"label": "Projekt B", "stunden": 28},
|
||||
{"label": "Projekt C", "stunden": 8},
|
||||
])
|
||||
out_path = Path("/tmp/charts/test_bar.png")
|
||||
if out_path.exists():
|
||||
out_path.unlink()
|
||||
|
||||
res = self.run_cli(["--type", "bar", "--title", "Projektstunden", "--data", data, "--output", str(out_path)])
|
||||
self.assertEqual(res.returncode, 0, f"Stderr: {res.stderr}")
|
||||
self.assertTrue(res.stdout.strip().startswith("MEDIA:"))
|
||||
self.assertTrue(out_path.exists())
|
||||
self.assertGreater(out_path.stat().st_size, 1000)
|
||||
|
||||
def test_line_chart_stdin(self):
|
||||
data = json.dumps([
|
||||
{"datum": "2026-09-10", "puls": 62},
|
||||
{"datum": "2026-09-11", "puls": 58},
|
||||
{"datum": "2026-09-12", "puls": 65},
|
||||
{"datum": "2026-09-13", "puls": 59},
|
||||
])
|
||||
out_path = Path("/tmp/charts/test_line.png")
|
||||
if out_path.exists():
|
||||
out_path.unlink()
|
||||
|
||||
res = self.run_cli(["--type", "line", "--title", "Ruhepuls Verlauf", "--data", "-", "--output", str(out_path)], stdin_input=data)
|
||||
self.assertEqual(res.returncode, 0, f"Stderr: {res.stderr}")
|
||||
self.assertTrue(res.stdout.strip().startswith("MEDIA:"))
|
||||
self.assertTrue(out_path.exists())
|
||||
self.assertGreater(out_path.stat().st_size, 1000)
|
||||
|
||||
def test_donut_chart(self):
|
||||
data = json.dumps([
|
||||
{"bereich": "Wohnen", "kosten": 950},
|
||||
{"bereich": "Essen", "kosten": 450},
|
||||
{"bereich": "Mobilität", "kosten": 120},
|
||||
{"bereich": "Freizeit", "kosten": 200},
|
||||
])
|
||||
out_path = Path("/tmp/charts/test_donut.png")
|
||||
if out_path.exists():
|
||||
out_path.unlink()
|
||||
|
||||
res = self.run_cli(["--type", "donut", "--title", "Ausgaben", "--data", data, "--output", str(out_path)])
|
||||
self.assertEqual(res.returncode, 0, f"Stderr: {res.stderr}")
|
||||
self.assertTrue(res.stdout.strip().startswith("MEDIA:"))
|
||||
self.assertTrue(out_path.exists())
|
||||
self.assertGreater(out_path.stat().st_size, 1000)
|
||||
|
||||
def test_quickchart_fallback_engine(self):
|
||||
data = json.dumps([
|
||||
{"fach": "Mathe", "note": 1.7},
|
||||
{"fach": "Physik", "note": 2.0},
|
||||
{"fach": "Informatik", "note": 1.0},
|
||||
])
|
||||
out_path = Path("/tmp/charts/test_quickchart.png")
|
||||
if out_path.exists():
|
||||
out_path.unlink()
|
||||
|
||||
res = self.run_cli(["--engine", "quickchart", "--type", "bar", "--title", "Notenspiegel", "--data", data, "--output", str(out_path)])
|
||||
self.assertEqual(res.returncode, 0, f"Stderr: {res.stderr}")
|
||||
self.assertTrue(res.stdout.strip().startswith("MEDIA:"))
|
||||
self.assertTrue(out_path.exists())
|
||||
self.assertGreater(out_path.stat().st_size, 1000)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,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`.
|
||||
Executable
+533
@@ -0,0 +1,533 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# dependencies = [
|
||||
# "garminconnect>=0.2.20",
|
||||
# "curl_cffi>=0.7.0",
|
||||
# ]
|
||||
# ///
|
||||
"""
|
||||
Garmin Connect Authentication & Token Lifecycle Manager for OpenClaw.
|
||||
|
||||
Provides headless, 2-turn MFA login via Telegram, proactive token keepalive,
|
||||
and token status inspection.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
handlers=[logging.StreamHandler(sys.stderr)],
|
||||
)
|
||||
logger = logging.getLogger("garmin_auth")
|
||||
|
||||
|
||||
def get_token_dir() -> str:
|
||||
"""Get the token directory from environment or default locations."""
|
||||
if os.environ.get("GARMINTOKENS"):
|
||||
p = os.path.expanduser(os.environ["GARMINTOKENS"])
|
||||
elif os.path.exists("/home/node/.openclaw"):
|
||||
p = "/home/node/.openclaw/garmin_tokens"
|
||||
else:
|
||||
p = os.path.expanduser("~/.garminconnect")
|
||||
os.makedirs(p, mode=0o700, exist_ok=True)
|
||||
return p
|
||||
|
||||
|
||||
def get_token_file() -> str:
|
||||
return os.path.join(get_token_dir(), "garmin_tokens.json")
|
||||
|
||||
|
||||
def get_token_base64_file() -> str:
|
||||
return os.path.join(get_token_dir(), "garmin_tokens.base64")
|
||||
|
||||
|
||||
def get_socket_path() -> str:
|
||||
token_dir = get_token_dir()
|
||||
if os.access(token_dir, os.W_OK):
|
||||
return os.path.join(token_dir, "auth_daemon.sock")
|
||||
return "/tmp/garmin_auth.sock"
|
||||
|
||||
|
||||
def get_status_file() -> str:
|
||||
token_dir = get_token_dir()
|
||||
if os.access(token_dir, os.W_OK):
|
||||
return os.path.join(token_dir, "auth_status.json")
|
||||
return "/tmp/garmin_auth_status.json"
|
||||
|
||||
|
||||
def get_credentials() -> Tuple[str, str]:
|
||||
email = os.environ.get("GARMIN_EMAIL") or os.environ.get("garmin-email")
|
||||
password = os.environ.get("GARMIN_PASSWORD") or os.environ.get("garmin-password")
|
||||
|
||||
if not email or not password:
|
||||
# Check files if defined
|
||||
email_file = os.environ.get("GARMIN_EMAIL_FILE")
|
||||
if email_file and os.path.exists(email_file):
|
||||
with open(email_file) as f:
|
||||
email = f.read().strip()
|
||||
password_file = os.environ.get("GARMIN_PASSWORD_FILE")
|
||||
if password_file and os.path.exists(password_file):
|
||||
with open(password_file) as f:
|
||||
password = f.read().strip()
|
||||
|
||||
if not email or not password:
|
||||
raise ValueError(
|
||||
"GARMIN_EMAIL and GARMIN_PASSWORD environment variables must be set."
|
||||
)
|
||||
return email.strip(), password.strip()
|
||||
|
||||
|
||||
def secure_tokens(token_dir: str) -> None:
|
||||
"""Set secure 700 / 600 permissions."""
|
||||
try:
|
||||
if os.path.isdir(token_dir):
|
||||
os.chmod(token_dir, 0o700)
|
||||
for entry in os.scandir(token_dir):
|
||||
if entry.is_file():
|
||||
os.chmod(entry.path, 0o600)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not secure directory permissions: {e}")
|
||||
|
||||
|
||||
def decode_jwt_exp(jwt_token: str) -> Tuple[Optional[datetime], Optional[int]]:
|
||||
"""Extract expiration datetime and timestamp from JWT."""
|
||||
try:
|
||||
parts = jwt_token.split(".")
|
||||
if len(parts) != 3:
|
||||
return None, None
|
||||
payload_b64 = parts[1]
|
||||
payload_b64 += "=" * (-len(payload_b64) % 4)
|
||||
payload = json.loads(base64.urlsafe_b64decode(payload_b64))
|
||||
exp = payload.get("exp")
|
||||
if exp:
|
||||
dt = datetime.fromtimestamp(exp, tz=timezone.utc)
|
||||
return dt, exp
|
||||
except Exception:
|
||||
pass
|
||||
return None, None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI Command: status
|
||||
# ---------------------------------------------------------------------------
|
||||
def cmd_status() -> int:
|
||||
token_file = get_token_file()
|
||||
token_dir = get_token_dir()
|
||||
status_file = get_status_file()
|
||||
|
||||
info: Dict[str, Any] = {
|
||||
"token_dir": token_dir,
|
||||
"token_file": token_file,
|
||||
"exists": os.path.exists(token_file),
|
||||
"valid": False,
|
||||
"user": None,
|
||||
"exp_utc": None,
|
||||
"exp_timestamp": None,
|
||||
"pending_mfa": False,
|
||||
}
|
||||
|
||||
if os.path.exists(status_file):
|
||||
try:
|
||||
with open(status_file) as f:
|
||||
s = json.load(f)
|
||||
if s.get("state") == "waiting_for_mfa":
|
||||
info["pending_mfa"] = True
|
||||
info["mfa_started_at"] = s.get("started_at")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not info["exists"]:
|
||||
print(json.dumps(info, indent=2))
|
||||
return 1
|
||||
|
||||
try:
|
||||
with open(token_file, "r") as f:
|
||||
data = json.load(f)
|
||||
|
||||
di_token = data.get("di_token")
|
||||
if di_token:
|
||||
dt, exp = decode_jwt_exp(di_token)
|
||||
if dt:
|
||||
info["exp_utc"] = dt.isoformat()
|
||||
info["exp_timestamp"] = exp
|
||||
|
||||
# Test live connectivity
|
||||
from garminconnect import Garmin
|
||||
|
||||
client = Garmin()
|
||||
client.login(token_dir)
|
||||
full_name = client.get_full_name()
|
||||
if full_name:
|
||||
info["valid"] = True
|
||||
info["user"] = full_name
|
||||
except Exception as e:
|
||||
info["error"] = str(e)
|
||||
|
||||
print(json.dumps(info, indent=2))
|
||||
return 0 if info["valid"] else 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Background Daemon: Run MFA Flow & Listen on Unix Socket
|
||||
# ---------------------------------------------------------------------------
|
||||
def _run_daemon():
|
||||
"""Executed as a detached background worker process."""
|
||||
socket_path = get_socket_path()
|
||||
status_file = get_status_file()
|
||||
token_dir = get_token_dir()
|
||||
token_file = get_token_file()
|
||||
base64_file = get_token_base64_file()
|
||||
|
||||
# Clean up old socket
|
||||
if os.path.exists(socket_path):
|
||||
try:
|
||||
os.unlink(socket_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
try:
|
||||
email, password = get_credentials()
|
||||
except Exception as e:
|
||||
with open(status_file, "w") as f:
|
||||
json.dump({"state": "error", "error": str(e)}, f)
|
||||
sys.exit(1)
|
||||
|
||||
with open(status_file, "w") as f:
|
||||
json.dump(
|
||||
{
|
||||
"state": "initiating_login",
|
||||
"started_at": time.time(),
|
||||
"email": email,
|
||||
},
|
||||
f,
|
||||
)
|
||||
|
||||
from garminconnect import (
|
||||
Garmin,
|
||||
GarminConnectAuthenticationError,
|
||||
GarminConnectConnectionError,
|
||||
GarminConnectTooManyRequestsError,
|
||||
)
|
||||
|
||||
try:
|
||||
garmin = Garmin(email=email, password=password, return_on_mfa=True)
|
||||
status, _ = garmin.login()
|
||||
|
||||
if status != "needs_mfa":
|
||||
# Logged in directly without MFA
|
||||
garmin.client.dump(token_dir)
|
||||
secure_tokens(token_dir)
|
||||
with open(token_file, "r") as tf:
|
||||
raw = tf.read()
|
||||
with open(base64_file, "w") as bf:
|
||||
bf.write(base64.b64encode(raw.encode()).decode())
|
||||
os.chmod(base64_file, 0o600)
|
||||
name = garmin.get_full_name()
|
||||
with open(status_file, "w") as f:
|
||||
json.dump({"state": "success", "user": name}, f)
|
||||
sys.exit(0)
|
||||
|
||||
# Needs MFA - set up listening Unix socket
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
sock.bind(socket_path)
|
||||
os.chmod(socket_path, 0o600)
|
||||
sock.listen(1)
|
||||
sock.settimeout(300.0) # 5 minutes timeout for user to submit code
|
||||
|
||||
with open(status_file, "w") as f:
|
||||
json.dump(
|
||||
{
|
||||
"state": "waiting_for_mfa",
|
||||
"started_at": time.time(),
|
||||
"email": email,
|
||||
"socket": socket_path,
|
||||
},
|
||||
f,
|
||||
)
|
||||
|
||||
while True:
|
||||
try:
|
||||
conn, _ = sock.accept()
|
||||
except socket.timeout:
|
||||
with open(status_file, "w") as f:
|
||||
json.dump(
|
||||
{"state": "timed_out", "error": "MFA wait timed out after 5 minutes"},
|
||||
f,
|
||||
)
|
||||
break
|
||||
|
||||
with conn:
|
||||
raw_data = conn.recv(1024).decode().strip()
|
||||
if not raw_data:
|
||||
continue
|
||||
|
||||
try:
|
||||
payload = json.loads(raw_data)
|
||||
code = payload.get("code", "").strip()
|
||||
except Exception:
|
||||
code = raw_data.strip()
|
||||
|
||||
code = re.sub(r"\D", "", code)
|
||||
if len(code) != 6:
|
||||
response = {
|
||||
"status": "invalid_code",
|
||||
"error": "Code must be 6 digits",
|
||||
}
|
||||
conn.sendall(json.dumps(response).encode())
|
||||
continue
|
||||
|
||||
try:
|
||||
garmin.resume_login(None, code)
|
||||
garmin.client.dump(token_dir)
|
||||
secure_tokens(token_dir)
|
||||
|
||||
# Export base64 string
|
||||
with open(token_file, "r") as tf:
|
||||
token_str = tf.read()
|
||||
with open(base64_file, "w") as bf:
|
||||
bf.write(base64.b64encode(token_str.encode()).decode())
|
||||
os.chmod(base64_file, 0o600)
|
||||
|
||||
name = garmin.get_full_name() or "User"
|
||||
response = {"status": "success", "user": name}
|
||||
conn.sendall(json.dumps(response).encode())
|
||||
|
||||
with open(status_file, "w") as f:
|
||||
json.dump(
|
||||
{
|
||||
"state": "success",
|
||||
"user": name,
|
||||
"finished_at": time.time(),
|
||||
},
|
||||
f,
|
||||
)
|
||||
break # Success! Clean exit
|
||||
|
||||
except GarminConnectAuthenticationError as ae:
|
||||
response = {
|
||||
"status": "auth_error",
|
||||
"error": f"MFA verification failed: {ae}",
|
||||
}
|
||||
conn.sendall(json.dumps(response).encode())
|
||||
except Exception as ex:
|
||||
response = {
|
||||
"status": "error",
|
||||
"error": f"Unexpected error during resume_login: {ex}",
|
||||
}
|
||||
conn.sendall(json.dumps(response).encode())
|
||||
|
||||
except Exception as e:
|
||||
with open(status_file, "w") as f:
|
||||
json.dump({"state": "error", "error": str(e)}, f)
|
||||
finally:
|
||||
if os.path.exists(socket_path):
|
||||
try:
|
||||
os.unlink(socket_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI Command: request-code
|
||||
# ---------------------------------------------------------------------------
|
||||
def cmd_request_code() -> int:
|
||||
socket_path = get_socket_path()
|
||||
status_file = get_status_file()
|
||||
|
||||
# If daemon is already running and waiting, check status
|
||||
if os.path.exists(socket_path) and os.path.exists(status_file):
|
||||
try:
|
||||
with open(status_file) as f:
|
||||
st = json.load(f)
|
||||
if st.get("state") == "waiting_for_mfa":
|
||||
print(
|
||||
"MFA_ALREADY_ACTIVE: A pending login session is already waiting for your MFA code. "
|
||||
"Reply with the 6-digit code or wait for timeout."
|
||||
)
|
||||
return 0
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Clean up stale files
|
||||
for p in (socket_path, status_file):
|
||||
if os.path.exists(p):
|
||||
try:
|
||||
os.unlink(p)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Spawn daemon process via subprocess.Popen detached
|
||||
script_path = os.path.abspath(__file__)
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, script_path, "_daemon"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
logger.info(f"Spawned Garmin auth daemon (PID: {proc.pid})")
|
||||
|
||||
# Poll status file up to 25 seconds
|
||||
start_wait = time.time()
|
||||
while time.time() - start_wait < 25.0:
|
||||
time.sleep(0.5)
|
||||
if os.path.exists(status_file):
|
||||
try:
|
||||
with open(status_file) as f:
|
||||
data = json.load(f)
|
||||
state = data.get("state")
|
||||
if state == "waiting_for_mfa":
|
||||
print(
|
||||
"MFA_REQUIRED: Garmin has sent a 6-digit verification code to your email/SMS. "
|
||||
"Please reply with the code."
|
||||
)
|
||||
return 0
|
||||
elif state == "success":
|
||||
print(
|
||||
f"SUCCESS: Logged in directly as {data.get('user')} without MFA. Tokens saved."
|
||||
)
|
||||
return 0
|
||||
elif state == "error":
|
||||
print(f"ERROR: Login failed: {data.get('error')}", file=sys.stderr)
|
||||
return 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print("ERROR: Timed out waiting for Garmin SSO to send MFA code.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI Command: verify <code>
|
||||
# ---------------------------------------------------------------------------
|
||||
def cmd_verify(code: str) -> int:
|
||||
code = re.sub(r"\D", "", code.strip())
|
||||
if len(code) != 6:
|
||||
print(f"ERROR: Code must be exactly 6 digits, got {code!r}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
socket_path = get_socket_path()
|
||||
if not os.path.exists(socket_path):
|
||||
print(
|
||||
"ERROR: No active Garmin auth session found. Run 'request-code' first.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
try:
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
sock.connect(socket_path)
|
||||
sock.settimeout(35.0)
|
||||
|
||||
sock.sendall(json.dumps({"code": code}).encode())
|
||||
raw_res = sock.recv(2048).decode()
|
||||
sock.close()
|
||||
|
||||
res = json.loads(raw_res)
|
||||
if res.get("status") == "success":
|
||||
user = res.get("user", "Moritz Graf")
|
||||
print(f"SUCCESS: Authenticated as {user}! Tokens saved successfully.")
|
||||
return 0
|
||||
else:
|
||||
print(f"ERROR: {res.get('error', 'Authentication failed')}", file=sys.stderr)
|
||||
return 1
|
||||
except Exception as e:
|
||||
print(f"ERROR: Failed communicating with auth daemon: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI Command: keepalive
|
||||
# ---------------------------------------------------------------------------
|
||||
def cmd_keepalive() -> int:
|
||||
token_dir = get_token_dir()
|
||||
token_file = get_token_file()
|
||||
|
||||
if not os.path.exists(token_file):
|
||||
print(f"ERROR: No tokens found at {token_file}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
try:
|
||||
from garminconnect import Garmin
|
||||
|
||||
client = Garmin()
|
||||
client.login(token_dir)
|
||||
# Calling an API endpoint triggers _token_expires_soon() and auto-refresh + dump
|
||||
name = client.get_full_name()
|
||||
if not name:
|
||||
print("ERROR: Login returned empty profile name", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
with open(token_file) as f:
|
||||
data = json.load(f)
|
||||
dt, exp = decode_jwt_exp(data.get("di_token", ""))
|
||||
|
||||
exp_str = dt.strftime("%Y-%m-%d %H:%M:%S UTC") if dt else "unknown"
|
||||
print(f"KEEPALIVE_OK: Token valid for {name}. Current DI token expires at: {exp_str}")
|
||||
return 0
|
||||
except Exception as e:
|
||||
print(f"ERROR: Keepalive check failed: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main Router
|
||||
# ---------------------------------------------------------------------------
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Garmin Authentication & Token Lifecycle CLI for OpenClaw"
|
||||
)
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
# status
|
||||
subparsers.add_parser("status", help="Check stored token status and validity")
|
||||
|
||||
# request-code
|
||||
subparsers.add_parser(
|
||||
"request-code", help="Initiate Garmin login and request an MFA code"
|
||||
)
|
||||
|
||||
# verify <code>
|
||||
verify_parser = subparsers.add_parser(
|
||||
"verify", help="Submit MFA code to the active auth session"
|
||||
)
|
||||
verify_parser.add_argument("code", help="6-digit verification code")
|
||||
|
||||
# keepalive
|
||||
subparsers.add_parser(
|
||||
"keepalive", help="Ping Garmin API and proactively refresh token if needed"
|
||||
)
|
||||
|
||||
# internal daemon
|
||||
subparsers.add_parser("_daemon", help=argparse.SUPPRESS)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command == "_daemon":
|
||||
_run_daemon()
|
||||
elif args.command == "status":
|
||||
sys.exit(cmd_status())
|
||||
elif args.command == "request-code":
|
||||
sys.exit(cmd_request_code())
|
||||
elif args.command == "verify":
|
||||
sys.exit(cmd_verify(args.code))
|
||||
elif args.command == "keepalive":
|
||||
sys.exit(cmd_keepalive())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,120 @@
|
||||
---
|
||||
name: garmin-workout
|
||||
description: Design, validate, upload, repair, delete, and schedule Garmin Connect workouts. Enforces strict sportType IDs, DTO schemas, and avoids Garmin Connect mobile app list corruption. Use when creating, modifying, uploading, scheduling, or deleting workouts in Garmin Connect.
|
||||
---
|
||||
|
||||
# 🏋️ Garmin Connect Workout Manager & Schema Validator
|
||||
|
||||
This skill defines the technical standards, DTO schemas, sport type identifiers, and safety protocols for creating, uploading, scheduling, and managing Garmin Connect workouts without corrupting the Garmin Connect mobile app or web client.
|
||||
|
||||
---
|
||||
|
||||
## 🚨 Critical Safety Rule: Sport Type Integrity
|
||||
|
||||
Whenever creating or uploading workouts via `garmin__upload_workout` or `schedule_workout`, **BOTH `sportTypeId` (integer) AND `sportTypeKey` (string)** MUST be provided in:
|
||||
1. The top-level workout object: `workout_data["sportType"]`
|
||||
2. Every segment object: `workout_data["workoutSegments"][i]["sportType"]`
|
||||
|
||||
### ⚠️ The "0 Workouts" Bug
|
||||
If you send only `{"sportTypeKey": "yoga"}` without `sportTypeId`, Garmin's backend accepts the HTTP POST but defaults `sportTypeId` to `0` and sets `sportTypeKey: null` in its database.
|
||||
When the Garmin Connect mobile app (iOS / Android) or modern web UI attempts to deserialize the workout list, it crashes on `null` and displays **"0 Workouts"**, making it appear as if all user workouts were deleted!
|
||||
|
||||
### 📋 Canonical Garmin Sport Type Mapping
|
||||
| Sport | `sportTypeId` | `sportTypeKey` | `displayOrder` | Notes |
|
||||
|---|---|---|---|---|
|
||||
| **Yoga** | `7` | `"yoga"` | `8` | Essential for yoga flows |
|
||||
| **Running** | `1` | `"running"` | `1` | Standard running |
|
||||
| **Cycling** | `2` | `"cycling"` | `2` | Road, gravel, Tacx indoor |
|
||||
| **Strength** | `5` | `"strength_training"` | `5` | Home gym, supersets |
|
||||
| **Cardio** | `6` | `"cardio_training"` | `6` | General cardio |
|
||||
| **Pilates** | `8` | `"pilates"` | `9` | Core & flexibility |
|
||||
| **HIIT** | `9` | `"hiit"` | `7` | Tabata, SIT, sprint intervals |
|
||||
| **Swimming** | `4` | `"lap_swimming"` | `4` | Pool swimming |
|
||||
| **Walking** | `12` | `"walking"` | `10` | Recovery walks |
|
||||
| **Mobility** | `11` | `"mobility"` | `11` | Joint mobility |
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Workout JSON Structure Reference
|
||||
|
||||
```json
|
||||
{
|
||||
"workoutName": "GeminiYoga#1 Fixed",
|
||||
"description": "Optimierte Yoga-Sequenz ohne Redundanz.",
|
||||
"estimatedDurationInSecs": 1800,
|
||||
"sportType": {
|
||||
"sportTypeId": 7,
|
||||
"sportTypeKey": "yoga",
|
||||
"displayOrder": 8
|
||||
},
|
||||
"workoutSegments": [
|
||||
{
|
||||
"segmentOrder": 1,
|
||||
"sportType": {
|
||||
"sportTypeId": 7,
|
||||
"sportTypeKey": "yoga",
|
||||
"displayOrder": 8
|
||||
},
|
||||
"workoutSteps": [
|
||||
{
|
||||
"type": "ExecutableStepDTO",
|
||||
"stepOrder": 1,
|
||||
"stepType": {
|
||||
"stepTypeId": 1,
|
||||
"stepTypeKey": "warmup"
|
||||
},
|
||||
"endCondition": {
|
||||
"conditionTypeId": 2,
|
||||
"conditionTypeKey": "time"
|
||||
},
|
||||
"endConditionValue": 60.0,
|
||||
"targetType": {
|
||||
"workoutTargetTypeId": 1,
|
||||
"workoutTargetTypeKey": "no.target"
|
||||
},
|
||||
"description": "Mountain Pose - Atmung etablieren"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Operational Recipes
|
||||
|
||||
### 1. Removing a Corrupted Workout
|
||||
If a workout was uploaded with `sportTypeId: 0` or corrupt data:
|
||||
```json
|
||||
// Tool: mcp:garmin:garmin__delete_workout
|
||||
{
|
||||
"workout_id": 1703067815
|
||||
}
|
||||
```
|
||||
*Effect:* Instantly restores Garmin Connect app visibility for all remaining workouts.
|
||||
|
||||
### 2. Uploading a Fixed Workout
|
||||
```json
|
||||
// Tool: mcp:garmin:garmin__upload_workout
|
||||
{
|
||||
"workout_data": {
|
||||
"workoutName": "...",
|
||||
"sportType": {
|
||||
"sportTypeId": 7,
|
||||
"sportTypeKey": "yoga",
|
||||
"displayOrder": 8
|
||||
},
|
||||
"workoutSegments": [ ... ]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Scheduling to Watch Calendar
|
||||
```json
|
||||
// Tool: mcp:garmin:garmin__schedule_workout
|
||||
{
|
||||
"workout_id": 1703067815,
|
||||
"calendar_date": "2026-09-20"
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,113 @@
|
||||
---
|
||||
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
|
||||
|
||||
You have access to 8 dedicated calendar tools:
|
||||
|
||||
1. `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_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_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"` (weekly Saturday market)
|
||||
- `recurrence="RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA;UNTIL=20261231T230000Z"` (daily except Sun until year end)
|
||||
- Shortcuts: `"weekly"`, `"daily"`, `"monthly"`, `"workdays"`.
|
||||
- **`show_as_busy` / `transparency`**:
|
||||
- For **Regensburg city events, markets, 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_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_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_move_event(event_id, source_calendar="family", destination_calendar="regensburg")`:
|
||||
- Moves an event atomically from one calendar to another (e.g. turning an interesting city event from Regensburg into a fixed family plan, or correcting a wrongly booked calendar).
|
||||
|
||||
7. `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.
|
||||
- Perfect for answering questions like: *"Wann haben wir diese Woche am Nachmittag 2 Stunden frei für einen Ausflug?"*.
|
||||
|
||||
8. `calendar_get_event(calendar="family"|"regensburg", event_id)`:
|
||||
- Retrieves full event metadata.
|
||||
|
||||
---
|
||||
|
||||
## 🧭 Routing & Best Practices for OpenClaw
|
||||
|
||||
### 1. Conflict Guard (Terminkonflikte & Puffer)
|
||||
- When a user asks to add an event, check `calendar_check_conflicts` or inspect the `conflict_warning` from `calendar_create_event`.
|
||||
- If an overlap or tight buffer (< 15 Min.) exists, explicitly warn the user:
|
||||
> *„⚠️ Achtung: Am Freitag um 17:30 Uhr hat Moritz schon 'Moritz Gartenparty Arbeit' (17:30 - 18:30). Dein Termin 'Spielplatz' würde sich damit überschneiden. Soll ich ihn trotzdem eintragen?“*
|
||||
|
||||
### 2. Informational Events vs. Family Commitments (Transparency)
|
||||
- When adding city events, festivals, or weekly markets 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. weekly markets, sports training, trash collection):
|
||||
- Use `recurrence="RRULE:FREQ=WEEKLY;BYDAY=..."` or convenient shortcuts.
|
||||
- Do NOT create manual cron scripts or multiple single events when a native Google Calendar recurrence series can be used.
|
||||
|
||||
### 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 (*„Aufbruch in 15 Min. wegen Verkehr“*).
|
||||
|
||||
### 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`).
|
||||
+1546
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -0,0 +1,598 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Google Maps Platform MCP Server & CLI Tool for OpenClaw.
|
||||
|
||||
Integrates Google Places (New), Routes API, Geocoding, and Static Maps with OpenClaw.
|
||||
Features:
|
||||
- maps_search_places: Search restaurants, bakeries, parks, shops with live opening hours & ratings
|
||||
- maps_get_place_details: Get full weekly schedule (Mo-Su), phone, website, and reviews
|
||||
- maps_get_directions: Calculate transit, walking, or driving routes with distance & duration
|
||||
- maps_render_static_map: Generate static map PNG images for Telegram visual previews
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import hashlib
|
||||
import argparse
|
||||
import requests
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
# Environment
|
||||
GOOGLE_MAPS_API_KEY = os.environ.get("GOOGLE_MAPS_API_KEY", "").strip() or os.environ.get("GOOGLE_PLACES_API_KEY", "").strip()
|
||||
DEFAULT_LOCATION = os.environ.get("MAPS_DEFAULT_LOCATION", "Regensburg, Germany")
|
||||
MEDIA_OUTPUT_DIR = os.environ.get("MAPS_MEDIA_DIR", "/tmp/openclaw_maps")
|
||||
|
||||
|
||||
def ensure_media_dir() -> str:
|
||||
"""Ensure output directory for static map images exists."""
|
||||
os.makedirs(MEDIA_OUTPUT_DIR, exist_ok=True)
|
||||
return MEDIA_OUTPUT_DIR
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Google Maps Web Services
|
||||
# ==============================================================================
|
||||
|
||||
def geocode_address(address: str, api_key: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||
"""Geocode an address to lat/lng."""
|
||||
key = api_key or GOOGLE_MAPS_API_KEY
|
||||
if not key:
|
||||
return None
|
||||
url = "https://maps.googleapis.com/maps/api/geocode/json"
|
||||
params = {"address": address, "key": key, "language": "de"}
|
||||
try:
|
||||
resp = requests.get(url, params=params, timeout=10)
|
||||
data = resp.json()
|
||||
if data.get("status") == "OK" and data.get("results"):
|
||||
first = data["results"][0]
|
||||
loc = first["geometry"]["location"]
|
||||
return {
|
||||
"lat": loc["lat"],
|
||||
"lng": loc["lng"],
|
||||
"formatted_address": first.get("formatted_address"),
|
||||
"place_id": first.get("place_id"),
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def search_places(
|
||||
query: str,
|
||||
location: Optional[str] = None,
|
||||
radius_meters: int = 5000,
|
||||
open_now: bool = False,
|
||||
api_key: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Search places using Google Places API (New)."""
|
||||
key = api_key or GOOGLE_MAPS_API_KEY
|
||||
if not key:
|
||||
return {"success": False, "error": "GOOGLE_MAPS_API_KEY is not set.", "places": []}
|
||||
|
||||
url = "https://places.googleapis.com/v1/places:searchText"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Goog-Api-Key": key,
|
||||
"X-Goog-FieldMask": (
|
||||
"places.id,places.displayName,places.formattedAddress,places.location,"
|
||||
"places.rating,places.userRatingCount,places.currentOpeningHours,"
|
||||
"places.nationalPhoneNumber,places.googleMapsUri,places.priceLevel,places.primaryType"
|
||||
),
|
||||
}
|
||||
|
||||
body: Dict[str, Any] = {
|
||||
"textQuery": query,
|
||||
"languageCode": "de",
|
||||
"maxResultCount": 8,
|
||||
}
|
||||
|
||||
if open_now:
|
||||
body["openNow"] = True
|
||||
|
||||
loc_str = location or DEFAULT_LOCATION
|
||||
geo = geocode_address(loc_str, key)
|
||||
if geo:
|
||||
body["locationBias"] = {
|
||||
"circle": {
|
||||
"center": {"latitude": geo["lat"], "longitude": geo["lng"]},
|
||||
"radius": float(radius_meters),
|
||||
}
|
||||
}
|
||||
|
||||
try:
|
||||
resp = requests.post(url, headers=headers, json=body, timeout=10)
|
||||
if resp.status_code != 200:
|
||||
return {"success": False, "error": f"Places API returned {resp.status_code}: {resp.text}", "places": []}
|
||||
|
||||
data = resp.json()
|
||||
raw_places = data.get("places", [])
|
||||
places = []
|
||||
|
||||
for p in raw_places:
|
||||
opening = p.get("currentOpeningHours", {})
|
||||
open_status = "Offen" if opening.get("openNow") else ("Geschlossen" if "openNow" in opening else "Keine Angabe")
|
||||
descriptions = opening.get("weekdayDescriptions", [])
|
||||
|
||||
places.append({
|
||||
"place_id": p.get("id"),
|
||||
"name": p.get("displayName", {}).get("text", "Unbekannt"),
|
||||
"address": p.get("formattedAddress", ""),
|
||||
"rating": p.get("rating"),
|
||||
"user_ratings_total": p.get("userRatingCount"),
|
||||
"open_now": opening.get("openNow"),
|
||||
"status_text": open_status,
|
||||
"today_hours": descriptions[0] if descriptions else None,
|
||||
"phone": p.get("nationalPhoneNumber"),
|
||||
"maps_url": p.get("googleMapsUri"),
|
||||
"category": p.get("primaryType"),
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"query": query,
|
||||
"count": len(places),
|
||||
"location_bias": loc_str if geo else None,
|
||||
"places": places,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": f"Place search failed: {str(e)}", "places": []}
|
||||
|
||||
|
||||
def get_place_details(place_id: str, api_key: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Get comprehensive place details including weekly schedule and reviews."""
|
||||
key = api_key or GOOGLE_MAPS_API_KEY
|
||||
if not key:
|
||||
return {"success": False, "error": "GOOGLE_MAPS_API_KEY is not set."}
|
||||
|
||||
# Ensure place_id format (v1 API supports raw ID or places/ID)
|
||||
p_id = place_id.replace("places/", "")
|
||||
url = f"https://places.googleapis.com/v1/places/{p_id}"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Goog-Api-Key": key,
|
||||
"X-Goog-FieldMask": (
|
||||
"id,displayName,formattedAddress,currentOpeningHours,regularOpeningHours,"
|
||||
"nationalPhoneNumber,internationalPhoneNumber,websiteUri,googleMapsUri,"
|
||||
"rating,userRatingCount,reviews,accessibilityOptions,priceLevel"
|
||||
),
|
||||
}
|
||||
params = {"languageCode": "de"}
|
||||
|
||||
try:
|
||||
resp = requests.get(url, headers=headers, params=params, timeout=10)
|
||||
if resp.status_code != 200:
|
||||
return {"success": False, "error": f"Places API returned {resp.status_code}: {resp.text}"}
|
||||
|
||||
p = resp.json()
|
||||
current_hours = p.get("currentOpeningHours", {})
|
||||
regular_hours = p.get("regularOpeningHours", {})
|
||||
schedule = regular_hours.get("weekdayDescriptions") or current_hours.get("weekdayDescriptions") or []
|
||||
|
||||
reviews_summary = []
|
||||
for rev in (p.get("reviews", []) or [])[:3]:
|
||||
reviews_summary.append({
|
||||
"author": rev.get("authorAttribution", {}).get("displayName"),
|
||||
"rating": rev.get("rating"),
|
||||
"text": rev.get("text", {}).get("text", "")[:300],
|
||||
"publish_time": rev.get("relativePublishTimeDescription"),
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"place_id": p_id,
|
||||
"name": p.get("displayName", {}).get("text", "Unbekannt"),
|
||||
"address": p.get("formattedAddress", ""),
|
||||
"rating": p.get("rating"),
|
||||
"user_ratings_total": p.get("userRatingCount"),
|
||||
"open_now": current_hours.get("openNow"),
|
||||
"weekly_hours": schedule,
|
||||
"phone": p.get("nationalPhoneNumber") or p.get("internationalPhoneNumber"),
|
||||
"website": p.get("websiteUri"),
|
||||
"maps_url": p.get("googleMapsUri"),
|
||||
"accessibility": p.get("accessibilityOptions", {}),
|
||||
"recent_reviews": reviews_summary,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": f"Place details fetch failed: {str(e)}"}
|
||||
|
||||
|
||||
def get_directions(
|
||||
origin: str,
|
||||
destination: str,
|
||||
travel_mode: str = "DRIVE",
|
||||
api_key: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Calculate route directions, distance, and duration using Google Routes API v2."""
|
||||
key = api_key or GOOGLE_MAPS_API_KEY
|
||||
if not key:
|
||||
return {"success": False, "error": "GOOGLE_MAPS_API_KEY is not set."}
|
||||
|
||||
# Normalize travel mode
|
||||
mode_map = {
|
||||
"drive": "DRIVE",
|
||||
"driving": "DRIVE",
|
||||
"transit": "TRANSIT",
|
||||
"public_transport": "TRANSIT",
|
||||
"walk": "WALK",
|
||||
"walking": "WALK",
|
||||
"bicycle": "BICYCLE",
|
||||
"bicycling": "BICYCLE",
|
||||
"bike": "BICYCLE",
|
||||
}
|
||||
norm_mode = mode_map.get(travel_mode.lower(), "DRIVE")
|
||||
|
||||
url = "https://routes.googleapis.com/directions/v2:computeRoutes"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Goog-Api-Key": key,
|
||||
"X-Goog-FieldMask": (
|
||||
"routes.duration,routes.distanceMeters,routes.description,"
|
||||
"routes.legs.duration,routes.legs.distanceMeters,routes.legs.steps"
|
||||
),
|
||||
}
|
||||
|
||||
body = {
|
||||
"origin": {"address": origin},
|
||||
"destination": {"address": destination},
|
||||
"travelMode": norm_mode,
|
||||
"languageCode": "de",
|
||||
"units": "METRIC",
|
||||
}
|
||||
|
||||
try:
|
||||
resp = requests.post(url, headers=headers, json=body, timeout=12)
|
||||
if resp.status_code != 200:
|
||||
return {"success": False, "error": f"Routes API returned {resp.status_code}: {resp.text}"}
|
||||
|
||||
data = resp.json()
|
||||
routes = data.get("routes", [])
|
||||
if not routes:
|
||||
return {
|
||||
"success": False,
|
||||
"origin": origin,
|
||||
"destination": destination,
|
||||
"error": "No route found between origin and destination.",
|
||||
}
|
||||
|
||||
primary = routes[0]
|
||||
dist_m = primary.get("distanceMeters", 0)
|
||||
dur_str = primary.get("duration", "0s").rstrip("s")
|
||||
dur_sec = int(dur_str) if dur_str.isdigit() else 0
|
||||
|
||||
dist_km = round(dist_m / 1000.0, 1)
|
||||
dur_min = round(dur_sec / 60)
|
||||
|
||||
# Step summary
|
||||
steps_summary = []
|
||||
for leg in primary.get("legs", []):
|
||||
for step in leg.get("steps", [])[:8]:
|
||||
instr = step.get("navigationInstruction", {}).get("instructions")
|
||||
dist_step = step.get("distanceMeters", 0)
|
||||
if instr:
|
||||
steps_summary.append(f"{instr} ({dist_step}m)")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"origin": origin,
|
||||
"destination": destination,
|
||||
"travel_mode": norm_mode,
|
||||
"distance_meters": dist_m,
|
||||
"distance_km": dist_km,
|
||||
"duration_seconds": dur_sec,
|
||||
"duration_minutes": dur_min,
|
||||
"duration_formatted": f"{dur_min} Min." if dur_min < 60 else f"{dur_min // 60} Std. {dur_min % 60} Min.",
|
||||
"description": primary.get("description", ""),
|
||||
"steps_overview": steps_summary,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": f"Directions calculation failed: {str(e)}"}
|
||||
|
||||
|
||||
def render_static_map(
|
||||
center_or_address: str,
|
||||
zoom: int = 15,
|
||||
markers: Optional[List[str]] = None,
|
||||
size: str = "600x400",
|
||||
maptype: str = "roadmap",
|
||||
api_key: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Download a static map PNG image and save to local media directory."""
|
||||
key = api_key or GOOGLE_MAPS_API_KEY
|
||||
if not key:
|
||||
return {"success": False, "error": "GOOGLE_MAPS_API_KEY is not set."}
|
||||
|
||||
url = "https://maps.googleapis.com/maps/api/staticmap"
|
||||
params: Dict[str, Any] = {
|
||||
"center": center_or_address,
|
||||
"zoom": zoom,
|
||||
"size": size,
|
||||
"maptype": maptype,
|
||||
"key": key,
|
||||
"format": "png",
|
||||
}
|
||||
|
||||
# Add markers
|
||||
marker_list = markers or [center_or_address]
|
||||
# Format: color:red|label:A|location
|
||||
params["markers"] = f"color:red|{marker_list[0]}"
|
||||
|
||||
try:
|
||||
resp = requests.get(url, params=params, timeout=12)
|
||||
if resp.status_code != 200:
|
||||
return {"success": False, "error": f"Static Maps API returned {resp.status_code}: {resp.text}"}
|
||||
|
||||
out_dir = ensure_media_dir()
|
||||
h = hashlib.md5(f"{center_or_address}_{zoom}_{size}".encode()).hexdigest()[:10]
|
||||
filename = f"map_{h}.png"
|
||||
filepath = os.path.join(out_dir, filename)
|
||||
|
||||
with open(filepath, "wb") as f:
|
||||
f.write(resp.content)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"center": center_or_address,
|
||||
"zoom": zoom,
|
||||
"file_path": filepath,
|
||||
"file_name": filename,
|
||||
"media_uri": f"MEDIA:{filepath}",
|
||||
"file_size_bytes": len(resp.content),
|
||||
}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": f"Static map generation failed: {str(e)}"}
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# MCP Protocol Handlers (JSON-RPC 2.0 stdio)
|
||||
# ==============================================================================
|
||||
|
||||
TOOLS_DEFINITIONS = [
|
||||
{
|
||||
"name": "maps_search_places",
|
||||
"description": "Search local places, venues, shops, restaurants, bakeries, or doctors using Google Places API (New). Returns names, addresses, live opening hours (open now?), ratings, phone numbers, and Google Maps URLs.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["query"],
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "What to search for (e.g. 'Bäckerei Stadtamhof', 'Spielplatz mit Schatten', 'Kinderarzt Prüfening').",
|
||||
},
|
||||
"location": {
|
||||
"type": "string",
|
||||
"description": "City or neighborhood to bias search around (default 'Regensburg, Germany').",
|
||||
"default": "Regensburg, Germany",
|
||||
},
|
||||
"radius_meters": {
|
||||
"type": "integer",
|
||||
"description": "Search radius in meters (default 5000).",
|
||||
"default": 5000,
|
||||
},
|
||||
"open_now": {
|
||||
"type": "boolean",
|
||||
"description": "Filter strictly to places currently open right now.",
|
||||
"default": False,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "maps_get_place_details",
|
||||
"description": "Get detailed schedule (opening hours Monday through Sunday), contact info, website, wheelchair accessibility, and recent reviews for a specific place ID.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["place_id"],
|
||||
"properties": {
|
||||
"place_id": {
|
||||
"type": "string",
|
||||
"description": "The Google place ID returned from maps_search_places.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "maps_get_directions",
|
||||
"description": "Compute route directions, travel time, and distance between two locations with traffic estimates.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["origin", "destination"],
|
||||
"properties": {
|
||||
"origin": {
|
||||
"type": "string",
|
||||
"description": "Starting address or place name (e.g. 'Grabengasse 7, 93059 Regensburg').",
|
||||
},
|
||||
"destination": {
|
||||
"type": "string",
|
||||
"description": "Target address or place name (e.g. 'Regensburg Hauptbahnhof', 'Westbad Regensburg').",
|
||||
},
|
||||
"travel_mode": {
|
||||
"type": "string",
|
||||
"description": "Mode of transit: 'DRIVE' (auto), 'TRANSIT' (bus/train), 'WALK' (foot), 'BICYCLE' (bike). Default 'DRIVE'.",
|
||||
"enum": ["DRIVE", "TRANSIT", "WALK", "BICYCLE"],
|
||||
"default": "DRIVE",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "maps_render_static_map",
|
||||
"description": "Generate a static satellite or roadmap PNG image of a location or route to send directly to Telegram as a visual photo card.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["center_or_address"],
|
||||
"properties": {
|
||||
"center_or_address": {
|
||||
"type": "string",
|
||||
"description": "Address or location to center the map on.",
|
||||
},
|
||||
"zoom": {
|
||||
"type": "integer",
|
||||
"description": "Zoom level (1-20). 15 is street/neighborhood level, 12 is city level.",
|
||||
"default": 15,
|
||||
},
|
||||
"size": {
|
||||
"type": "string",
|
||||
"description": "Image dimensions in pixels (default '600x400').",
|
||||
"default": "600x400",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def run_mcp_server():
|
||||
"""Run JSON-RPC 2.0 stdio loop."""
|
||||
while True:
|
||||
try:
|
||||
line = sys.stdin.readline()
|
||||
if not line:
|
||||
break
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
req = json.loads(line)
|
||||
req_id = req.get("id")
|
||||
method = req.get("method")
|
||||
params = req.get("params", {})
|
||||
|
||||
if method == "initialize":
|
||||
res = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
"result": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {"tools": {}},
|
||||
"serverInfo": {
|
||||
"name": "openclaw-google-maps",
|
||||
"version": "1.0.0",
|
||||
},
|
||||
},
|
||||
}
|
||||
print(json.dumps(res), flush=True)
|
||||
|
||||
elif method == "notifications/initialized":
|
||||
pass
|
||||
|
||||
elif method == "tools/list":
|
||||
res = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
"result": {"tools": TOOLS_DEFINITIONS},
|
||||
}
|
||||
print(json.dumps(res), flush=True)
|
||||
|
||||
elif method == "tools/call":
|
||||
tool_name = params.get("name")
|
||||
args = params.get("arguments", {})
|
||||
content = []
|
||||
|
||||
if tool_name == "maps_search_places":
|
||||
result = search_places(
|
||||
query=args.get("query", ""),
|
||||
location=args.get("location"),
|
||||
radius_meters=args.get("radius_meters", 5000),
|
||||
open_now=args.get("open_now", False),
|
||||
)
|
||||
content.append({"type": "text", "text": json.dumps(result, indent=2, ensure_ascii=False)})
|
||||
|
||||
elif tool_name == "maps_get_place_details":
|
||||
result = get_place_details(place_id=args.get("place_id", ""))
|
||||
content.append({"type": "text", "text": json.dumps(result, indent=2, ensure_ascii=False)})
|
||||
|
||||
elif tool_name == "maps_get_directions":
|
||||
result = get_directions(
|
||||
origin=args.get("origin", ""),
|
||||
destination=args.get("destination", ""),
|
||||
travel_mode=args.get("travel_mode", "DRIVE"),
|
||||
)
|
||||
content.append({"type": "text", "text": json.dumps(result, indent=2, ensure_ascii=False)})
|
||||
|
||||
elif tool_name == "maps_render_static_map":
|
||||
result = render_static_map(
|
||||
center_or_address=args.get("center_or_address", ""),
|
||||
zoom=args.get("zoom", 15),
|
||||
size=args.get("size", "600x400"),
|
||||
)
|
||||
content.append({"type": "text", "text": json.dumps(result, indent=2, ensure_ascii=False)})
|
||||
|
||||
else:
|
||||
content.append({"type": "text", "text": f"Error: Unknown tool '{tool_name}'"})
|
||||
|
||||
res = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
"result": {"content": content},
|
||||
}
|
||||
print(json.dumps(res), flush=True)
|
||||
|
||||
else:
|
||||
res = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
"error": {"code": -32601, "message": f"Method '{method}' not found"},
|
||||
}
|
||||
print(json.dumps(res), flush=True)
|
||||
|
||||
except Exception as e:
|
||||
err_res = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": None,
|
||||
"error": {"code": -32603, "message": f"Internal error: {str(e)}"},
|
||||
}
|
||||
print(json.dumps(err_res), flush=True)
|
||||
|
||||
|
||||
def run_cli():
|
||||
"""CLI testing interface."""
|
||||
parser = argparse.ArgumentParser(description="Google Maps MCP Server & CLI Tool")
|
||||
subparsers = parser.add_subparsers(dest="command", help="Subcommand")
|
||||
|
||||
# Places
|
||||
p_places = subparsers.add_parser("places", help="Search places")
|
||||
p_places.add_argument("query", help="Search query")
|
||||
p_places.add_argument("--location", "-l", default="Regensburg, Germany", help="Location bias")
|
||||
p_places.add_argument("--radius", "-r", type=int, default=5000, help="Radius in meters")
|
||||
p_places.add_argument("--open-now", action="store_true", help="Only open now")
|
||||
|
||||
# Details
|
||||
p_det = subparsers.add_parser("details", help="Get place details")
|
||||
p_det.add_argument("place_id", help="Place ID")
|
||||
|
||||
# Directions
|
||||
p_dir = subparsers.add_parser("directions", help="Get route directions")
|
||||
p_dir.add_argument("origin", help="Origin address")
|
||||
p_dir.add_argument("destination", help="Destination address")
|
||||
p_dir.add_argument("--mode", "-m", choices=["DRIVE", "TRANSIT", "WALK", "BICYCLE"], default="DRIVE")
|
||||
|
||||
# Static map
|
||||
p_static = subparsers.add_parser("static", help="Render static map")
|
||||
p_static.add_argument("center", help="Center address")
|
||||
p_static.add_argument("--zoom", "-z", type=int, default=15)
|
||||
p_static.add_argument("--size", "-s", default="600x400")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command == "places":
|
||||
res = search_places(query=args.query, location=args.location, radius_meters=args.radius, open_now=args.open_now)
|
||||
print(json.dumps(res, indent=2, ensure_ascii=False))
|
||||
elif args.command == "details":
|
||||
res = get_place_details(place_id=args.place_id)
|
||||
print(json.dumps(res, indent=2, ensure_ascii=False))
|
||||
elif args.command == "directions":
|
||||
res = get_directions(origin=args.origin, destination=args.destination, travel_mode=args.mode)
|
||||
print(json.dumps(res, indent=2, ensure_ascii=False))
|
||||
elif args.command == "static":
|
||||
res = render_static_map(center_or_address=args.center, zoom=args.zoom, size=args.size)
|
||||
print(json.dumps(res, indent=2, ensure_ascii=False))
|
||||
else:
|
||||
parser.print_help()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) == 1 or (len(sys.argv) == 2 and sys.argv[1] == "--stdio"):
|
||||
run_mcp_server()
|
||||
else:
|
||||
run_cli()
|
||||
@@ -0,0 +1,97 @@
|
||||
---
|
||||
name: grill-me
|
||||
description: Relentless, structured interview to stress-test and sharpen plans, architectures, decisions, or ideas before writing code. Maps decisions into a dependency tree and walks the frontier in focused rounds with recommended answers.
|
||||
compatibility: Claude Code, OpenClaw, Antigravity (agy), Gemini CLI
|
||||
---
|
||||
|
||||
# Grill-Me Skill 🎯🔥
|
||||
|
||||
Transform from a passive assistant into a relentless, high-bandwidth interviewer. Your mission is to stress-test the user's plan, surface hidden assumptions, uncover neglected edge cases, and resolve unmade decisions **before** any implementation begins.
|
||||
|
||||
---
|
||||
|
||||
## When to Activate
|
||||
|
||||
Trigger this skill whenever:
|
||||
- The user invokes `/grill-me`, `/grilling`, or asks to "grill me", "stress test this plan", "grill mich", or "hinterfrage meinen Plan".
|
||||
- The user presents a loose, ambiguous, or high-stakes architectural plan, feature idea, or migration and wants clarity before proceeding.
|
||||
|
||||
---
|
||||
|
||||
## Core Principles
|
||||
|
||||
### 1. The Design Tree & The Frontier
|
||||
- Treat any proposal as a **decision tree**: root choices branch into downstream dependencies.
|
||||
- Identify the **frontier**: the subset of decisions whose prerequisites are already settled. Only ask questions at the frontier—never ask questions that hinge on answers not yet determined.
|
||||
- As the user answers, recompute the frontier. Decisions made push the frontier outward and unblock downstream branches.
|
||||
|
||||
### 2. Facts vs. Decisions
|
||||
- **Finding facts is YOUR job, never the user's.**
|
||||
- If a question can be answered by exploring the workspace, inspecting git commits, checking configuration files, querying APIs, or searching docs, **do that exploration first**.
|
||||
- Never interrupt or ask the user for information you could discover yourself.
|
||||
- **Making decisions is the USER's job.**
|
||||
- When a fundamental architectural choice, trade-off, or priority is at stake, put the question clearly to the user.
|
||||
|
||||
### 3. Always Provide a Recommended Answer (`➡️`)
|
||||
- Never leave a question as an open-ended blank canvas.
|
||||
- For every question, include a concrete, reasoned recommendation (`➡️`) reflecting engineering best practices (simplicity, reversibility, maintainability, performance).
|
||||
- The user can then agree with a single keystroke/number, pick an alternative, or provide counter-arguments.
|
||||
|
||||
### 4. Anti-Sycophancy & Pushing Back
|
||||
- **Do not nod along.** A grilling session with zero disagreement is a failed session.
|
||||
- Challenge hand-waving, vague requirements, and "TBDs".
|
||||
- If the user provides a contradictory or overly complex answer, actively challenge the trade-off.
|
||||
- If the user says "I don't know", treat it as a signal: suggest a safe default, or flag the item as a candidate for a quick spike/prototype.
|
||||
|
||||
### 5. Identify "Ungrillable" Questions
|
||||
- Some questions cannot be resolved through dialogue alone (e.g. subjective UX feel, exact latency under real traffic, animation timing).
|
||||
- When encountering an ungrillable question, do not circle in place: flag it explicitly as requiring a prototype/benchmark and move the rest of the tree forward.
|
||||
|
||||
---
|
||||
|
||||
## Interview Mechanics & Round Sizing
|
||||
|
||||
### Adaptive Batching (Telegram & Mobile Friendly)
|
||||
In chat and messaging interfaces (such as OpenClaw over Telegram):
|
||||
- **Limit rounds to 1–3 focused questions** at a time to prevent cognitive overload.
|
||||
- Ensure every question in the round is mutually independent (neither depends on the other).
|
||||
- Use distinct numbers so the user can reply rapidly from mobile (e.g., `1: Ja, 2: Variante B, 3: Nein weil...`).
|
||||
|
||||
### Question Format
|
||||
Format each round cleanly using the following pattern:
|
||||
|
||||
```markdown
|
||||
❓ **Q1 - [Prägnanter Titel / Question Title]**
|
||||
[Detaillierter Kontext, Optionen und Implikationen]
|
||||
|
||||
➡️ **Empfehlung:** [Konkreter Vorschlag mit technischer Begründung]
|
||||
|
||||
---
|
||||
|
||||
❓ **Q2 - [Prägnanter Titel / Question Title]**
|
||||
[Detaillierter Kontext, Optionen und Implikationen]
|
||||
|
||||
➡️ **Empfehlung:** [Konkreter Vorschlag mit technischer Begründung]
|
||||
```
|
||||
|
||||
### Formatting Rules for Messaging & Telegram
|
||||
- **NO RAW HTML:** Never use raw HTML tags (`<b>`, `<strong>`, `<a>`, `<tag>`). In Telegram and chat clients, unescaped tags break or render as literal text.
|
||||
- **Pure Markdown:** Use standard Markdown (`**bold**`, `*italic*`, `[Link-Text](url)`).
|
||||
- **Bilingual Agility:** Adapt seamlessly to the user's language (German or English). Maintain the same language the user initiated the session with.
|
||||
|
||||
---
|
||||
|
||||
## Definition of Done & Outcome Synthesis
|
||||
|
||||
The grilling session is complete **only** when:
|
||||
1. The decision frontier is empty (no unaddressed branches or hidden assumptions remain).
|
||||
2. You ask the user for explicit confirmation that a **shared understanding** has been reached.
|
||||
3. **DO NOT start implementing automatically.**
|
||||
|
||||
### Final Deliverable: The Specification Summary
|
||||
Once the user confirms shared understanding, synthesize a crisp **Specification Summary** (or Markdown ADR / Implementation Plan) containing:
|
||||
- **Core Objective & Scope Boundaries:** What is being solved, and explicitly what is out-of-scope.
|
||||
- **Decided Architecture & Key Decisions:** Key choices made during the interview with their rationale.
|
||||
- **Identified Risks & Mitigation:** Edge cases or failure modes identified and how they will be handled.
|
||||
- **Ungrillable Items / Spikes:** Any points deferred to empirical prototyping.
|
||||
- **Step-by-Step Action Plan:** Concrete, verifiable implementation steps.
|
||||
@@ -0,0 +1,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.
|
||||
Executable
+158
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# dependencies = [
|
||||
# "boto3>=1.34.0",
|
||||
# ]
|
||||
# ///
|
||||
"""
|
||||
Publish files and static HTML documents to public S3 hosting at hosting.haumdaucher.de.
|
||||
Organizes artifacts under /{category}/{YYYYMMDD}-{slug}.{ext}.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import boto3
|
||||
from botocore.client import Config
|
||||
|
||||
# Configuration defaults
|
||||
DEFAULT_ENDPOINT = os.environ.get("MINIO_ENDPOINT", "http://minio.minio.svc.cluster.local:9000")
|
||||
DEFAULT_ACCESS_KEY = os.environ.get("MINIO_ACCESS_KEY", "openclaw")
|
||||
DEFAULT_SECRET_KEY = os.environ.get("MINIO_SECRET_KEY", "5uu23t69xC2Oigf8Bky0KTlI")
|
||||
DEFAULT_BUCKET = os.environ.get("MINIO_BUCKET", "hosting")
|
||||
DEFAULT_BASE_URL = os.environ.get("HOSTING_BASE_URL", "https://hosting.haumdaucher.de").rstrip("/")
|
||||
|
||||
MIME_OVERRIDES = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".htm": "text/html; charset=utf-8",
|
||||
".md": "text/markdown; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".pdf": "application/pdf",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".webp": "image/webp",
|
||||
".svg": "image/svg+xml",
|
||||
".txt": "text/plain; charset=utf-8",
|
||||
}
|
||||
|
||||
def get_s3_client():
|
||||
if not DEFAULT_SECRET_KEY:
|
||||
raise ValueError("MINIO_SECRET_KEY environment variable is not configured.")
|
||||
|
||||
return boto3.client(
|
||||
"s3",
|
||||
endpoint_url=DEFAULT_ENDPOINT,
|
||||
aws_access_key_id=DEFAULT_ACCESS_KEY,
|
||||
aws_secret_access_key=DEFAULT_SECRET_KEY,
|
||||
config=Config(signature_version="s3v4"),
|
||||
region_name="us-east-1",
|
||||
)
|
||||
|
||||
def slugify(text: str) -> str:
|
||||
"""Converts raw string to URL-safe slug."""
|
||||
text = text.strip().lower()
|
||||
text = re.sub(r"[^\w\s-]", "", text)
|
||||
text = re.sub(r"[\s_-]+", "-", text)
|
||||
return text.strip("-") or "document"
|
||||
|
||||
def detect_mime(ext: str) -> str:
|
||||
ext_lower = ext.lower()
|
||||
if ext_lower in MIME_OVERRIDES:
|
||||
return MIME_OVERRIDES[ext_lower]
|
||||
guessed, _ = mimetypes.guess_type(f"file{ext_lower}")
|
||||
return guessed or "application/octet-stream"
|
||||
|
||||
def upload_artifact(file_path: str = None, content: str = None, category: str = "research", slug: str = None, ext: str = None) -> dict:
|
||||
s3 = get_s3_client()
|
||||
now_str = datetime.datetime.now().strftime("%Y%m%d")
|
||||
|
||||
# Determine file data and extension
|
||||
if file_path:
|
||||
p = Path(file_path)
|
||||
if not p.exists():
|
||||
raise FileNotFoundError(f"File not found: {file_path}")
|
||||
with open(p, "rb") as f:
|
||||
data = f.read()
|
||||
chosen_ext = (ext or p.suffix).lstrip(".")
|
||||
chosen_slug = slug or slugify(p.stem)
|
||||
elif content is not None:
|
||||
data = content.encode("utf-8")
|
||||
chosen_ext = (ext or "html").lstrip(".")
|
||||
chosen_slug = slug or f"doc-{datetime.datetime.now().strftime('%H%M%S')}"
|
||||
else:
|
||||
raise ValueError("Either --file or --content must be specified.")
|
||||
|
||||
chosen_slug = slugify(chosen_slug)
|
||||
category_slug = slugify(category)
|
||||
|
||||
# Construct standard object key: {category}/{YYYYMMDD}-{slug}.{ext}
|
||||
filename = f"{now_str}-{chosen_slug}.{chosen_ext}"
|
||||
object_key = f"{category_slug}/{filename}"
|
||||
|
||||
content_type = detect_mime(f".{chosen_ext}")
|
||||
|
||||
s3.put_object(
|
||||
Bucket=DEFAULT_BUCKET,
|
||||
Key=object_key,
|
||||
Body=data,
|
||||
ContentType=content_type,
|
||||
)
|
||||
|
||||
public_url = f"{DEFAULT_BASE_URL}/{object_key}"
|
||||
|
||||
return {
|
||||
"url": public_url,
|
||||
"key": object_key,
|
||||
"category": category_slug,
|
||||
"filename": filename,
|
||||
"content_type": content_type,
|
||||
"bytes": len(data),
|
||||
}
|
||||
|
||||
def delete_artifact(object_key: str):
|
||||
s3 = get_s3_client()
|
||||
s3.delete_object(Bucket=DEFAULT_BUCKET, Key=object_key)
|
||||
print(f"Deleted: {object_key}")
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Publish files to hosting.haumdaucher.de.")
|
||||
parser.add_argument("--file", "-f", help="Local file path to upload")
|
||||
parser.add_argument("--content", "-c", help="Inline text or HTML string to publish")
|
||||
parser.add_argument("--category", default="research", help="Category folder (e.g. research, recipes, images, docs)")
|
||||
parser.add_argument("--slug", "-s", help="Readable slug identifier for the document")
|
||||
parser.add_argument("--ext", help="File extension override (e.g. html, pdf, png)")
|
||||
parser.add_argument("--delete", help="Delete an object key from the hosting bucket")
|
||||
parser.add_argument("--url-only", action="store_true", help="Print only the public URL to stdout")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.delete:
|
||||
delete_artifact(args.delete)
|
||||
return
|
||||
|
||||
if not args.file and args.content is None:
|
||||
parser.error("You must provide either --file or --content.")
|
||||
|
||||
result = upload_artifact(
|
||||
file_path=args.file,
|
||||
content=args.content,
|
||||
category=args.category,
|
||||
slug=args.slug,
|
||||
ext=args.ext,
|
||||
)
|
||||
|
||||
if args.url_only:
|
||||
print(result["url"])
|
||||
else:
|
||||
print(f"URL: {result['url']}")
|
||||
print(f"KEY: {result['key']}")
|
||||
print(f"TYPE: {result['content_type']}")
|
||||
print(f"SIZE: {result['bytes']} bytes")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
name: jelly-media
|
||||
description: Seamless Jellyfin streaming and Jellyseerr media discovery & requests. Use when the user asks to search for movies/series, discover recommendations (e.g. "a cool new action movie"), get stream or direct download URLs, request media via Jellyseerr, control active playback on connected devices (TVs), or check what to resume watching.
|
||||
---
|
||||
|
||||
# 🎬 Jelly Media (Jellyfin & Jellyseerr)
|
||||
|
||||
Full integration with **Jellyfin** (media streaming, direct downloads, active sessions, resume points) and **Jellyseerr** (TMDB search, intelligent discovery, download requests).
|
||||
|
||||
## 🛠️ MCP Tools & Capabilities
|
||||
|
||||
You have access to 8 dedicated MCP tools:
|
||||
|
||||
1. `jelly_search(query, media_type)`:
|
||||
- Searches Jellyseerr and cross-references against your personal Jellyfin library.
|
||||
- Categorizes every match as:
|
||||
- 🟢 **AVAILABLE**: Ready in Jellyfin right now (includes web playback, stream, and direct download links).
|
||||
- 🟡 **PROCESSING**: Already requested in Jellyseerr and downloading/importing.
|
||||
- ⚪ **NOT_AVAILABLE**: Not in the library yet; can be requested with one tap.
|
||||
- Automatically ranks **AVAILABLE** items on top.
|
||||
|
||||
2. `jelly_discover(genre, min_rating, year, sort_by, only_available, media_type)`:
|
||||
- Natural language discovery for queries like: *"a cool new action movie with good ratings"*.
|
||||
- Filter by genre (`action`, `comedy`, `sci-fi`, `thriller`, `drama`, etc.), `min_rating` (e.g. 7.0), and `year`.
|
||||
- Prioritizes items already downloaded on Jellyfin.
|
||||
|
||||
3. `jelly_get_playback(item_id)`:
|
||||
- Returns links for a specific Jellyfin item:
|
||||
- Web client URL (opens player/details in Jellyfin web or app)
|
||||
- Direct HTTP stream URL (plays directly in VLC, Infuse, MPV, Safari)
|
||||
- Direct offline download URL (original media file for travel/offline)
|
||||
|
||||
4. `jelly_request(media_id, media_type, seasons)`:
|
||||
- Submits a request to Jellyseerr under user account `mop`.
|
||||
- Supports movies or specific TV seasons.
|
||||
|
||||
5. `jelly_list_sessions()`:
|
||||
- Lists active devices connected to Jellyfin (e.g. Living Room TV, iPad, Web browser, Apple TV).
|
||||
|
||||
6. `jelly_remote_play(session_id, item_id)`:
|
||||
- Triggers playback of a movie or episode directly on an active device.
|
||||
|
||||
7. `jelly_get_resume()`:
|
||||
- Fetches what the user is currently watching with resume percentage, minutes elapsed, and direct stream links.
|
||||
|
||||
8. `jelly_get_latest(limit)`:
|
||||
- Returns newly added movies and series in the library.
|
||||
|
||||
---
|
||||
|
||||
## 📱 Telegram Presentation Guidelines
|
||||
|
||||
When formatting recommendations or search results for Telegram:
|
||||
- **Badge Availability Clearly**:
|
||||
- 🟢 **Verfügbar** (Sofort streamen & downloaden)
|
||||
- 🟡 **In Bearbeitung** (Bereits angefordert)
|
||||
- ⚪ **Verfügbar auf Anfrage** (Kann in Jellyseerr angefordert werden)
|
||||
- **Include Direct Links**:
|
||||
- `[▶️ In Jellyfin ansehen](<web_details_url>)`
|
||||
- `[📥 Direkt-Download](<download_url>)`
|
||||
- `[📡 Stream-Link](<stream_url>)`
|
||||
- **Provide Essential Metadata**:
|
||||
- Title, Year, TMDB/Community rating (⭐), runtime, and a short 1-2 sentence synopsis.
|
||||
- **Requesting**:
|
||||
- If an item is NOT in the library, offer to request it: *"Möchtest du, dass ich das über Jellyseerr für dich anfordere?"*
|
||||
Executable
+786
@@ -0,0 +1,786 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Jelly Media MCP Server & CLI Tool
|
||||
Integrates Jellyfin and Jellyseerr with OpenClaw via MCP stdio or direct CLI commands.
|
||||
Supports username & password authentication with automatic session management and renewal.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import ssl
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
import urllib.error
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
# Default Configurations (Can be overridden via environment variables)
|
||||
JELLYFIN_URL = os.environ.get("JELLYFIN_URL", "https://stream.paschke.dev").rstrip("/")
|
||||
JELLYFIN_USERNAME = os.environ.get("JELLYFIN_USERNAME", "mop")
|
||||
JELLYFIN_PASSWORD = os.environ.get("JELLYFIN_PASSWORD", "Hintenlinks1#")
|
||||
|
||||
JELLYSEERR_URL = os.environ.get("JELLYSEERR_URL", "https://jellyseer.paschke.dev").rstrip("/")
|
||||
JELLYSEERR_USERNAME = os.environ.get("JELLYSEERR_USERNAME", "mop")
|
||||
JELLYSEERR_PASSWORD = os.environ.get("JELLYSEERR_PASSWORD", "Hintenlinks1#")
|
||||
|
||||
CACHE_DIR = os.environ.get("JELLY_CACHE_DIR", "/tmp")
|
||||
JF_CACHE_FILE = os.path.join(CACHE_DIR, "jellyfin_session.json")
|
||||
JS_CACHE_FILE = os.path.join(CACHE_DIR, "jellyseerr_session.json")
|
||||
|
||||
# SSL Context
|
||||
SSL_CTX = ssl.create_default_context()
|
||||
|
||||
GENRE_MAP = {
|
||||
"action": 28,
|
||||
"adventure": 12,
|
||||
"animation": 16,
|
||||
"comedy": 35,
|
||||
"crime": 80,
|
||||
"documentary": 99,
|
||||
"drama": 18,
|
||||
"family": 10751,
|
||||
"fantasy": 14,
|
||||
"history": 36,
|
||||
"horror": 27,
|
||||
"music": 10402,
|
||||
"mystery": 9648,
|
||||
"romance": 10749,
|
||||
"sci-fi": 878,
|
||||
"science fiction": 878,
|
||||
"thriller": 53,
|
||||
"war": 10752,
|
||||
"western": 37,
|
||||
}
|
||||
|
||||
|
||||
class JellyClient:
|
||||
def __init__(self):
|
||||
self.jf_token: Optional[str] = None
|
||||
self.jf_user_id: Optional[str] = None
|
||||
self.jf_server_id: Optional[str] = None
|
||||
self.js_cookie: Optional[str] = None
|
||||
self.load_cache()
|
||||
|
||||
def load_cache(self):
|
||||
if os.path.exists(JF_CACHE_FILE):
|
||||
try:
|
||||
with open(JF_CACHE_FILE, "r") as f:
|
||||
data = json.load(f)
|
||||
self.jf_token = data.get("token")
|
||||
self.jf_user_id = data.get("user_id")
|
||||
self.jf_server_id = data.get("server_id")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if os.path.exists(JS_CACHE_FILE):
|
||||
try:
|
||||
with open(JS_CACHE_FILE, "r") as f:
|
||||
data = json.load(f)
|
||||
self.js_cookie = data.get("cookie")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def save_cache(self):
|
||||
try:
|
||||
with open(JF_CACHE_FILE, "w") as f:
|
||||
json.dump({
|
||||
"token": self.jf_token,
|
||||
"user_id": self.jf_user_id,
|
||||
"server_id": self.jf_server_id,
|
||||
"timestamp": time.time()
|
||||
}, f)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
with open(JS_CACHE_FILE, "w") as f:
|
||||
json.dump({
|
||||
"cookie": self.js_cookie,
|
||||
"timestamp": time.time()
|
||||
}, f)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# --- JELLYFIN AUTH & REQUESTS ---
|
||||
def auth_jellyfin(self) -> bool:
|
||||
login_url = f"{JELLYFIN_URL}/Users/AuthenticateByName"
|
||||
auth_header = "MediaBrowser Client=\"OpenClaw\", Device=\"OpenClaw\", DeviceId=\"openclaw-bot-1\", Version=\"1.0.0\""
|
||||
payload = json.dumps({"Username": JELLYFIN_USERNAME, "Pw": JELLYFIN_PASSWORD}).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
login_url,
|
||||
data=payload,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": auth_header,
|
||||
"User-Agent": "OpenClaw/1.0"
|
||||
}
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, context=SSL_CTX, timeout=15) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
self.jf_token = data.get("AccessToken")
|
||||
self.jf_user_id = data.get("User", {}).get("Id")
|
||||
self.jf_server_id = data.get("ServerId")
|
||||
self.save_cache()
|
||||
return True
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"Jellyfin auth failed: {e}\n")
|
||||
return False
|
||||
|
||||
def request_jellyfin(self, path: str, method: str = "GET", payload: Optional[Dict[str, Any]] = None) -> Any:
|
||||
if not self.jf_token or not self.jf_user_id:
|
||||
if not self.auth_jellyfin():
|
||||
raise RuntimeError("Failed to authenticate with Jellyfin")
|
||||
|
||||
url = f"{JELLYFIN_URL}{path}"
|
||||
auth_header = (
|
||||
f"MediaBrowser Client=\"OpenClaw\", Device=\"OpenClaw\", DeviceId=\"openclaw-bot-1\", "
|
||||
f"Version=\"1.0.0\", Token=\"{self.jf_token}\""
|
||||
)
|
||||
data_bytes = json.dumps(payload).encode("utf-8") if payload else None
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=data_bytes,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": auth_header,
|
||||
"X-Emby-Token": self.jf_token or "",
|
||||
"User-Agent": "OpenClaw/1.0"
|
||||
},
|
||||
method=method
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, context=SSL_CTX, timeout=15) as resp:
|
||||
body = resp.read().decode("utf-8")
|
||||
return json.loads(body) if body else {}
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 401:
|
||||
# Token expired, retry once
|
||||
if self.auth_jellyfin():
|
||||
auth_header = (
|
||||
f"MediaBrowser Client=\"OpenClaw\", Device=\"OpenClaw\", DeviceId=\"openclaw-bot-1\", "
|
||||
f"Version=\"1.0.0\", Token=\"{self.jf_token}\""
|
||||
)
|
||||
req.headers["Authorization"] = auth_header
|
||||
req.headers["X-Emby-Token"] = self.jf_token or ""
|
||||
with urllib.request.urlopen(req, context=SSL_CTX, timeout=15) as resp:
|
||||
body = resp.read().decode("utf-8")
|
||||
return json.loads(body) if body else {}
|
||||
raise
|
||||
|
||||
# --- JELLYSEERR AUTH & REQUESTS ---
|
||||
def auth_jellyseerr(self) -> bool:
|
||||
login_url = f"{JELLYSEERR_URL}/api/v1/auth/jellyfin"
|
||||
payload = json.dumps({"username": JELLYSEERR_USERNAME, "password": JELLYSEERR_PASSWORD}).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
login_url,
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/json", "User-Agent": "OpenClaw/1.0"}
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, context=SSL_CTX, timeout=15) as resp:
|
||||
cookies = resp.info().get_all("Set-Cookie") or []
|
||||
for c in cookies:
|
||||
if "connect.sid=" in c:
|
||||
self.js_cookie = c.split(";")[0]
|
||||
break
|
||||
self.save_cache()
|
||||
return bool(self.js_cookie)
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"Jellyseerr auth failed: {e}\n")
|
||||
return False
|
||||
|
||||
def request_jellyseerr(self, path: str, method: str = "GET", payload: Optional[Dict[str, Any]] = None) -> Any:
|
||||
if not self.js_cookie:
|
||||
if not self.auth_jellyseerr():
|
||||
raise RuntimeError("Failed to authenticate with Jellyseerr")
|
||||
|
||||
url = f"{JELLYSEERR_URL}{path}"
|
||||
data_bytes = json.dumps(payload).encode("utf-8") if payload else None
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Cookie": self.js_cookie or "",
|
||||
"User-Agent": "OpenClaw/1.0"
|
||||
}
|
||||
req = urllib.request.Request(url, data=data_bytes, headers=headers, method=method)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, context=SSL_CTX, timeout=15) as resp:
|
||||
body = resp.read().decode("utf-8")
|
||||
return json.loads(body) if body else {}
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code in (401, 403):
|
||||
# Session expired, retry once
|
||||
if self.auth_jellyseerr():
|
||||
req.headers["Cookie"] = self.js_cookie or ""
|
||||
with urllib.request.urlopen(req, context=SSL_CTX, timeout=15) as resp:
|
||||
body = resp.read().decode("utf-8")
|
||||
return json.loads(body) if body else {}
|
||||
raise
|
||||
|
||||
# --- HIGH LEVEL TOOLS ---
|
||||
|
||||
def search_media(self, query: str, media_type: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
"""Search Jellyseerr and cross-reference with Jellyfin library."""
|
||||
results: List[Dict[str, Any]] = []
|
||||
|
||||
# 1. Search Jellyseerr
|
||||
encoded_query = urllib.parse.quote(query)
|
||||
js_data = self.request_jellyseerr(f"/api/v1/search?query={encoded_query}")
|
||||
raw_results = js_data.get("results", [])
|
||||
|
||||
# 2. Search Jellyfin directly to match any items in library
|
||||
jf_matches = {}
|
||||
try:
|
||||
encoded_jf = urllib.parse.quote(query)
|
||||
jf_items = self.request_jellyfin(
|
||||
f"/Users/{self.jf_user_id}/Items?searchTerm={encoded_jf}&Limit=20&Recursive=true&IncludeItemTypes=Movie,Series"
|
||||
).get("Items", [])
|
||||
for item in jf_items:
|
||||
name_clean = item.get("Name", "").strip().lower()
|
||||
jf_matches[name_clean] = item
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"Jellyfin search error: {e}\n")
|
||||
|
||||
for item in raw_results:
|
||||
mtype = item.get("mediaType")
|
||||
if media_type and media_type != "all" and mtype != media_type:
|
||||
continue
|
||||
|
||||
title = item.get("title") or item.get("name") or "Unknown"
|
||||
year = (item.get("releaseDate") or item.get("firstAirDate") or "")[:4]
|
||||
rating = item.get("voteAverage")
|
||||
minfo = item.get("mediaInfo") or {}
|
||||
status_code = minfo.get("status")
|
||||
|
||||
# Check availability
|
||||
is_available = status_code == 5
|
||||
is_processing = status_code in (2, 3, 4)
|
||||
|
||||
jf_item = jf_matches.get(title.strip().lower())
|
||||
jf_item_id = None
|
||||
if jf_item:
|
||||
is_available = True
|
||||
jf_item_id = jf_item.get("Id")
|
||||
|
||||
entry: Dict[str, Any] = {
|
||||
"title": title,
|
||||
"year": year,
|
||||
"media_type": mtype,
|
||||
"tmdb_id": item.get("id"),
|
||||
"rating": rating,
|
||||
"overview": item.get("overview"),
|
||||
"status": "AVAILABLE" if is_available else ("PROCESSING" if is_processing else "NOT_AVAILABLE"),
|
||||
"poster_url": ("https://image.tmdb.org/t/p/w500" + item.get('posterPath')) if item.get('posterPath') else None,
|
||||
}
|
||||
|
||||
if is_available:
|
||||
if not jf_item_id and minfo.get("jellyfinMediaId"):
|
||||
jf_item_id = minfo.get("jellyfinMediaId")
|
||||
|
||||
if jf_item_id:
|
||||
entry["jellyfin_item_id"] = jf_item_id
|
||||
entry["playback"] = {
|
||||
"web_details_url": f"{JELLYFIN_URL}/web/index.html#!/details?id={jf_item_id}&serverId={self.jf_server_id}",
|
||||
"stream_url": f"{JELLYFIN_URL}/Videos/{jf_item_id}/stream?static=true&api_key={self.jf_token}",
|
||||
"download_url": f"{JELLYFIN_URL}/Items/{jf_item_id}/Download?api_key={self.jf_token}"
|
||||
}
|
||||
|
||||
results.append(entry)
|
||||
|
||||
# Sort: AVAILABLE first, then PROCESSING, then NOT_AVAILABLE
|
||||
status_priority = {"AVAILABLE": 0, "PROCESSING": 1, "NOT_AVAILABLE": 2}
|
||||
results.sort(key=lambda x: (status_priority.get(x["status"], 3), -(x.get("rating") or 0)))
|
||||
return results
|
||||
|
||||
def discover_media(
|
||||
self,
|
||||
genre: Optional[str] = None,
|
||||
min_rating: Optional[float] = None,
|
||||
year: Optional[int] = None,
|
||||
sort_by: str = "popularity.desc",
|
||||
only_available: bool = False,
|
||||
media_type: str = "movie"
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Discover movies or TV shows with rich filters, prioritizing available items."""
|
||||
genre_id = None
|
||||
if genre:
|
||||
genre_lower = genre.lower().strip()
|
||||
genre_id = GENRE_MAP.get(genre_lower)
|
||||
if not genre_id and genre.isdigit():
|
||||
genre_id = int(genre)
|
||||
|
||||
endpoint = "/api/v1/discover/movies" if media_type != "tv" else "/api/v1/discover/tv"
|
||||
params: List[str] = [f"sortBy={sort_by}"]
|
||||
if genre_id:
|
||||
params.append(f"genre={genre_id}")
|
||||
if year:
|
||||
params.append(f"primaryReleaseDateGte={year}-01-01")
|
||||
params.append(f"primaryReleaseDateLte={year}-12-31")
|
||||
|
||||
query_str = "&".join(params)
|
||||
js_data = self.request_jellyseerr(f"{endpoint}?{query_str}")
|
||||
raw_results = js_data.get("results", [])
|
||||
|
||||
results: List[Dict[str, Any]] = []
|
||||
for item in raw_results:
|
||||
rating = item.get("voteAverage") or 0.0
|
||||
if min_rating and rating < min_rating:
|
||||
continue
|
||||
|
||||
title = item.get("title") or item.get("name") or "Unknown"
|
||||
rel_date = item.get("releaseDate") or item.get("firstAirDate") or ""
|
||||
minfo = item.get("mediaInfo") or {}
|
||||
status_code = minfo.get("status")
|
||||
|
||||
is_available = status_code == 5
|
||||
is_processing = status_code in (2, 3, 4)
|
||||
|
||||
if only_available and not is_available:
|
||||
continue
|
||||
|
||||
entry = {
|
||||
"title": title,
|
||||
"year": rel_date[:4] if rel_date else None,
|
||||
"media_type": media_type,
|
||||
"tmdb_id": item.get("id"),
|
||||
"rating": rating,
|
||||
"overview": item.get("overview"),
|
||||
"status": "AVAILABLE" if is_available else ("PROCESSING" if is_processing else "NOT_AVAILABLE"),
|
||||
"poster_url": ("https://image.tmdb.org/t/p/w500" + item.get('posterPath')) if item.get('posterPath') else None,
|
||||
}
|
||||
|
||||
if is_available and minfo.get("jellyfinMediaId"):
|
||||
jf_id = minfo.get("jellyfinMediaId")
|
||||
entry["jellyfin_item_id"] = jf_id
|
||||
entry["playback"] = {
|
||||
"web_details_url": f"{JELLYFIN_URL}/web/index.html#!/details?id={jf_id}&serverId={self.jf_server_id}",
|
||||
"stream_url": f"{JELLYFIN_URL}/Videos/{jf_id}/stream?static=true&api_key={self.jf_token}",
|
||||
"download_url": f"{JELLYFIN_URL}/Items/{jf_id}/Download?api_key={self.jf_token}"
|
||||
}
|
||||
|
||||
results.append(entry)
|
||||
|
||||
# Sort: AVAILABLE first
|
||||
status_priority = {"AVAILABLE": 0, "PROCESSING": 1, "NOT_AVAILABLE": 2}
|
||||
results.sort(key=lambda x: (status_priority.get(x["status"], 3), -(x.get("rating") or 0)))
|
||||
return results
|
||||
|
||||
def get_playback_info(self, item_id: str) -> Dict[str, Any]:
|
||||
"""Fetch full details, web link, stream link, and download link for a Jellyfin item."""
|
||||
data = self.request_jellyfin(f"/Users/{self.jf_user_id}/Items/{item_id}")
|
||||
name = data.get("Name", "Unknown")
|
||||
year = data.get("ProductionYear")
|
||||
overview = data.get("Overview")
|
||||
media_type = data.get("Type")
|
||||
genres = data.get("Genres", [])
|
||||
community_rating = data.get("CommunityRating")
|
||||
runtime_ticks = data.get("RunTimeTicks", 0)
|
||||
runtime_mins = int(runtime_ticks / (10_000_000 * 60)) if runtime_ticks else None
|
||||
|
||||
return {
|
||||
"title": name,
|
||||
"year": year,
|
||||
"type": media_type,
|
||||
"genres": genres,
|
||||
"rating": community_rating,
|
||||
"runtime_mins": runtime_mins,
|
||||
"overview": overview,
|
||||
"jellyfin_item_id": item_id,
|
||||
"web_details_url": f"{JELLYFIN_URL}/web/index.html#!/details?id={item_id}&serverId={self.jf_server_id}",
|
||||
"stream_url": f"{JELLYFIN_URL}/Videos/{item_id}/stream?static=true&api_key={self.jf_token}",
|
||||
"download_url": f"{JELLYFIN_URL}/Items/{item_id}/Download?api_key={self.jf_token}",
|
||||
}
|
||||
|
||||
def request_media(self, media_id: int, media_type: str = "movie", seasons: Optional[List[int]] = None) -> Dict[str, Any]:
|
||||
"""Submit a request to Jellyseerr."""
|
||||
payload: Dict[str, Any] = {
|
||||
"mediaId": media_id,
|
||||
"mediaType": media_type,
|
||||
}
|
||||
if media_type == "tv":
|
||||
payload["seasons"] = seasons if seasons is not None else [1]
|
||||
|
||||
res = self.request_jellyseerr("/api/v1/request", method="POST", payload=payload)
|
||||
return {
|
||||
"success": True,
|
||||
"request_id": res.get("id"),
|
||||
"status": res.get("status"),
|
||||
"media": res.get("media"),
|
||||
"message": "Media request successfully submitted to Jellyseerr!"
|
||||
}
|
||||
|
||||
def list_sessions(self) -> List[Dict[str, Any]]:
|
||||
"""List active playback sessions and devices on Jellyfin."""
|
||||
raw_sessions = self.request_jellyfin("/Sessions")
|
||||
output = []
|
||||
for s in raw_sessions:
|
||||
np = s.get("NowPlayingItem")
|
||||
output.append({
|
||||
"session_id": s.get("Id"),
|
||||
"device_name": s.get("DeviceName"),
|
||||
"client": s.get("Client"),
|
||||
"user_name": s.get("UserName"),
|
||||
"supports_remote_control": s.get("SupportsRemoteControl", False),
|
||||
"is_active": s.get("IsActive", False),
|
||||
"now_playing": {
|
||||
"name": np.get("Name"),
|
||||
"type": np.get("Type"),
|
||||
"id": np.get("Id")
|
||||
} if np else None
|
||||
})
|
||||
return output
|
||||
|
||||
def remote_play(self, session_id: str, item_id: str) -> Dict[str, Any]:
|
||||
"""Trigger remote playback on a Jellyfin client session."""
|
||||
path = f"/Sessions/{session_id}/Play?playCommand=PlayNow&ItemIds={item_id}"
|
||||
self.request_jellyfin(path, method="POST")
|
||||
return {"success": True, "message": f"Playback triggered for item {item_id} on session {session_id}"}
|
||||
|
||||
def get_resume(self) -> List[Dict[str, Any]]:
|
||||
"""Fetch items currently in progress for the user."""
|
||||
data = self.request_jellyfin(f"/Users/{self.jf_user_id}/Items/Resume?Recursive=true")
|
||||
items = data.get("Items", [])
|
||||
output = []
|
||||
for it in items:
|
||||
ticks = it.get("UserData", {}).get("PlaybackPositionTicks", 0)
|
||||
total_ticks = it.get("RunTimeTicks", 1) or 1
|
||||
pct = round((ticks / total_ticks) * 100, 1)
|
||||
mins = round(ticks / (10_000_000 * 60), 1)
|
||||
total_mins = round(total_ticks / (10_000_000 * 60), 1)
|
||||
|
||||
iid = it.get("Id")
|
||||
output.append({
|
||||
"title": it.get("Name"),
|
||||
"series": it.get("SeriesName"),
|
||||
"type": it.get("Type"),
|
||||
"item_id": iid,
|
||||
"progress_percent": pct,
|
||||
"position_mins": mins,
|
||||
"total_mins": total_mins,
|
||||
"web_details_url": f"{JELLYFIN_URL}/web/index.html#!/details?id={iid}&serverId={self.jf_server_id}",
|
||||
"stream_url": f"{JELLYFIN_URL}/Videos/{iid}/stream?static=true&api_key={self.jf_token}"
|
||||
})
|
||||
return output
|
||||
|
||||
def get_latest(self, limit: int = 10) -> List[Dict[str, Any]]:
|
||||
"""Fetch newly added items in the Jellyfin library."""
|
||||
data = self.request_jellyfin(f"/Users/{self.jf_user_id}/Items/Latest?Limit={limit}")
|
||||
output = []
|
||||
for it in data:
|
||||
iid = it.get("Id")
|
||||
output.append({
|
||||
"title": it.get("Name"),
|
||||
"series": it.get("SeriesName"),
|
||||
"type": it.get("Type"),
|
||||
"year": it.get("ProductionYear"),
|
||||
"item_id": iid,
|
||||
"web_details_url": f"{JELLYFIN_URL}/web/index.html#!/details?id={iid}&serverId={self.jf_server_id}",
|
||||
"stream_url": f"{JELLYFIN_URL}/Videos/{iid}/stream?static=true&api_key={self.jf_token}",
|
||||
"download_url": f"{JELLYFIN_URL}/Items/{iid}/Download?api_key={self.jf_token}"
|
||||
})
|
||||
return output
|
||||
|
||||
|
||||
# --- MCP SERVER STDIO IMPLEMENTATION ---
|
||||
|
||||
MCP_TOOLS = [
|
||||
{
|
||||
"name": "jelly_search",
|
||||
"description": "Search for movies or series across Jellyseerr and Jellyfin. Returns availability status (AVAILABLE, PROCESSING, NOT_AVAILABLE) favoring existing library media with instant playback and download URLs.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "The search term, title, or keywords"},
|
||||
"media_type": {"type": "string", "enum": ["all", "movie", "tv"], "description": "Optional filter for media type", "default": "all"}
|
||||
},
|
||||
"required": ["query"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "jelly_discover",
|
||||
"description": "Discover movies or TV shows based on genre, minimum rating, release year, or popularity. Automatically checks and highlights items already available to stream on Jellyfin.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"genre": {"type": "string", "description": "Genre name (e.g. action, comedy, sci-fi, thriller, drama, animation, horror)"},
|
||||
"min_rating": {"type": "number", "description": "Minimum TMDB score out of 10 (e.g. 7.0)"},
|
||||
"year": {"type": "integer", "description": "Release year filter (e.g. 2024)"},
|
||||
"sort_by": {"type": "string", "description": "Sort order (e.g. popularity.desc, vote_average.desc)", "default": "popularity.desc"},
|
||||
"only_available": {"type": "boolean", "description": "If true, returns only items already downloaded on Jellyfin", "default": False},
|
||||
"media_type": {"type": "string", "enum": ["movie", "tv"], "description": "Type of media", "default": "movie"}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "jelly_get_playback",
|
||||
"description": "Get web details URL, direct streaming URL, and offline direct download URL for an item in the Jellyfin library.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"item_id": {"type": "string", "description": "The Jellyfin item ID"}
|
||||
},
|
||||
"required": ["item_id"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "jelly_request",
|
||||
"description": "Submit a download request for a movie or TV series in Jellyseerr on behalf of user mop.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"media_id": {"type": "integer", "description": "The TMDB ID of the movie or series"},
|
||||
"media_type": {"type": "string", "enum": ["movie", "tv"], "description": "Type of media", "default": "movie"},
|
||||
"seasons": {"type": "array", "items": {"type": "integer"}, "description": "For TV series: list of season numbers to request (e.g. [1, 2])"}
|
||||
},
|
||||
"required": ["media_id"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "jelly_list_sessions",
|
||||
"description": "List all active playback devices and sessions connected to Jellyfin (e.g. Apple TV in Living Room, iPad, Chrome Web).",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "jelly_remote_play",
|
||||
"description": "Send a command to a specific Jellyfin client session to immediately play an item.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"session_id": {"type": "string", "description": "The Jellyfin session ID of the target device"},
|
||||
"item_id": {"type": "string", "description": "The Jellyfin item ID to play"}
|
||||
},
|
||||
"required": ["session_id", "item_id"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "jelly_get_resume",
|
||||
"description": "Get in-progress movies and TV series episodes that user mop is currently watching, with timestamps and progress percentage.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "jelly_get_latest",
|
||||
"description": "Get the latest movies or series episodes newly added to the Jellyfin library.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"limit": {"type": "integer", "description": "Maximum number of items to return", "default": 10}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def run_mcp_server():
|
||||
client = JellyClient()
|
||||
|
||||
while True:
|
||||
line = sys.stdin.readline()
|
||||
if not line:
|
||||
break
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
req = json.loads(line)
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"Invalid JSON received: {e}\n")
|
||||
continue
|
||||
|
||||
msg_id = req.get("id")
|
||||
method = req.get("method")
|
||||
params = req.get("params", {})
|
||||
|
||||
if method == "initialize":
|
||||
resp = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": msg_id,
|
||||
"result": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {"tools": {}},
|
||||
"serverInfo": {"name": "jelly-media-mcp", "version": "1.0.0"}
|
||||
}
|
||||
}
|
||||
sys.stdout.write(json.dumps(resp) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
elif method == "notifications/initialized":
|
||||
pass
|
||||
|
||||
elif method == "ping":
|
||||
resp = {"jsonrpc": "2.0", "id": msg_id, "result": {}}
|
||||
sys.stdout.write(json.dumps(resp) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
elif method == "tools/list":
|
||||
resp = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": msg_id,
|
||||
"result": {"tools": MCP_TOOLS}
|
||||
}
|
||||
sys.stdout.write(json.dumps(resp) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
elif method == "tools/call":
|
||||
tool_name = params.get("name")
|
||||
args = params.get("arguments", {})
|
||||
try:
|
||||
if tool_name == "jelly_search":
|
||||
data = client.search_media(args.get("query", ""), args.get("media_type"))
|
||||
elif tool_name == "jelly_discover":
|
||||
data = client.discover_media(
|
||||
genre=args.get("genre"),
|
||||
min_rating=args.get("min_rating"),
|
||||
year=args.get("year"),
|
||||
sort_by=args.get("sort_by", "popularity.desc"),
|
||||
only_available=args.get("only_available", False),
|
||||
media_type=args.get("media_type", "movie")
|
||||
)
|
||||
elif tool_name == "jelly_get_playback":
|
||||
data = client.get_playback_info(args.get("item_id", ""))
|
||||
elif tool_name == "jelly_request":
|
||||
data = client.request_media(
|
||||
media_id=args.get("media_id"),
|
||||
media_type=args.get("media_type", "movie"),
|
||||
seasons=args.get("seasons")
|
||||
)
|
||||
elif tool_name == "jelly_list_sessions":
|
||||
data = client.list_sessions()
|
||||
elif tool_name == "jelly_remote_play":
|
||||
data = client.remote_play(args.get("session_id", ""), args.get("item_id", ""))
|
||||
elif tool_name == "jelly_get_resume":
|
||||
data = client.get_resume()
|
||||
elif tool_name == "jelly_get_latest":
|
||||
data = client.get_latest(args.get("limit", 10))
|
||||
else:
|
||||
raise ValueError(f"Unknown tool: {tool_name}")
|
||||
|
||||
text_result = json.dumps(data, indent=2, ensure_ascii=False)
|
||||
resp = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": msg_id,
|
||||
"result": {
|
||||
"content": [{"type": "text", "text": text_result}]
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
resp = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": msg_id,
|
||||
"result": {
|
||||
"isError": True,
|
||||
"content": [{"type": "text", "text": f"Error executing {tool_name}: {str(e)}"}]
|
||||
}
|
||||
}
|
||||
sys.stdout.write(json.dumps(resp) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
else:
|
||||
if msg_id is not None:
|
||||
resp = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": msg_id,
|
||||
"error": {"code": -32601, "message": f"Method not found: {method}"}
|
||||
}
|
||||
sys.stdout.write(json.dumps(resp) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def run_cli():
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description="Jellyfin & Jellyseerr Media Tool")
|
||||
subparsers = parser.add_subparsers(dest="command")
|
||||
|
||||
# Search
|
||||
sp_search = subparsers.add_parser("search", help="Search media")
|
||||
sp_search.add_argument("query", help="Search query")
|
||||
sp_search.add_argument("--type", choices=["all", "movie", "tv"], default="all")
|
||||
|
||||
# Discover
|
||||
sp_disc = subparsers.add_parser("discover", help="Discover movies/shows")
|
||||
sp_disc.add_argument("--genre", help="Genre (action, comedy, etc.)")
|
||||
sp_disc.add_argument("--min-rating", type=float, help="Minimum TMDB rating")
|
||||
sp_disc.add_argument("--year", type=int, help="Year")
|
||||
sp_disc.add_argument("--sort-by", default="popularity.desc")
|
||||
sp_disc.add_argument("--only-available", action="store_true")
|
||||
sp_disc.add_argument("--type", choices=["movie", "tv"], default="movie")
|
||||
|
||||
# Playback
|
||||
sp_play = subparsers.add_parser("playback", help="Get playback links for item ID")
|
||||
sp_play.add_argument("item_id", help="Jellyfin Item ID")
|
||||
|
||||
# Request
|
||||
sp_req = subparsers.add_parser("request", help="Request media")
|
||||
sp_req.add_argument("media_id", type=int, help="TMDB ID")
|
||||
sp_req.add_argument("--type", choices=["movie", "tv"], default="movie")
|
||||
sp_req.add_argument("--seasons", type=int, nargs="*", help="Seasons for TV")
|
||||
|
||||
# Sessions
|
||||
subparsers.add_parser("sessions", help="List active Jellyfin sessions")
|
||||
|
||||
# Remote Play
|
||||
sp_rem = subparsers.add_parser("remote-play", help="Trigger remote play")
|
||||
sp_rem.add_argument("session_id", help="Session ID")
|
||||
sp_rem.add_argument("item_id", help="Item ID")
|
||||
|
||||
# Resume
|
||||
subparsers.add_parser("resume", help="Get in-progress items")
|
||||
|
||||
# Latest
|
||||
sp_latest = subparsers.add_parser("latest", help="Get latest items")
|
||||
sp_latest.add_argument("--limit", type=int, default=10)
|
||||
|
||||
# Test
|
||||
subparsers.add_parser("test", help="Test connectivity to Jellyfin & Jellyseerr")
|
||||
|
||||
args = parser.parse_args()
|
||||
client = JellyClient()
|
||||
|
||||
if args.command == "search":
|
||||
print(json.dumps(client.search_media(args.query, args.type), indent=2, ensure_ascii=False))
|
||||
elif args.command == "discover":
|
||||
print(json.dumps(client.discover_media(
|
||||
genre=args.genre,
|
||||
min_rating=args.min_rating,
|
||||
year=args.year,
|
||||
sort_by=args.sort_by,
|
||||
only_available=args.only_available,
|
||||
media_type=args.type
|
||||
), indent=2, ensure_ascii=False))
|
||||
elif args.command == "playback":
|
||||
print(json.dumps(client.get_playback_info(args.item_id), indent=2, ensure_ascii=False))
|
||||
elif args.command == "request":
|
||||
print(json.dumps(client.request_media(args.media_id, args.type, args.seasons), indent=2, ensure_ascii=False))
|
||||
elif args.command == "sessions":
|
||||
print(json.dumps(client.list_sessions(), indent=2, ensure_ascii=False))
|
||||
elif args.command == "remote-play":
|
||||
print(json.dumps(client.remote_play(args.session_id, args.item_id), indent=2, ensure_ascii=False))
|
||||
elif args.command == "resume":
|
||||
print(json.dumps(client.get_resume(), indent=2, ensure_ascii=False))
|
||||
elif args.command == "latest":
|
||||
print(json.dumps(client.get_latest(args.limit), indent=2, ensure_ascii=False))
|
||||
elif args.command == "test":
|
||||
print("Testing Jellyfin Auth...")
|
||||
jf_ok = client.auth_jellyfin()
|
||||
print(" Jellyfin:", "[OK]" if jf_ok else "[FAILED]")
|
||||
print("Testing Jellyseerr Auth...")
|
||||
js_ok = client.auth_jellyseerr()
|
||||
print(" Jellyseerr:", "[OK]" if js_ok else "[FAILED]")
|
||||
else:
|
||||
parser.print_help()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) == 1 or (len(sys.argv) == 2 and sys.argv[1] == "--stdio"):
|
||||
run_mcp_server()
|
||||
else:
|
||||
run_cli()
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
name: markitdown
|
||||
description: Universal document and media converter powered by Microsoft MarkItDown. Converts PDF, Word (docx), Excel (xlsx), PowerPoint (pptx), HTML, CSV, audio, and images to clean, structured Markdown. Use whenever you need to read, inspect, or extract text from PDFs, spreadsheets, office documents, or web files.
|
||||
---
|
||||
|
||||
# 📄 Microsoft MarkItDown Converter
|
||||
|
||||
Convert arbitrary documents and files into clean, structured Markdown for analysis, summarization, or archiving.
|
||||
|
||||
## 🚀 Usage
|
||||
|
||||
Use `scripts/convert_to_markdown.py`. The script runs automatically via `uv` with all document dependencies bundled.
|
||||
|
||||
### 1. Convert a Remote URL (PDF, Word, etc.)
|
||||
Download and convert in one step:
|
||||
```bash
|
||||
python3 scripts/convert_to_markdown.py --url "https://example.com/document.pdf"
|
||||
```
|
||||
|
||||
To save directly to a file with frontmatter metadata:
|
||||
```bash
|
||||
python3 scripts/convert_to_markdown.py --url "https://example.com/document.pdf" --frontmatter -o /path/to/output.md
|
||||
```
|
||||
|
||||
### 2. Convert a Local File
|
||||
Convert an existing local document or an uploaded attachment:
|
||||
```bash
|
||||
python3 scripts/convert_to_markdown.py /path/to/invoice.pdf
|
||||
python3 scripts/convert_to_markdown.py /path/to/spreadsheet.xlsx
|
||||
python3 scripts/convert_to_markdown.py /path/to/presentation.pptx
|
||||
```
|
||||
|
||||
## 🛠️ Supported Formats
|
||||
* **PDF (`.pdf`)**: Extracts text, columns, formatting, and tables.
|
||||
* **Microsoft Word (`.docx`)**: Preserves headers, bold/italic, lists, and tables.
|
||||
* **Microsoft Excel (`.xlsx`)**: Converts sheets and tables into Markdown pipe tables.
|
||||
* **Microsoft PowerPoint (`.pptx`)**: Extracts slide text, outlines, and speaker notes.
|
||||
* **HTML / Web pages**: Cleans boilerplate and outputs article markdown.
|
||||
* **CSV / JSON / XML**: Formats data into readable markdown tables or code blocks.
|
||||
* **Audio (`.wav`, `.mp3`)**: Transcribes speech (when speech models are active).
|
||||
* **Images**: Extracts metadata, EXIF details, and embedded text descriptions.
|
||||
|
||||
## 💡 Best Practices for OpenClaw
|
||||
- When the user sends a PDF or document link in Telegram, call `convert_to_markdown.py --url <URL>` to read its contents before answering.
|
||||
- For large documents (>50 pages), save the converted Markdown to a temporary file and search/filter for relevant sections.
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# dependencies = [
|
||||
# "markitdown[all]>=0.0.1a4",
|
||||
# "requests>=2.31.0",
|
||||
# ]
|
||||
# ///
|
||||
"""
|
||||
Universal Document to Markdown Converter
|
||||
Powered by Microsoft MarkItDown.
|
||||
|
||||
Supports PDF, DOCX, XLSX, PPTX, HTML, CSV, TXT, JSON, XML, images, and audio.
|
||||
Can take a local file path, a remote URL, or stdin.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.parse
|
||||
|
||||
def ensure_dependencies_and_run():
|
||||
"""If markitdown is not installed in the current Python env, re-exec via uv run if available."""
|
||||
try:
|
||||
import markitdown # noqa: F401
|
||||
import requests # noqa: F401
|
||||
except ImportError:
|
||||
uv_path = shutil.which("uv")
|
||||
if uv_path and os.environ.get("_MARKITDOWN_REEXEC") != "1":
|
||||
env = os.environ.copy()
|
||||
env["_MARKITDOWN_REEXEC"] = "1"
|
||||
cmd = [uv_path, "run", os.path.abspath(__file__), *sys.argv[1:]]
|
||||
try:
|
||||
res = subprocess.run(cmd, env=env)
|
||||
sys.exit(res.returncode)
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"Failed to re-execute via uv: {e}\n")
|
||||
sys.stderr.write("Error: 'markitdown[all]' or 'requests' not found. Please install with:\n")
|
||||
sys.stderr.write(" uv pip install 'markitdown[all]' requests\n")
|
||||
sys.exit(1)
|
||||
|
||||
ensure_dependencies_and_run()
|
||||
|
||||
from markitdown import MarkItDown
|
||||
import requests
|
||||
|
||||
def download_url(url: str, output_dir: str) -> str:
|
||||
"""Download a remote URL to a temporary local file, guessing the filename from URL or headers."""
|
||||
headers = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
}
|
||||
resp = requests.get(url, headers=headers, stream=True, timeout=60)
|
||||
resp.raise_for_status()
|
||||
|
||||
# Determine filename
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
basename = os.path.basename(parsed.path) or "downloaded_file"
|
||||
if not os.path.splitext(basename)[1]:
|
||||
ct = resp.headers.get("Content-Type", "")
|
||||
if "pdf" in ct:
|
||||
basename += ".pdf"
|
||||
elif "word" in ct or "docx" in ct:
|
||||
basename += ".docx"
|
||||
elif "sheet" in ct or "excel" in ct or "xlsx" in ct:
|
||||
basename += ".xlsx"
|
||||
elif "html" in ct:
|
||||
basename += ".html"
|
||||
|
||||
local_path = os.path.join(output_dir, basename)
|
||||
with open(local_path, "wb") as f:
|
||||
for chunk in resp.iter_content(chunk_size=65536):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
return local_path
|
||||
|
||||
def convert_file(path: str) -> str:
|
||||
"""Convert local file to Markdown text using MarkItDown."""
|
||||
md = MarkItDown()
|
||||
result = md.convert(path)
|
||||
return result.text_content
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Convert documents (PDF, Office, HTML, images) to Markdown via Microsoft MarkItDown"
|
||||
)
|
||||
parser.add_argument("file", nargs="?", help="Local path to the document file")
|
||||
parser.add_argument("--url", "-u", help="Remote URL of the document to download and convert")
|
||||
parser.add_argument("--output", "-o", help="Optional output file path (defaults to stdout)")
|
||||
parser.add_argument("--frontmatter", action="store_true", help="Add YAML frontmatter with file metadata")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.file and not args.url:
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
temp_dir = None
|
||||
target_file = args.file
|
||||
|
||||
try:
|
||||
if args.url:
|
||||
temp_dir = tempfile.mkdtemp(prefix="markitdown_")
|
||||
target_file = download_url(args.url, temp_dir)
|
||||
|
||||
if not target_file or not os.path.exists(target_file):
|
||||
sys.stderr.write(f"Error: File not found: {target_file}\n")
|
||||
sys.exit(1)
|
||||
|
||||
content = convert_file(target_file)
|
||||
|
||||
if args.frontmatter:
|
||||
meta = [
|
||||
"---",
|
||||
f"source_file: {os.path.basename(target_file)}",
|
||||
f"original_url: {args.url or ''}",
|
||||
f"size_bytes: {os.path.getsize(target_file)}",
|
||||
"converted_by: microsoft-markitdown",
|
||||
"---",
|
||||
"",
|
||||
]
|
||||
content = "\n".join(meta) + content
|
||||
|
||||
if args.output:
|
||||
os.makedirs(os.path.dirname(os.path.abspath(args.output)), exist_ok=True)
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
print(f"Successfully converted to {args.output}")
|
||||
else:
|
||||
print(content)
|
||||
|
||||
finally:
|
||||
if temp_dir and os.path.exists(temp_dir):
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,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.
|
||||
Executable
+136
@@ -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()
|
||||
@@ -1,20 +1,37 @@
|
||||
---
|
||||
name: nerd-report
|
||||
description: Generiert personalisierte Morgen-Reports für Moritz Graf in Regensburg. Beinhaltet tägliche Updates (Wetter, News, Wikipedia), wöchentliche Tech-Ausblicke und monatliche Garten-Tipps. Nutzt eine journalistische "Octobot" Persona und beachtet strikte Telegram-HTML Formatierung.
|
||||
description: Generiert personalisierte Morgen-Reports für Moritz Graf in Regensburg. Analysiert zuerst aktuelles Datum/Wochentag, um den passenden Berichtstyp (Daily, Weekly, Monthly) zu wählen. Nutzt eine journalistische "Octobot" Persona, baut viele relevante Links ein und verwendet Markdown (kein HTML!) zur optimalen Darstellung in Telegram.
|
||||
---
|
||||
|
||||
# Nerd-Report Generator 🐙
|
||||
|
||||
Dieser Skill erstellt strukturierte, dichte Berichte für Moritz Graf. Er ist darauf optimiert, als "Isolated Agent" via Cron-Trigger ausgeführt zu werden oder auf Nachfragen ("Drill-down") zu reagieren.
|
||||
|
||||
## Kernfunktionen
|
||||
1. **Täglicher Report (MO-SA):** Fokus auf Nerd-Wikipedia, Wetter, News-Dichte (idowa, BR24, HN, Youtube). Montags inkl. Sonntags-Catchup.
|
||||
2. **Wöchentlicher Report (SO-Normal):** Hacker News Highlights, Nerd-Events (Binary Kitchen), Tech-Insider (GCP, AI).
|
||||
3. **Monatlicher Report (1. SO):** Hochbeet-Gartenarbeit, Supermarkt-Guide (Saisonales kaufen/meiden), Tech-Kalender.
|
||||
## Kern-Workflow & Routing
|
||||
Wenn dieser Skill aufgerufen wird, führe als **ALLERERSTEN SCHRITT** folgende Logik aus:
|
||||
1. **Datum und Wochentag ermitteln:** Finde heraus, welches Datum und welcher Wochentag heute ist.
|
||||
2. **Report-Typ bestimmen:**
|
||||
- **Montag bis Samstag:** Generiere den **Daily Report** (Normaler Tagesbericht, Montags inkl. Sonntags-Catchup).
|
||||
- **Sonntag:** Prüfe, ob es der _erste Sonntag im Monat_ ist.
|
||||
- Wenn JA: Generiere den **Monthly Report**.
|
||||
- Wenn NEIN: Generiere den **Weekly Report**.
|
||||
|
||||
*(Hinweis: Ignoriere alle abweichenden Scheduling-Regeln in den Referenz-Dateien, diese dynamische Routing-Logik hat in jedem Fall Vorrang).*
|
||||
|
||||
## Report-Typen
|
||||
1. **Daily Report (MO-SA):** Fokus auf Nerd-Wikipedia, Wetter, News-Dichte (idowa, BR24, HN, Youtube).
|
||||
2. **Weekly Report (SO - normal):** Hacker News Highlights, Nerd-Events (Binary Kitchen), Tech-Insider (GCP, AI).
|
||||
3. **Monthly Report (SO - 1. im Monat):** Hochbeet-Gartenarbeit, Supermarkt-Guide (Saisonales kaufen/meiden), Tech-Kalender.
|
||||
4. **Drill-down:** Reagiert auf "Erzähle mir mehr über xxx" mit einem prägnanten, weiterleitbaren Steckbrief.
|
||||
|
||||
## Formatierung für Telegram (WICHTIG!)
|
||||
- **KEIN HTML:** Verwende **NIEMALS** rohe HTML-Tags wie `<b>`, `<strong>`, `<i>` oder `<a>`. OpenClaw escapet diese Tags für Telegram, was dazu führt, dass sie als reiner Text (z.B. `<b>Text</b>`) in der Chat-Bubble angezeigt werden!
|
||||
- **Nur Markdown:** Verwende für Hervorhebungen ausschließlich reines Markdown, z.B. `**fetter Text**` und `_kursiver Text_`.
|
||||
- **Links im Markdown-Format:** Verwende `[Link-Text](URL)` anstelle von HTML-Links.
|
||||
- **Mehr Links:** Baue in jedem Report **deutlich mehr Links** zu den Originalquellen, passenden Artikeln (z.B. Wikipedia) oder GitHub-Projekten ein! Je mehr nützliche Querverweise, desto besser.
|
||||
- **Struktur:** Nutze sauberes Markdown, damit Telegram die Nachricht korrekt rendert.
|
||||
|
||||
## Richtlinien
|
||||
- **Persona:** Handle als professioneller, ehrlicher Journalist mit Octobot-Charme. Siehe [persona.md](references/persona.md).
|
||||
- **Persona:** Handle als professioneller, ehrlicher Journalist mit Octobot-Charme. Siehe `references/persona.md`.
|
||||
- **Fokus:** Immer nerdy & techy. Priorisiere GCP, AI (OpenAI, Anthropic) und Hacker News.
|
||||
- **Formatierung:** Strikte Telegram-HTML Regeln (Keine Header, keine Listen). Kompakte Links via `<a>`.
|
||||
- **Workflow:** Siehe [daily.md](references/daily.md) und [weekly_monthly.md](references/weekly_monthly.md).
|
||||
- **Details zu Inhalten:** Siehe `references/daily.md` und `references/weekly_monthly.md`.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
- **Immer nerdy/techy!** (Wissenschaftler, Erfinder, IT-Durchbrüche). Keinen "Mainstream" ohne Tech-Bezug.
|
||||
2. **Weather (BrightSky):**
|
||||
- Location: Grabengasse 7, 93059 Regensburg.
|
||||
- Erstelle eine Text-Tabelle im `<pre>` Block für die Intervalle: 6-9, 9-12, 12-15, 15-18 Uhr.
|
||||
- Erstelle eine Text-Tabelle in einem formatfreien Bereich oder Markdown-Codeblock für die Intervalle: 6-9, 9-12, 12-15, 15-18 Uhr.
|
||||
3. **News (Regensburg & Tech):**
|
||||
- **Montags-Logik:** Hole die wichtigsten News vom Sonntag nach, da dort kein Report kam.
|
||||
- Scanne `idowa.de`, `BR24`, `Hacker News` und `Youtube`.
|
||||
@@ -13,31 +13,31 @@
|
||||
|
||||
## Drill-down Feature ("Erzähle mir mehr")
|
||||
Falls Moritz fragt: "Erzähle mir mehr über xxx", antworte SOFORT mit einem prägnanten Steckbrief:
|
||||
- **Titel:** <b>[NAME/THEMA]</b>
|
||||
- **Titel:** **[NAME/THEMA]**
|
||||
- **Kerninfo:** 2-3 Sätze, was das ist und warum es relevant ist.
|
||||
- **Deep Dive:** Weiterführende Links für mehr Details.
|
||||
- **Format:** Direkt weiterleitbar (Telegram HTML).
|
||||
- **Format:** Direkt weiterleitbar (nur Markdown `[Text](URL)` - kein HTML!).
|
||||
|
||||
## Format-Vorlage (Telegram HTML)
|
||||
```html
|
||||
<b>### Morgenreport (Wochentag, Datum) ###</b>
|
||||
## Format-Vorlage (Telegram Markdown)
|
||||
```markdown
|
||||
**### Morgenreport (Wochentag, Datum) ###**
|
||||
|
||||
<b>📰 Person des Tages (Nerdy Edition):</b>
|
||||
<a href="URL">Name</a>, Begründung (Fokus: Tech/Wissenschaft)
|
||||
**📰 Person des Tages (Nerdy Edition):**
|
||||
[Name](URL), Begründung (Fokus: Tech/Wissenschaft)
|
||||
|
||||
<b>🌡️ Wetterbericht für Regensburg:</b>
|
||||
<pre>
|
||||
**🌡️ Wetterbericht für Regensburg:**
|
||||
```text
|
||||
| Zeit | 6-9 | 9-12 | 12-15 | 15-18 |
|
||||
|-------|-----|------|-------|-------|
|
||||
| Temp | 12° | 16° | 18° | 10° |
|
||||
| Wetter| 🌧️ | ☀️ | ☁️ | ☁️ |
|
||||
</pre>
|
||||
```
|
||||
|
||||
<b>💡 Besonderheit des Tages:</b>
|
||||
<a href="URL">Artikel-Titel</a>: Prägnanter Tech-Fokus.
|
||||
**💡 Besonderheit des Tages:**
|
||||
[Artikel-Titel](URL): Prägnanter Tech-Fokus.
|
||||
|
||||
<b>📣 News Feed (Concise & Dense):</b>
|
||||
1. <a href="URL">Titel</a>: Kurz-Teaser.
|
||||
2. <a href="URL">Titel</a>: Kurz-Teaser.
|
||||
**📣 News Feed (Concise & Dense):**
|
||||
1. [Titel](URL): Kurz-Teaser.
|
||||
2. [Titel](URL): Kurz-Teaser.
|
||||
... (bis zu 5-7 News, wenn relevant)
|
||||
```
|
||||
|
||||
@@ -12,6 +12,6 @@ Du bist ein hochspezialisierter KI-Journalist, der exklusiv für Moritz Graf arb
|
||||
|
||||
## Richtlinien für die Kommunikation
|
||||
- Nutze Emojis passend zum Thema (🐙, 💻, 🌿, 🌤️, 🏛️).
|
||||
- Nutze kompakte HTML-Links `<a>Klick</a>` um den Lesefluss nicht zu stören.
|
||||
- Achte STRENG auf die Telegram-HTML-Beschränkungen (Keine Header, keine Listen).
|
||||
- Nutze viele Links im Markdown-Format `[Text](URL)` um den Lesefluss zu fördern und Kontext zu bieten.
|
||||
- Verwende **KEIN ROHES HTML** (kein `<b>`, kein `<a>`). Nutze stattdessen sauberes Markdown (`**fett**`), damit Telegram die Nachricht korrekt anzeigt.
|
||||
- Bei Nachfragen ("Erzähle mir mehr...") wechselst du in den **Steckbrief-Modus** (siehe `daily.md`).
|
||||
|
||||
@@ -16,5 +16,5 @@
|
||||
- **Tech-Kalender:** Wichtige Releases/Konferenzen.
|
||||
|
||||
## Formatierung
|
||||
- Nutze `<b>` für Sektionen.
|
||||
- Beachte die Telegram HTML-Beschränkungen.
|
||||
- Nutze `**fett**` für Sektionen anstatt von HTML.
|
||||
- Beachte die Telegram Markdown-Regeln (kein rohes HTML!). Baue viele aussagekräftige Links im Format `[Text](URL)` ein.
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
name: openclaw-audit
|
||||
description: Audit OpenClaw runtime logs, session transcripts, tool failures, and correlate errors with recent git commits across infrapuzzle, mop-skills, and knowledge-wiki. Use when performing system health audits, investigating tool failures, or running the weekly IT Guy self-audit.
|
||||
---
|
||||
|
||||
# 🩺 OpenClaw System & Log Self-Audit (`openclaw-audit`)
|
||||
|
||||
Comprehensive system diagnostic and correlation engine designed for **IT Guy** (`itguy`). It analyzes runtime container stdout logs, session transcripts, and tool execution failures across all agents, correlates errors with recent Git commits across the 3 core repositories, and files structured audit reports and actionable task queue entries into the Knowledge Wiki.
|
||||
|
||||
## 🎯 When to Use
|
||||
- **Weekly Scheduled Audit**: Triggered automatically via OpenClaw Cron (`itguy-weekly-health-audit`) every Sunday at 03:00 Europe/Berlin.
|
||||
- **On-Demand Diagnostics**: Whenever the user asks: *"Prüfe die Logs von OpenClaw"*, *"Gibt es Tool-Fehler?"*, *"Audit durchführen"*, *"System-Health prüfen"*.
|
||||
- **Post-Deployment Validation**: After making configuration, skill, or manifest changes to verify whether errors appeared.
|
||||
|
||||
---
|
||||
|
||||
## 🗂️ The Three Core Repositories Architecture
|
||||
The audit correlates runtime behavior with recent git commits across three foundational repositories:
|
||||
|
||||
1. **`infrapuzzle`** (`/home/node/infrapuzzle/infrapuzzle` or `/home/node/.openclaw/repos/infrapuzzle`):
|
||||
- Scope: Cluster infrastructure, Kubespray bootstrap, K8s manifests, Terraform DNS, ESPHome IoT, ADRs.
|
||||
2. **`mop-skills`** (`/home/node/mop-skills/mop-skills`):
|
||||
- Scope: Custom Python skills, CLI utilities, and MCP server bridges.
|
||||
3. **`knowledge-wiki`** (`/home/node/.openclaw/wiki/main`):
|
||||
- Scope: Agents-as-code (`agents/*.md`), active task queue (`tasks/antigravity.md`), family knowledge vault, and audit archives (`operations/audits/`).
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ CLI Operations Reference
|
||||
|
||||
### 1. Standard Audit (Inspection Only)
|
||||
```bash
|
||||
uv run /home/node/mop-skills/mop-skills/skills/openclaw-audit/scripts/audit_openclaw.py --since 7d
|
||||
```
|
||||
Fetches container logs from the last 7 days, parses session transcripts across all agents, summarizes recent commits from all 3 git repos, and outputs a formatted diagnostic markdown report to stdout.
|
||||
|
||||
### 2. Full Audit with Wiki Archival & Task Filing
|
||||
```bash
|
||||
uv run /home/node/mop-skills/mop-skills/skills/openclaw-audit/scripts/audit_openclaw.py \
|
||||
--since 7d \
|
||||
--write-report \
|
||||
--add-task \
|
||||
--git-push
|
||||
```
|
||||
- **`--write-report`**: Writes the detailed diagnostic report to `/home/node/.openclaw/wiki/main/operations/audits/YYYY-Wxx.md`.
|
||||
- **`--add-task`**: Appends any newly detected, deduplicated high/critical issues to `/home/node/.openclaw/wiki/main/tasks/antigravity.md` under `## 📥 Pending Tasks`.
|
||||
- **`--git-push`**: Commits and pushes the modified wiki pages directly to `git.moritzgraf.de/moritz/knowledge-wiki.git`.
|
||||
|
||||
### 3. Machine-Readable JSON Output
|
||||
```bash
|
||||
uv run /home/node/mop-skills/mop-skills/skills/openclaw-audit/scripts/audit_openclaw.py --since 24h --json
|
||||
```
|
||||
Returns a structured JSON payload containing `status`, `recent_commits`, `error_clusters`, `session_tool_failures`, and `affected_agents`.
|
||||
|
||||
---
|
||||
|
||||
## 🤖 Protocol for IT Guy Agent (`itguy`)
|
||||
|
||||
When executing the weekly audit turn:
|
||||
1. Run the audit script:
|
||||
```bash
|
||||
uv run /home/node/mop-skills/mop-skills/skills/openclaw-audit/scripts/audit_openclaw.py --since 7d --write-report --add-task --git-push
|
||||
```
|
||||
2. Review the aggregated output:
|
||||
- Check if any new issues were added to `tasks/antigravity.md`.
|
||||
- Correlate any error spikes with commits in `infrapuzzle` or `mop-skills`.
|
||||
3. Delivery Decision:
|
||||
- **If issues were found and tasks added**: Send a concise summary of findings, affected agents/tools, and links to the wiki task queue directly into Telegram Topic `#IT Guy` (thread `119`).
|
||||
- **If zero issues / all healthy**: Respond with **`NO_REPLY`** to maintain intentional silence (no Telegram notification spam).
|
||||
+622
@@ -0,0 +1,622 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
OpenClaw System, Log & Repository Self-Audit Engine
|
||||
Analyzes container logs, session transcripts, and correlates error events
|
||||
with recent Git commits across infrapuzzle, mop-skills, and knowledge-wiki.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
# Default search directories (in-cluster vs local fallback)
|
||||
DEFAULT_CANDIDATE_PATHS = {
|
||||
"infrapuzzle": [
|
||||
Path("/home/node/infrapuzzle/infrapuzzle"),
|
||||
Path("/home/node/.openclaw/repos/infrapuzzle"),
|
||||
Path("/home/node/infrapuzzle"),
|
||||
Path("/Users/moritz/src/infrapuzzle"),
|
||||
],
|
||||
"mop-skills": [
|
||||
Path("/home/node/mop-skills/mop-skills"),
|
||||
Path("/Users/moritz/src/mop-skills"),
|
||||
],
|
||||
"knowledge-wiki": [
|
||||
Path("/home/node/.openclaw/wiki/main"),
|
||||
Path("/Users/moritz/src/infrapuzzle/knowledge-wiki"),
|
||||
],
|
||||
}
|
||||
|
||||
DEFAULT_SESSIONS_DIRS = [
|
||||
Path("/home/node/.openclaw/agents"),
|
||||
Path.home() / ".openclaw/agents",
|
||||
]
|
||||
|
||||
|
||||
def resolve_repo_path(repo_name: str, override: Optional[str] = None) -> Optional[Path]:
|
||||
if override:
|
||||
p = Path(override)
|
||||
if p.exists() and (p / ".git").exists():
|
||||
return p
|
||||
for candidate in DEFAULT_CANDIDATE_PATHS.get(repo_name, []):
|
||||
if candidate.exists() and (candidate / ".git").exists():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def resolve_sessions_dir(override: Optional[str] = None) -> Optional[Path]:
|
||||
if override:
|
||||
p = Path(override)
|
||||
if p.exists():
|
||||
return p
|
||||
for candidate in DEFAULT_SESSIONS_DIRS:
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def resolve_wiki_dir(override: Optional[str] = None) -> Optional[Path]:
|
||||
return resolve_repo_path("knowledge-wiki", override)
|
||||
|
||||
|
||||
def run_cmd(cmd: List[str], cwd: Optional[Path] = None) -> Tuple[int, str, str]:
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
cwd=str(cwd) if cwd else None,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
return proc.returncode, proc.stdout, proc.stderr
|
||||
except Exception as e:
|
||||
return -1, "", str(e)
|
||||
|
||||
|
||||
def get_git_commits(repo_path: Path, since: str) -> List[Dict[str, str]]:
|
||||
if not repo_path or not repo_path.exists():
|
||||
return []
|
||||
# format: hash | iso_date | author | subject
|
||||
cmd = [
|
||||
"git",
|
||||
"-C",
|
||||
str(repo_path),
|
||||
"log",
|
||||
f"--since={since}",
|
||||
'--pretty=format:%h%x09%ad%x09%an%x09%s',
|
||||
"--date=iso-strict",
|
||||
"-n",
|
||||
"20",
|
||||
]
|
||||
rc, stdout, _ = run_cmd(cmd)
|
||||
if rc != 0 or not stdout.strip():
|
||||
return []
|
||||
|
||||
commits = []
|
||||
for line in stdout.strip().splitlines():
|
||||
parts = line.split("\t")
|
||||
if len(parts) >= 4:
|
||||
commits.append({
|
||||
"hash": parts[0],
|
||||
"date": parts[1],
|
||||
"author": parts[2],
|
||||
"subject": parts[3],
|
||||
})
|
||||
return commits
|
||||
|
||||
|
||||
def normalize_error(msg: str) -> str:
|
||||
"""Normalize error strings to group identical issues together."""
|
||||
# Strip ISO timestamps
|
||||
s = re.sub(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?([+-]\d{2}:\d{2}|Z)?", "<TS>", msg)
|
||||
# Strip UUIDs
|
||||
s = re.sub(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", "<UUID>", s)
|
||||
# Strip toolCallIds and runIds
|
||||
s = re.sub(r"(call_[0-9a-zA-Z]+|runId=[0-9a-zA-Z-]+)", "<ID>", s)
|
||||
# Strip memory addresses
|
||||
s = re.sub(r"0x[0-9a-fA-F]+", "<HEX>", s)
|
||||
# Strip excessive whitespace
|
||||
s = " ".join(s.split())
|
||||
return s[:250]
|
||||
|
||||
|
||||
def fetch_container_logs(since: str, tail: int = 25000) -> str:
|
||||
"""Fetch container stdout logs using kubectl or read from openclaw log files."""
|
||||
# 1. Try in-cluster kubectl directly
|
||||
kubectl_bin = "/home/node/.openclaw/bin/kubectl" if Path("/home/node/.openclaw/bin/kubectl").exists() else "kubectl"
|
||||
cmd = [
|
||||
kubectl_bin,
|
||||
"logs",
|
||||
f"--since={since}",
|
||||
f"--tail={tail}",
|
||||
"-n",
|
||||
"openclaw",
|
||||
"openclaw-0",
|
||||
"-c",
|
||||
"openclaw",
|
||||
]
|
||||
rc, stdout, _ = run_cmd(cmd)
|
||||
if rc == 0 and stdout.strip():
|
||||
return stdout
|
||||
|
||||
# 2. Fallback: Check local logs directory
|
||||
local_log_dir = Path("/home/node/.openclaw/logs")
|
||||
if local_log_dir.exists():
|
||||
combined = []
|
||||
for log_file in local_log_dir.glob("*.log"):
|
||||
try:
|
||||
combined.append(log_file.read_text(errors="replace"))
|
||||
except Exception:
|
||||
pass
|
||||
if combined:
|
||||
return "\n".join(combined)
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def parse_container_logs(raw_logs: str) -> List[Dict[str, Any]]:
|
||||
"""Parse and cluster errors found in container stdout logs."""
|
||||
error_clusters: Dict[str, Dict[str, Any]] = defaultdict(lambda: {
|
||||
"signature": "",
|
||||
"category": "GATEWAY",
|
||||
"count": 0,
|
||||
"first_seen": None,
|
||||
"last_seen": None,
|
||||
"sample": "",
|
||||
})
|
||||
|
||||
if not raw_logs:
|
||||
return []
|
||||
|
||||
lines = raw_logs.splitlines()
|
||||
for line in lines:
|
||||
line_lower = line.lower()
|
||||
|
||||
# False positive exclusions
|
||||
if "iserror=false" in line_lower:
|
||||
continue
|
||||
if "[model-fetch] start" in line_lower:
|
||||
continue
|
||||
if "timeoutms=undefined" in line_lower and "status=200" in line_lower:
|
||||
continue
|
||||
if "response provider=" in line_lower and "status=200" in line_lower:
|
||||
continue
|
||||
|
||||
is_error = False
|
||||
category = "GATEWAY"
|
||||
|
||||
# Categorization patterns
|
||||
if "iserror=true" in line_lower or "tool failed" in line_lower or "[embedded run tool end: " in line and "error" in line_lower:
|
||||
is_error = True
|
||||
category = "TOOL"
|
||||
elif "mcp" in line_lower and ("exit" in line_lower or "crashed" in line_lower or "closed with code" in line_lower or "failed to start" in line_lower):
|
||||
is_error = True
|
||||
category = "MCP_SERVER"
|
||||
elif "unhandledrejection" in line_lower or "uncaughtexception" in line_lower or "traceback (most recent call last)" in line_lower or "fatal error" in line_lower:
|
||||
is_error = True
|
||||
category = "RUNTIME_EXCEPTION"
|
||||
elif any(f"status={code}" in line for code in ["500", "502", "503", "504", "429"]) or ("model-fetch" in line_lower and ("failed" in line_lower or "timed out" in line_lower)):
|
||||
is_error = True
|
||||
category = "LLM_PROVIDER"
|
||||
elif " [error] " in line_lower or "level=error" in line_lower or "econnrefused" in line_lower or "etimedout" in line_lower:
|
||||
is_error = True
|
||||
category = "SYSTEM_ERROR"
|
||||
|
||||
if is_error:
|
||||
# Extract timestamp if present at beginning of line
|
||||
ts_match = re.match(r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[^\s]*)", line)
|
||||
ts = ts_match.group(1) if ts_match else ""
|
||||
|
||||
sig = normalize_error(line)
|
||||
cluster = error_clusters[sig]
|
||||
cluster["signature"] = sig
|
||||
cluster["category"] = category
|
||||
cluster["count"] += 1
|
||||
cluster["sample"] = line[:400]
|
||||
if ts:
|
||||
if not cluster["first_seen"] or ts < cluster["first_seen"]:
|
||||
cluster["first_seen"] = ts
|
||||
if not cluster["last_seen"] or ts > cluster["last_seen"]:
|
||||
cluster["last_seen"] = ts
|
||||
|
||||
# Convert to sorted list by frequency
|
||||
sorted_clusters = sorted(error_clusters.values(), key=lambda x: x["count"], reverse=True)
|
||||
return sorted_clusters
|
||||
|
||||
|
||||
def parse_session_transcripts(sessions_dir: Path, since_hours: float = 168.0) -> List[Dict[str, Any]]:
|
||||
"""Scan and parse JSONL session transcripts for tool errors."""
|
||||
if not sessions_dir or not sessions_dir.exists():
|
||||
return []
|
||||
|
||||
cutoff = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(hours=since_hours)
|
||||
tool_failures: Dict[str, Dict[str, Any]] = defaultdict(lambda: {
|
||||
"agent": "",
|
||||
"tool": "",
|
||||
"signature": "",
|
||||
"count": 0,
|
||||
"sample": "",
|
||||
"first_seen": None,
|
||||
"last_seen": None,
|
||||
})
|
||||
|
||||
# Sessions are stored at <agent>/sessions/*.jsonl
|
||||
for jsonl_file in sessions_dir.glob("*/sessions/*.jsonl"):
|
||||
try:
|
||||
mtime = datetime.datetime.fromtimestamp(jsonl_file.stat().st_mtime, tz=datetime.timezone.utc)
|
||||
if mtime < cutoff:
|
||||
continue
|
||||
|
||||
agent_name = jsonl_file.parent.parent.name
|
||||
|
||||
with open(jsonl_file, "r", encoding="utf-8", errors="replace") as f:
|
||||
for line in f:
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
record = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Check for tool errors in various OpenClaw transcript representations
|
||||
role = record.get("role")
|
||||
msg_type = record.get("type")
|
||||
is_err = record.get("isError", False)
|
||||
content = record.get("content", "")
|
||||
|
||||
if isinstance(content, list):
|
||||
content_str = json.dumps(content)
|
||||
else:
|
||||
content_str = str(content)
|
||||
|
||||
# Detect failure markers
|
||||
has_error_keyword = any(k in content_str.lower() for k in [
|
||||
"tool failed",
|
||||
"error:",
|
||||
"traceback",
|
||||
"validationerror",
|
||||
"status code 5",
|
||||
"connection refused",
|
||||
"timeout",
|
||||
])
|
||||
|
||||
if role == "tool" and (is_err or has_error_keyword):
|
||||
tool_name = record.get("name") or record.get("tool") or "unknown_tool"
|
||||
sig = f"[{agent_name}] {tool_name}: {normalize_error(content_str)}"
|
||||
entry = tool_failures[sig]
|
||||
entry["agent"] = agent_name
|
||||
entry["tool"] = tool_name
|
||||
entry["signature"] = sig
|
||||
entry["count"] += 1
|
||||
entry["sample"] = content_str[:300]
|
||||
ts = record.get("createdAt") or record.get("timestamp") or mtime.isoformat()
|
||||
if not entry["first_seen"] or ts < entry["first_seen"]:
|
||||
entry["first_seen"] = ts
|
||||
if not entry["last_seen"] or ts > entry["last_seen"]:
|
||||
entry["last_seen"] = ts
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return sorted(tool_failures.values(), key=lambda x: x["count"], reverse=True)
|
||||
|
||||
|
||||
def build_audit_report(
|
||||
since: str,
|
||||
repos_commits: Dict[str, List[Dict[str, str]]],
|
||||
container_errors: List[Dict[str, Any]],
|
||||
session_errors: List[Dict[str, Any]],
|
||||
) -> Dict[str, Any]:
|
||||
"""Compile structured audit report from all inputs."""
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
year, week, _ = now.isocalendar()
|
||||
|
||||
total_container_errors = sum(c["count"] for c in container_errors)
|
||||
total_session_tool_errors = sum(s["count"] for s in session_errors)
|
||||
|
||||
# Determine severity
|
||||
has_critical = any(c["category"] in ["MCP_SERVER", "RUNTIME_EXCEPTION"] and c["count"] >= 3 for c in container_errors)
|
||||
has_high = total_session_tool_errors > 5 or total_container_errors > 20
|
||||
|
||||
if has_critical:
|
||||
status = "CRITICAL"
|
||||
elif has_high:
|
||||
status = "DEGRADED"
|
||||
elif total_container_errors > 0 or total_session_tool_errors > 0:
|
||||
status = "WARNING"
|
||||
else:
|
||||
status = "HEALTHY"
|
||||
|
||||
return {
|
||||
"year": year,
|
||||
"week": week,
|
||||
"generated_at": now.isoformat(),
|
||||
"since": since,
|
||||
"status": status,
|
||||
"total_container_errors": total_container_errors,
|
||||
"total_session_tool_errors": total_session_tool_errors,
|
||||
"repos_commits": repos_commits,
|
||||
"container_errors": container_errors,
|
||||
"session_errors": session_errors,
|
||||
}
|
||||
|
||||
|
||||
def render_markdown_report(report: Dict[str, Any]) -> str:
|
||||
"""Format audit report into clean Markdown with YAML frontmatter."""
|
||||
year = report["year"]
|
||||
week = report["week"]
|
||||
status = report["status"]
|
||||
status_emoji = {
|
||||
"HEALTHY": "🟢",
|
||||
"WARNING": "🟡",
|
||||
"DEGRADED": "🟠",
|
||||
"CRITICAL": "🔴",
|
||||
}.get(status, "⚪")
|
||||
|
||||
lines = [
|
||||
"---",
|
||||
f'title: "Weekly OpenClaw System Audit {year}-W{week:02d}"',
|
||||
f'date: "{report["generated_at"]}"',
|
||||
f'status: "{status.lower()}"',
|
||||
f'evaluated_window: "{report["since"]}"',
|
||||
"type: audit-report",
|
||||
"---",
|
||||
"",
|
||||
f"# {status_emoji} OpenClaw System Health Audit — {year}-W{week:02d}",
|
||||
"",
|
||||
f"- **Generated At**: `{report['generated_at']}`",
|
||||
f"- **Audit Window**: Last `{report['since']}`",
|
||||
f"- **Overall System State**: **{status}**",
|
||||
f"- **Container Log Errors**: `{report['total_container_errors']}` occurrences",
|
||||
f"- **Session Tool Failures**: `{report['total_session_tool_errors']}` occurrences",
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
"## 🗂️ Recent Repository Commits Timeline",
|
||||
"",
|
||||
"Cross-repository correlation of recent code and configuration modifications:",
|
||||
"",
|
||||
]
|
||||
|
||||
repos = report.get("repos_commits", {})
|
||||
for repo_name, commits in repos.items():
|
||||
lines.append(f"### 📦 `{repo_name}` ({len(commits)} commits)")
|
||||
if not commits:
|
||||
lines.append("_No commits within this audit window._\n")
|
||||
continue
|
||||
lines.append("| Hash | Date | Author | Message |")
|
||||
lines.append("|---|---|---|---|")
|
||||
for c in commits[:8]:
|
||||
msg = c['subject'].replace("|", "\\|")
|
||||
lines.append(f"| `{c['hash']}` | {c['date'][:10]} | {c['author']} | {msg} |")
|
||||
lines.append("")
|
||||
|
||||
lines.extend([
|
||||
"---",
|
||||
"",
|
||||
"## 🛠️ Session Transcript Tool Failures",
|
||||
"",
|
||||
"Tool invocation errors across subagent conversations (`/home/node/.openclaw/agents/*/sessions/`):",
|
||||
"",
|
||||
])
|
||||
|
||||
session_errs = report.get("session_errors", [])
|
||||
if not session_errs:
|
||||
lines.append("✅ **No tool failures detected in agent session transcripts.**\n")
|
||||
else:
|
||||
lines.append("| Agent | Tool | Count | Sample Error |")
|
||||
lines.append("|---|---|---|---|")
|
||||
for s in session_errs[:15]:
|
||||
sample_clean = s['sample'].replace("|", "\\|").replace("\n", " ")[:120]
|
||||
lines.append(f"| `{s['agent']}` | `{s['tool']}` | **{s['count']}** | `{sample_clean}` |")
|
||||
lines.append("")
|
||||
|
||||
lines.extend([
|
||||
"---",
|
||||
"",
|
||||
"## 🖥️ Container Runtime & Gateway Log Anomalies",
|
||||
"",
|
||||
"Aggregated errors from OpenClaw pod stdout (`kubectl logs -n openclaw openclaw-0 -c openclaw`):",
|
||||
"",
|
||||
])
|
||||
|
||||
container_errs = report.get("container_errors", [])
|
||||
if not container_errs:
|
||||
lines.append("✅ **No runtime exceptions or container errors detected.**\n")
|
||||
else:
|
||||
lines.append("| Category | Count | Normalized Signature | Sample |")
|
||||
lines.append("|---|---|---|---|")
|
||||
for c in container_errs[:15]:
|
||||
sig_clean = c['signature'].replace("|", "\\|").replace("\n", " ")[:100]
|
||||
sample_clean = c['sample'].replace("|", "\\|").replace("\n", " ")[:100]
|
||||
lines.append(f"| `{c['category']}` | **{c['count']}** | `{sig_clean}` | `{sample_clean}` |")
|
||||
lines.append("")
|
||||
|
||||
lines.extend([
|
||||
"---",
|
||||
"",
|
||||
"## 🎯 Actionable Antigravity Recommendations",
|
||||
"",
|
||||
])
|
||||
|
||||
if status == "HEALTHY":
|
||||
lines.append("All services, MCP bridges, and subagent tools operate within healthy parameters. No remediation required.")
|
||||
else:
|
||||
lines.append("The following candidate fixes should be investigated and resolved:")
|
||||
# List top issues
|
||||
for idx, s in enumerate(session_errs[:3], start=1):
|
||||
lines.append(f"{idx}. **Fix Tool Failure in `{s['agent']}` / `{s['tool']}`**: `{s['signature'][:140]}`")
|
||||
for idx, c in enumerate(container_errs[:3], start=len(session_errs[:3]) + 1):
|
||||
lines.append(f"{idx}. **Resolve `{c['category']}` Anomaly**: `{c['signature'][:140]}`")
|
||||
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def update_tasks_antigravity(wiki_dir: Path, report: Dict[str, Any]) -> List[str]:
|
||||
"""Append actionable issues to tasks/antigravity.md under ## 📥 Pending Tasks."""
|
||||
tasks_file = wiki_dir / "tasks" / "antigravity.md"
|
||||
if not tasks_file.exists():
|
||||
return []
|
||||
|
||||
try:
|
||||
content = tasks_file.read_text(encoding="utf-8")
|
||||
except Exception as e:
|
||||
print(f"Error reading {tasks_file}: {e}", file=sys.stderr)
|
||||
return []
|
||||
|
||||
added_tasks = []
|
||||
today = datetime.datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
# Combine top session and container errors
|
||||
top_candidates = []
|
||||
for s in report.get("session_errors", []):
|
||||
if s["count"] >= 2:
|
||||
top_candidates.append({
|
||||
"title": f"Fix Tool Failure: {s['agent']} / {s['tool']}",
|
||||
"detail": f"Observed {s['count']} times in session transcripts. Sample: {s['sample'][:250]}",
|
||||
"key": f"{s['agent']}:{s['tool']}",
|
||||
})
|
||||
|
||||
for c in report.get("container_errors", []):
|
||||
if c["count"] >= 3 and c["category"] in ["MCP_SERVER", "RUNTIME_EXCEPTION", "TOOL"]:
|
||||
top_candidates.append({
|
||||
"title": f"Resolve {c['category']} Error in OpenClaw Gateway",
|
||||
"detail": f"Observed {c['count']} times in pod logs. Signature: {c['signature'][:200]}",
|
||||
"key": c["signature"][:40],
|
||||
})
|
||||
|
||||
# Only append if not already mentioned in tasks file
|
||||
pending_anchor = "## 📥 Pending Tasks\n"
|
||||
if pending_anchor not in content:
|
||||
return []
|
||||
|
||||
new_task_blocks = []
|
||||
for cand in top_candidates:
|
||||
# Check deduplication
|
||||
if cand["title"] in content or cand["key"] in content:
|
||||
continue
|
||||
|
||||
block = (
|
||||
f"- [ ] **{today}: {cand['title']}**\n"
|
||||
f" - **Details**: Detected by IT Guy weekly self-audit (`audit_openclaw.py`). {cand['detail']}\n"
|
||||
f" - **Target**: OpenClaw Gateway / MCP / Subagent configuration\n"
|
||||
f" - **Requested by**: IT Guy Agent (Autonomous Weekly Audit)\n"
|
||||
)
|
||||
new_task_blocks.append(block)
|
||||
added_tasks.append(cand["title"])
|
||||
|
||||
if new_task_blocks:
|
||||
parts = content.split(pending_anchor, 1)
|
||||
updated_content = parts[0] + pending_anchor + "\n" + "\n".join(new_task_blocks) + "\n" + parts[1]
|
||||
tasks_file.write_text(updated_content, encoding="utf-8")
|
||||
|
||||
return added_tasks
|
||||
|
||||
|
||||
def git_commit_and_push(wiki_dir: Path, commit_msg: str) -> bool:
|
||||
"""Commit and push changes in knowledge-wiki repository."""
|
||||
rc1, _, _ = run_cmd(["git", "-C", str(wiki_dir), "add", "-A"])
|
||||
if rc1 != 0:
|
||||
return False
|
||||
rc2, _, _ = run_cmd(["git", "-C", str(wiki_dir), "commit", "-m", commit_msg])
|
||||
# rc 1 can mean nothing to commit
|
||||
rc3, _, _ = run_cmd(["git", "-C", str(wiki_dir), "push", "origin", "main"])
|
||||
return rc3 == 0
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="OpenClaw System, Log & Repository Self-Audit Engine")
|
||||
parser.add_argument("--since", default="7d", help="Audit time window (e.g. 7d, 24h, 168h)")
|
||||
parser.add_argument("--write-report", action="store_true", help="Save markdown report to operations/audits/ in knowledge-wiki")
|
||||
parser.add_argument("--add-task", action="store_true", help="Append new actionable issues to tasks/antigravity.md")
|
||||
parser.add_argument("--git-push", action="store_true", help="Commit and push changes to knowledge-wiki git remote")
|
||||
parser.add_argument("--json", action="store_true", help="Output machine-readable JSON to stdout")
|
||||
parser.add_argument("--infrapuzzle-path", help="Path to infrapuzzle repo override")
|
||||
parser.add_argument("--mop-skills-path", help="Path to mop-skills repo override")
|
||||
parser.add_argument("--wiki-path", help="Path to knowledge-wiki repo override")
|
||||
parser.add_argument("--sessions-dir", help="Path to OpenClaw sessions directory override")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Convert since string to hours for file timestamps
|
||||
since_hours = 168.0
|
||||
if args.since.endswith("d"):
|
||||
since_hours = float(args.since[:-1]) * 24.0
|
||||
elif args.since.endswith("h"):
|
||||
since_hours = float(args.since[:-1])
|
||||
|
||||
# 1. Resolve Repositories
|
||||
repos = {
|
||||
"infrapuzzle": resolve_repo_path("infrapuzzle", args.infrapuzzle_path),
|
||||
"mop-skills": resolve_repo_path("mop-skills", getattr(args, "mop_skills_path", None)),
|
||||
"knowledge-wiki": resolve_wiki_dir(args.wiki_path),
|
||||
}
|
||||
|
||||
# 2. Correlate Git Commits
|
||||
repos_commits = {}
|
||||
for r_name, r_path in repos.items():
|
||||
if r_path:
|
||||
repos_commits[r_name] = get_git_commits(r_path, args.since)
|
||||
else:
|
||||
repos_commits[r_name] = []
|
||||
|
||||
# 3. Ingest Container Logs
|
||||
raw_logs = fetch_container_logs(args.since)
|
||||
container_errors = parse_container_logs(raw_logs)
|
||||
|
||||
# 4. Ingest Session Transcripts
|
||||
sessions_dir = resolve_sessions_dir(args.sessions_dir)
|
||||
session_errors = parse_session_transcripts(sessions_dir, since_hours) if sessions_dir else []
|
||||
|
||||
# 5. Build Report
|
||||
report = build_audit_report(args.since, repos_commits, container_errors, session_errors)
|
||||
|
||||
# 6. JSON output mode
|
||||
if args.json:
|
||||
print(json.dumps(report, indent=2))
|
||||
return
|
||||
|
||||
# 7. Render Markdown
|
||||
md_report = render_markdown_report(report)
|
||||
|
||||
# 8. Write Report if requested
|
||||
wiki_dir = repos.get("knowledge-wiki")
|
||||
if args.write_report and wiki_dir:
|
||||
audits_dir = wiki_dir / "operations" / "audits"
|
||||
audits_dir.mkdir(parents=True, exist_ok=True)
|
||||
report_path = audits_dir / f"{report['year']}-W{report['week']:02d}.md"
|
||||
report_path.write_text(md_report, encoding="utf-8")
|
||||
print(f"✅ Audit report saved: {report_path}")
|
||||
|
||||
# 9. Append to tasks/antigravity.md if requested
|
||||
added_tasks = []
|
||||
if args.add_task and wiki_dir:
|
||||
added_tasks = update_tasks_antigravity(wiki_dir, report)
|
||||
if added_tasks:
|
||||
print(f"📋 Added {len(added_tasks)} new task(s) to tasks/antigravity.md:")
|
||||
for t in added_tasks:
|
||||
print(f" - {t}")
|
||||
else:
|
||||
print("📋 No new tasks required (all issues healthy or already queued).")
|
||||
|
||||
# 10. Commit & Push if requested
|
||||
if args.git_push and wiki_dir and (args.write_report or added_tasks):
|
||||
success = git_commit_and_push(wiki_dir, f"chore(audit): weekly health audit {report['year']}-W{report['week']:02d}")
|
||||
if success:
|
||||
print("🚀 Pushed audit report and task updates to git.moritzgraf.de!")
|
||||
else:
|
||||
print("⚠️ Git push skipped or failed (check credentials/remote).")
|
||||
|
||||
# Print summary to stdout
|
||||
print(md_report)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,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 |
Executable
+122
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# dependencies = [
|
||||
# "qrcode[pil]>=7.4",
|
||||
# "pillow>=10.0.0",
|
||||
# ]
|
||||
# ///
|
||||
"""
|
||||
Haumdaucher QR Code Generator with Centered Logo.
|
||||
Encodes any URL or string into a high-error-correction QR code (ERROR_CORRECT_H),
|
||||
embeds the Haumdaucher logo in the center with a clean white rounded badge,
|
||||
and outputs MEDIA:<path> for direct Telegram inline photo delivery.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from PIL import Image, ImageDraw
|
||||
import qrcode
|
||||
from qrcode.constants import ERROR_CORRECT_H
|
||||
|
||||
DEFAULT_LOGO_PATH = Path(__file__).resolve().parent.parent / "assets" / "haumdaucher_logo.png"
|
||||
|
||||
def generate_qr_code(text: str, output_path: str = None, logo_path: str = None) -> str:
|
||||
if not text:
|
||||
raise ValueError("Text/URL to encode cannot be empty.")
|
||||
|
||||
if not output_path:
|
||||
text_hash = hashlib.sha256(text.encode("utf-8")).hexdigest()[:8]
|
||||
output_path = f"/tmp/qr_{text_hash}.png"
|
||||
|
||||
# Resolve logo path
|
||||
logo_file = Path(logo_path) if logo_path else DEFAULT_LOGO_PATH
|
||||
if not logo_file.exists():
|
||||
# Fallback to current script dir or assets if relative path differs
|
||||
logo_file = DEFAULT_LOGO_PATH
|
||||
|
||||
# Initialize QR code generator with High error correction (30% redundancy)
|
||||
qr = qrcode.QRCode(
|
||||
version=None, # Auto-detect best version
|
||||
error_correction=ERROR_CORRECT_H,
|
||||
box_size=12,
|
||||
border=4,
|
||||
)
|
||||
qr.add_data(text)
|
||||
qr.make(fit=True)
|
||||
|
||||
# Generate base QR code image (RGBA for clean compositing)
|
||||
qr_img = qr.make_image(fill_color="black", back_color="white").convert("RGBA")
|
||||
qr_width, qr_height = qr_img.size
|
||||
|
||||
# Overlay logo if available
|
||||
if logo_file.exists():
|
||||
try:
|
||||
logo = Image.open(logo_file).convert("RGBA")
|
||||
|
||||
# Logo should occupy roughly 22% of the QR code dimensions to ensure readability
|
||||
max_logo_size = int(qr_width * 0.23)
|
||||
|
||||
# Maintain aspect ratio
|
||||
logo_w, logo_h = logo.size
|
||||
scale = min(max_logo_size / logo_w, max_logo_size / logo_h)
|
||||
new_w = int(logo_w * scale)
|
||||
new_h = int(logo_h * scale)
|
||||
logo = logo.resize((new_w, new_h), Image.Resampling.LANCZOS)
|
||||
|
||||
# Create a white rounded badge behind the logo for contrast and module separation
|
||||
badge_pad = 8
|
||||
badge_w = new_w + badge_pad * 2
|
||||
badge_h = new_h + badge_pad * 2
|
||||
badge = Image.new("RGBA", (badge_w, badge_h), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(badge)
|
||||
radius = 12
|
||||
draw.rounded_rectangle(
|
||||
[(0, 0), (badge_w - 1, badge_h - 1)],
|
||||
radius=radius,
|
||||
fill=(255, 255, 255, 255),
|
||||
outline=(220, 220, 220, 255),
|
||||
width=1,
|
||||
)
|
||||
|
||||
# Paste logo onto the white badge
|
||||
badge.paste(logo, (badge_pad, badge_pad), mask=logo)
|
||||
|
||||
# Paste badge into the center of the QR code
|
||||
pos_x = (qr_width - badge_w) // 2
|
||||
pos_y = (qr_height - badge_h) // 2
|
||||
qr_img.paste(badge, (pos_x, pos_y), mask=badge)
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"Warning: could not overlay logo: {e}\n")
|
||||
|
||||
# Ensure output directory exists and save
|
||||
out_file = Path(output_path)
|
||||
out_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
qr_img.save(str(out_file), format="PNG")
|
||||
|
||||
return str(out_file)
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Generate a Haumdaucher-branded QR code.")
|
||||
parser.add_argument("text", nargs="?", help="Text or URL to encode into the QR code")
|
||||
parser.add_argument("--text", "-t", dest="text_opt", help="Text or URL to encode")
|
||||
parser.add_argument("--output", "-o", help="Output file path (default: /tmp/qr_<hash>.png)")
|
||||
parser.add_argument("--logo", "-l", help="Custom path to center logo PNG")
|
||||
parser.add_argument("--quiet", "-q", action="store_true", help="Only output MEDIA tag")
|
||||
|
||||
args = parser.parse_args()
|
||||
raw_text = args.text or args.text_opt
|
||||
if not raw_text:
|
||||
parser.error("You must provide text or a URL to encode.")
|
||||
|
||||
out_path = generate_qr_code(raw_text, output_path=args.output, logo_path=args.logo)
|
||||
|
||||
# Standard OpenClaw / Telegram media dispatch tag
|
||||
print(f"MEDIA:{out_path}")
|
||||
if not args.quiet:
|
||||
sys.stderr.write(f"QR code successfully generated for: {raw_text}\nSaved to: {out_path}\n")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
name: regensburg-stadtrat
|
||||
description: Municipal politics, city council resolutions, meeting agendas, and Ratsinformation for the City of Regensburg (ALLRIS net). Use when the user asks about Regensburg city council politics, upcoming council or committee meetings (Stadtrat, Ausschüsse), local building/traffic decisions (e.g. Stadtbahn, Kaufhof, Radwege, Schulen), or specific Vorlagen and Beschlüsse.
|
||||
---
|
||||
|
||||
# 🏛️ Regensburg Stadtrat & Ratsinformationssystem (ALLRIS)
|
||||
|
||||
Query the Regensburg municipal council information system (**ALLRIS net** on `srv19.regensburg.de/bi/`) for real-time meeting schedules, committee agendas, council proposals (*Vorlagen*), and binding resolutions (*Beschlüsse*).
|
||||
|
||||
## 🚀 Quick Usage
|
||||
|
||||
Use `scripts/regensburg_ris.py`. The tool auto-executes via `uv` and interfaces directly with ALLRIS XML feeds and search endpoints.
|
||||
|
||||
### 1. Search Council Documents & Resolutions
|
||||
Search by keyword (e.g., "Kaufhof", "Stadtbahn", "Haushalt", "Klimaschutz"):
|
||||
```bash
|
||||
python3 scripts/regensburg_ris.py search "Kaufhof" --limit 10
|
||||
```
|
||||
|
||||
### 2. View Meeting Calendar (Upcoming or Recent)
|
||||
See what council or committee meetings are scheduled:
|
||||
```bash
|
||||
# Next 30 days
|
||||
python3 scripts/regensburg_ris.py calendar --days 30
|
||||
|
||||
# Specific date range
|
||||
python3 scripts/regensburg_ris.py calendar --from-date 01.09.2026 --to-date 30.09.2026
|
||||
```
|
||||
|
||||
### 3. Inspect a Meeting Agenda (TOPs)
|
||||
Fetch all agenda items (Tagesordnungspunkte) and linked proposal IDs for a meeting ID (`SILFDNR`):
|
||||
```bash
|
||||
python3 scripts/regensburg_ris.py session 2665
|
||||
```
|
||||
|
||||
### 4. Read a Vorlage & Extract Resolutions
|
||||
View proposal metadata, responsible departments, voting results, and exact resolution text:
|
||||
```bash
|
||||
python3 scripts/regensburg_ris.py vorlage 21362
|
||||
```
|
||||
|
||||
To automatically download and convert linked PDF attachments to Markdown using the `markitdown` skill:
|
||||
```bash
|
||||
python3 scripts/regensburg_ris.py vorlage 21362 --convert-attachments
|
||||
```
|
||||
|
||||
### 5. Compile a Topic Dossier for Knowledge-Wiki
|
||||
Compile a multi-document synthesis and automatically file it into the Obsidian `knowledge-wiki`:
|
||||
```bash
|
||||
python3 scripts/regensburg_ris.py compile-topic "Kaufhof" --vault /Users/moritz/src/infrapuzzle/knowledge-wiki
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🏛️ Major Committees in Regensburg
|
||||
- **Stadtrat der Stadt Regensburg**: Full city council plenum chaired by Oberbürgermeisterin Gertrud Maltz-Schwarzfischer.
|
||||
- **Ausschuss für Stadtplanung, Verkehr und Wohnungsfragen**: Urban development, mobility, zoning, housing.
|
||||
- **Ausschuss für Verwaltung, Finanzen und Beteiligungen**: Budget, municipal companies, personnel.
|
||||
- **Ausschuss für Umweltfragen, Natur- und Klimaschutz**: Environmental policy, energy, climate transition.
|
||||
- **Ausschuss für Wirtschaft**: Local business, commerce, downtown vitality.
|
||||
- **Sportausschuss / Kulturausschuss / Ausschuss für Bildung**: Sports, culture, schools, and daycares.
|
||||
|
||||
---
|
||||
|
||||
## 💡 Best Practices for OpenClaw
|
||||
- **Always Cite Sources**: Provide the exact Vorlagen code (e.g. `VO/25/21865/61`) and direct ALLRIS URL in responses.
|
||||
- **Check Resolution Outcomes**: When examining controversial topics or motions, check whether an Antrag was *zurückgezogen* (withdrawn), *durch Bericht erledigt* (settled by report), or *beschlossen* (enacted).
|
||||
- **Combine with `markitdown`**: When deep technical details (such as urban planning maps, budget numbers, or expert appraisals) are in PDF attachments, pass them to `skills/markitdown/scripts/convert_to_markdown.py`.
|
||||
+506
@@ -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
@@ -0,0 +1,233 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# dependencies = [
|
||||
# "beautifulsoup4>=4.12.0",
|
||||
# "requests>=2.31.0",
|
||||
# ]
|
||||
# ///
|
||||
"""
|
||||
Regensburg Stadtrat (ALLRIS) MCP Server
|
||||
Exposes municipal council documents, upcoming meetings, agenda items (TOPs),
|
||||
and binding resolutions as Model Context Protocol (MCP) tools over stdio.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import traceback
|
||||
|
||||
# Ensure current directory is on sys.path to import AllrisClient
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from regensburg_ris import AllrisClient
|
||||
|
||||
TOOLS = [
|
||||
{
|
||||
"name": "stadtrat_search",
|
||||
"description": "Search Regensburg city council documents, resolutions (Beschlüsse), and motions (Vorlagen) by keyword (e.g. 'Kaufhof', 'Protzenweiherbrücke', 'Stadtbahn', 'Haushalt').",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Keyword to search in ALLRIS net (e.g. 'Brücke', 'Kaufhof', 'Verkehr')."
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of results to return (default: 10).",
|
||||
"default": 10
|
||||
}
|
||||
},
|
||||
"required": ["query"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "stadtrat_calendar",
|
||||
"description": "Fetch upcoming municipal council and committee meetings (Stadtrat, Ausschüsse) in Regensburg.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"days": {
|
||||
"type": "integer",
|
||||
"description": "Number of days in the future to search (default: 30).",
|
||||
"default": 30
|
||||
},
|
||||
"from_date": {
|
||||
"type": "string",
|
||||
"description": "Start date in DD.MM.YYYY format (default: today)."
|
||||
},
|
||||
"to_date": {
|
||||
"type": "string",
|
||||
"description": "End date in DD.MM.YYYY format."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "stadtrat_session",
|
||||
"description": "Fetch detailed meeting agenda items (Tagesordnungspunkte / TOPs) and linked Vorlagen IDs for a meeting ID (SILFDNR).",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"session_id": {
|
||||
"type": "string",
|
||||
"description": "ALLRIS session ID (SILFDNR, e.g. '2665')."
|
||||
}
|
||||
},
|
||||
"required": ["session_id"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "stadtrat_vorlage",
|
||||
"description": "Inspect a specific council proposal (Vorlage) by VOLFDNR ID: extracts voting outcomes, responsible department, and verbatim resolution text.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"vorlage_id": {
|
||||
"type": "string",
|
||||
"description": "ALLRIS proposal ID (VOLFDNR, e.g. '21362')."
|
||||
},
|
||||
"convert_attachments": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to convert linked PDF attachments to Markdown (default: false).",
|
||||
"default": False
|
||||
}
|
||||
},
|
||||
"required": ["vorlage_id"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "stadtrat_compile_topic",
|
||||
"description": "Compile a multi-document synthesis of a topic across ALLRIS into the Obsidian knowledge-wiki.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"topic": {
|
||||
"type": "string",
|
||||
"description": "Topic keyword (e.g. 'Protzenweiherbrücke', 'Kaufhof')."
|
||||
},
|
||||
"vault": {
|
||||
"type": "string",
|
||||
"description": "Path to the Obsidian knowledge-wiki vault.",
|
||||
"default": "/home/node/.openclaw/wiki/main"
|
||||
}
|
||||
},
|
||||
"required": ["topic"]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def run_mcp_server():
|
||||
client = AllrisClient()
|
||||
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
req = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
method = req.get("method")
|
||||
msg_id = req.get("id")
|
||||
params = req.get("params", {})
|
||||
|
||||
if method == "initialize":
|
||||
resp = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": msg_id,
|
||||
"result": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"serverInfo": {
|
||||
"name": "regensburg-stadtrat-mcp",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"capabilities": {
|
||||
"tools": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
sys.stdout.write(json.dumps(resp) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
elif method == "notifications/initialized":
|
||||
pass
|
||||
|
||||
elif method == "tools/list":
|
||||
resp = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": msg_id,
|
||||
"result": {
|
||||
"tools": TOOLS
|
||||
}
|
||||
}
|
||||
sys.stdout.write(json.dumps(resp) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
elif method == "tools/call":
|
||||
tool_name = params.get("name")
|
||||
args = params.get("arguments", {})
|
||||
try:
|
||||
if tool_name == "stadtrat_search":
|
||||
data = client.search(
|
||||
query=args.get("query", ""),
|
||||
limit=int(args.get("limit", 10))
|
||||
)
|
||||
elif tool_name == "stadtrat_calendar":
|
||||
data = client.get_calendar(
|
||||
days=int(args.get("days", 30)),
|
||||
from_date=args.get("from_date"),
|
||||
to_date=args.get("to_date")
|
||||
)
|
||||
elif tool_name == "stadtrat_session":
|
||||
data = client.get_session(
|
||||
silfdnr=str(args.get("session_id", ""))
|
||||
)
|
||||
elif tool_name == "stadtrat_vorlage":
|
||||
data = client.get_vorlage(
|
||||
volfdnr=str(args.get("vorlage_id", "")),
|
||||
convert_attachments=bool(args.get("convert_attachments", False))
|
||||
)
|
||||
elif tool_name == "stadtrat_compile_topic":
|
||||
vault_path = args.get("vault", "/home/node/.openclaw/wiki/main")
|
||||
data = client.compile_topic(
|
||||
topic=args.get("topic", ""),
|
||||
vault=vault_path
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unknown tool: {tool_name}")
|
||||
|
||||
text_result = json.dumps(data, indent=2, ensure_ascii=False)
|
||||
resp = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": msg_id,
|
||||
"result": {
|
||||
"content": [{"type": "text", "text": text_result}]
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
resp = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": msg_id,
|
||||
"result": {
|
||||
"isError": True,
|
||||
"content": [{"type": "text", "text": f"Error executing {tool_name}: {str(e)}\n{traceback.format_exc()}"}]
|
||||
}
|
||||
}
|
||||
sys.stdout.write(json.dumps(resp) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
else:
|
||||
if msg_id is not None:
|
||||
resp = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": msg_id,
|
||||
"error": {"code": -32601, "message": f"Method not found: {method}"}
|
||||
}
|
||||
sys.stdout.write(json.dumps(resp) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_mcp_server()
|
||||
@@ -0,0 +1,132 @@
|
||||
---
|
||||
name: shopping
|
||||
description: Intelligent shopping advisory, price comparisons across German supermarkets (Edeka, Rewe, Aldi, Lidl, Kaufland, Netto, Penny, dm, Rossmann), Geizhals retail best prices & price history, eBay marketplace searches, and Bring! shopping list management (Einkaufsladen, Baumarkt, Ikea, Gewuerzeinkaufsliste). Use when the user asks for shopping advice, supermarket offers, adding items to their shopping lists, or checking off items.
|
||||
---
|
||||
|
||||
# 🛒 Smart Shopper & Vorrats-Concierge
|
||||
|
||||
Comprehensive shopping advisor for the Graf family. Combines live German supermarket weekly brochures (Regensburg default), Geizhals retail price comparison & price histories, and eBay marketplace searches.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ MCP Tools Overview
|
||||
|
||||
### 1. Supermarket & Grocery Deals (`supermarket`)
|
||||
- **`supermarket_search(query, city="Regensburg", lat=49.0134, lng=12.1016)`**:
|
||||
- Searches live weekly offers and digital brochures across Edeka, Rewe, Aldi Süd, Lidl, Kaufland, Netto Marken-Discount, Penny, dm, Rossmann, V-Markt, Müller.
|
||||
- Returns price in EUR, retailer, brand, validity dates, distance in km, and brochure page.
|
||||
- **`supermarket_compare_staples(staples=["Mutti", "Oro di Parma"], city="Regensburg")`**:
|
||||
- Batch search for multiple staple brands or product categories to compare discounts across all local supermarkets in one view.
|
||||
|
||||
### 2. Retail Price Comparison & History (`geizhals`)
|
||||
- **`geizhals_search(query, country="de", limit=8)`**:
|
||||
- Full-text search across Geizhals.de (and .at). Returns the best price, average price, offer count across all web shops, cheapest shop name, star ratings, and test review count & metascore.
|
||||
- **`geizhals_price_history(product_id, days=31, country="de")`**:
|
||||
- Analyzes 31, 91, 183, or 365-day price trends. Reports `period_min_price`, `current_best_price`, and flags if it is an `is_all_time_low`.
|
||||
|
||||
### 3. Marketplace & Second-Hand (`ebay`)
|
||||
- **`ebay_search(query, condition, buying_format, min_price, max_price, limit=10)`**:
|
||||
- Searches eBay.de using the official Browse API. Supports filtering by condition (`used`/`refurbished`/`new`) and buying format (`fixed_price`/`auction`).
|
||||
- **`ebay_get_item(item_id)`**:
|
||||
- Fetches listing details, photos, location, and seller reputation.
|
||||
|
||||
### 4. Bring! Shopping Lists (`bring`)
|
||||
- **`bring_add_item(item_name, specification="", list_name="Einkaufsladen")`**:
|
||||
- Adds an item with optional quantity/specification (e.g. `specification="2L"`, `"100 Stück"`) to the target list.
|
||||
- **`bring_add_batch(items=[{"name": "...", "specification": "..."}], list_name="Einkaufsladen")`**:
|
||||
- Adds multiple items at once (ideal for recipe ingredients or bulk additions).
|
||||
- **`bring_get_items(list_name="Einkaufsladen")`**:
|
||||
- Retrieves active to-buy items and recently bought items for the specified list.
|
||||
- **`bring_complete_item(item_name, list_name="Einkaufsladen")`**:
|
||||
- Marks an item as purchased/completed (moves it to "recently bought").
|
||||
- **`bring_remove_item(item_name, list_name="Einkaufsladen")`**:
|
||||
- Completely deletes an item from the list.
|
||||
- **`bring_list_lists()`**:
|
||||
- Lists all Bring! shopping lists with their UUIDs and item counts.
|
||||
|
||||
---
|
||||
|
||||
## 🍅 Vorratskauf & Supermarkt Protocol
|
||||
|
||||
When the user asks for groceries, weekly offers, or staples to stock up on:
|
||||
|
||||
### 1. Multi-Brand / Equivalency Queries (e.g. „Mutti oder Oro Tomatensauce“)
|
||||
- Always run a comparative search (using `supermarket_compare_staples` or parallel queries).
|
||||
- Group results by retailer and rank by best price.
|
||||
- **Price Benchmarks for Staples**:
|
||||
- **Tomaten / Passata / Polpa (Mutti / Oro di Parma)**:
|
||||
- Normal: 1,69 € – 1,99 €
|
||||
- 🟢 **Top-Vorratspreis**: $\le$ 1,10 € (z.B. Netto 0,88 € oder Penny 1,19 €)
|
||||
- **Marken-Pasta (Barilla / De Cecco)**:
|
||||
- Normal: 1,89 € – 2,29 €
|
||||
- 🟢 **Top-Vorratspreis**: $\le$ 0,99 €
|
||||
- **Kaffee ganze Bohnen (Lavazza, Segafredo, Melitta)**:
|
||||
- Normal: 14,99 € – 17,99 € / kg
|
||||
- 🟢 **Top-Vorratspreis**: $\le$ 11,99 € / kg
|
||||
|
||||
### 2. Windeln Größe X (Stückpreis-Berechnung)
|
||||
Windel-Packungsgrößen variieren stark (Single-Pack vs. XXL-Box vs. Monatsbox).
|
||||
- **Berechne immer den Cent-Preis pro Windel**:
|
||||
$$\text{Stückpreis} = \frac{\text{Gesamtpreis}}{\text{Stückzahl}} \times 100\ \text{ct}$$
|
||||
- **Orientierungswerte**:
|
||||
- **Eigenmarke** (Lupilu / babylove): **11 – 14 ct / Windel**
|
||||
- **Markenwindeln** (Pampers Baby-Dry): **18 – 24 ct / Windel** (über 26 ct ist teuer)
|
||||
- Beispiel-Antwort: *„Bei Lidl gibt es Lupilu Gr. 4 (84 Stück) für 10,35 € (12,3 ct/Windel). Bei V-Markt sind Pampers Pants für 6,99 € im Angebot.“*
|
||||
|
||||
### 3. Location & Store Distance
|
||||
- Default location is **Regensburg** (Lat: `49.0134`, Lng: `12.1016`).
|
||||
- Mention the retailer and approximate distance (e.g. *„Netto Marken-Discount (0,5 km)“* oder *„Penny (1,4 km)“*).
|
||||
- Mention the validity dates (z.B. *„Gültig bis Samstag, 12.09.“*).
|
||||
|
||||
---
|
||||
|
||||
## 💻 Tech & Electronics Advisory Protocol
|
||||
|
||||
When the user asks whether to buy a gadget, phone, laptop, or appliance:
|
||||
1. **Model & Quality Check**:
|
||||
- Inspect `test_reviews_count` and `test_reviews_metascore` in `geizhals_search`.
|
||||
- If needed, run a quick web search on Stiftung Warentest / Chip / Reddit for common faults.
|
||||
2. **Timing & Price History Check**:
|
||||
- Run `geizhals_price_history(product_id)`.
|
||||
- If `is_all_time_low` is true: *„🟢 Aktuell auf Tiefstpreis-Niveau!“*
|
||||
- If significantly higher than `period_min_price`: advise waiting or setting a price alert.
|
||||
3. **Refurbished / B-Ware Option**:
|
||||
- Query `ebay_search` with `condition="refurbished"` or `"used"` to compare savings against brand-new retail.
|
||||
|
||||
---
|
||||
|
||||
## 📝 Bring! Shopping Lists Protocol
|
||||
|
||||
OpenClaw manages the Graf family's Bring! shopping lists using the `bring` MCP tools:
|
||||
- **`bring_add_item(item_name, specification="", list_name="Einkaufsladen")`**
|
||||
- **`bring_add_batch(items=[{"name": "...", "specification": "..."}], list_name="Einkaufsladen")`**
|
||||
- **`bring_get_items(list_name="Einkaufsladen")`**
|
||||
- **`bring_complete_item(item_name, list_name="Einkaufsladen")`**
|
||||
- **`bring_remove_item(item_name, list_name="Einkaufsladen")`**
|
||||
- **`bring_list_lists()`**
|
||||
|
||||
### 1. List Routing & Defaults
|
||||
The family maintains multiple distinct lists in Bring!:
|
||||
1. **`Einkaufsladen`** (**Default**): Groceries, supermarket items, drugstore (dm/Rossmann), household staples. Used for all general shopping queries unless another store is explicitly mentioned.
|
||||
2. **`Baumarkt`**: Hardware store items, DIY supplies, tools, screws/nails, timber, paint, plumbing, garden supplies.
|
||||
3. **`Ikea`**: Furniture, home decor, Swedish food market, home organization items.
|
||||
4. **`Gewuerzeinkaufsliste`**: Dedicated list for spices, herbs, and seasonings.
|
||||
|
||||
### 2. Interaction Rules
|
||||
- **Adding Items**:
|
||||
- *"Pack Milch auf die Einkaufsliste"* -> `bring_add_item(item_name="Milch", list_name="Einkaufsladen")`
|
||||
- *"Schreib 50er Spax Schrauben auf die Baumarkt-Liste"* -> `bring_add_item(item_name="Spax Schrauben", specification="50er", list_name="Baumarkt")`
|
||||
- *"Wir brauchen Koriander und Kardamom"* -> `bring_add_item` with `list_name="Gewuerzeinkaufsliste"` (or `bring_add_batch`)
|
||||
- *"Brauchen neue Kissenbezüge von Ikea"* -> `bring_add_item(item_name="Kissenbezüge", list_name="Ikea")`
|
||||
- **Confirmation**:
|
||||
- Always give a concise confirmation with markdown bolding:
|
||||
- *„✅ **Milch** (3.8%) auf die Liste **Einkaufsladen** gesetzt.“*
|
||||
- *„✅ **Spax Schrauben** (50er) auf die Liste **Baumarkt** gesetzt.“*
|
||||
- **Reading / Checking**:
|
||||
- When asked *"Was steht auf der Einkaufsliste?"*: call `bring_get_items(list_name="Einkaufsladen")` and summarize the items clearly.
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Presentation Rules
|
||||
- Follow OpenClaw's SOUL protocol: Hide hyperlinks behind descriptive text (`[Händler (Preis €)](url)`).
|
||||
- Never send raw search URLs or unverified Google links.
|
||||
Executable
+553
@@ -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()
|
||||
Executable
+477
@@ -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()
|
||||
Executable
+399
@@ -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()
|
||||
Executable
+385
@@ -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
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# dependencies = [
|
||||
# "beautifulsoup4>=4.12.0",
|
||||
# "requests>=2.31.0",
|
||||
# ]
|
||||
# ///
|
||||
"""
|
||||
Universal E-Commerce Product Extractor (Tier 2 JSON-LD + OpenGraph + Microdata)
|
||||
Extracts structured product data (title, brand, price, currency, availability, sku)
|
||||
from arbitrary online stores in < 200ms using Schema.org metadata, bypassing heavy browser
|
||||
overhead and bot detection. Falls back to Chromium CDP if JS rendering is strictly required.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import re
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
from typing import Dict, Any, Optional, List
|
||||
|
||||
DEFAULT_HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
|
||||
"Accept-Language": "de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7",
|
||||
}
|
||||
|
||||
|
||||
class UniversalProductExtractor:
|
||||
def __init__(self, cdp_url: Optional[str] = None):
|
||||
self.cdp_url = cdp_url or os.environ.get("OPENCLAW_CHROMIUM_CDP", "http://127.0.0.1:9222")
|
||||
|
||||
def fetch_html(self, url: str, timeout: int = 15) -> str:
|
||||
req = urllib.request.Request(url, headers=DEFAULT_HEADERS)
|
||||
with urllib.request.urlopen(req, timeout=timeout) as response:
|
||||
encoding = response.headers.get_content_charset() or "utf-8"
|
||||
return response.read().decode(encoding, errors="replace")
|
||||
|
||||
def extract_from_jsonld(self, html: str) -> List[Dict[str, Any]]:
|
||||
"""Extract all Schema.org Product objects from application/ld+json scripts."""
|
||||
products = []
|
||||
pattern = re.compile(r'<script[^>]*type=[\'"]application/ld\+json[\'"][^>]*>(.*?)</script>', re.DOTALL | re.IGNORECASE)
|
||||
for match in pattern.finditer(html):
|
||||
raw_json = match.group(1).strip()
|
||||
if not raw_json:
|
||||
continue
|
||||
try:
|
||||
data = json.loads(raw_json)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
items = data if isinstance(data, list) else [data]
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
# Support @graph structures
|
||||
if "@graph" in item and isinstance(item["@graph"], list):
|
||||
for sub in item["@graph"]:
|
||||
if isinstance(sub, dict) and sub.get("@type") in ("Product", "IndividualProduct"):
|
||||
products.append(self._normalize_schema_product(sub))
|
||||
elif item.get("@type") in ("Product", "IndividualProduct"):
|
||||
products.append(self._normalize_schema_product(item))
|
||||
|
||||
return products
|
||||
|
||||
def _normalize_schema_product(self, raw: dict) -> dict:
|
||||
brand = raw.get("brand")
|
||||
if isinstance(brand, dict):
|
||||
brand = brand.get("name", "")
|
||||
|
||||
offers = raw.get("offers", {})
|
||||
if isinstance(offers, list) and offers:
|
||||
offers = offers[0]
|
||||
|
||||
price = None
|
||||
currency = "EUR"
|
||||
availability = "Unknown"
|
||||
|
||||
if isinstance(offers, dict):
|
||||
price = offers.get("price") or offers.get("lowPrice")
|
||||
currency = offers.get("priceCurrency", "EUR")
|
||||
raw_avail = str(offers.get("availability", ""))
|
||||
if "InStock" in raw_avail:
|
||||
availability = "InStock"
|
||||
elif "OutOfStock" in raw_avail:
|
||||
availability = "OutOfStock"
|
||||
elif "PreOrder" in raw_avail:
|
||||
availability = "PreOrder"
|
||||
|
||||
return {
|
||||
"title": raw.get("name", "").strip(),
|
||||
"brand": str(brand or "").strip(),
|
||||
"price": float(price) if price and str(price).replace(".", "", 1).isdigit() else price,
|
||||
"currency": currency,
|
||||
"availability": availability,
|
||||
"sku": str(raw.get("sku") or raw.get("gtin13") or raw.get("gtin") or "").strip(),
|
||||
"image": raw.get("image") if isinstance(raw.get("image"), str) else (raw.get("image", [None])[0] if isinstance(raw.get("image"), list) else None),
|
||||
"description": raw.get("description", "").strip()[:500] if raw.get("description") else None,
|
||||
"extraction_tier": "json-ld"
|
||||
}
|
||||
|
||||
def extract_from_opengraph(self, html: str) -> Dict[str, Any]:
|
||||
"""Fallback extraction from OpenGraph / Twitter Cards / Meta tags."""
|
||||
title_m = re.search(r'<meta[^>]*property=[\'"]og:title[\'"][^>]*content=[\'"](.*?)[\'"]', html, re.IGNORECASE)
|
||||
price_m = re.search(r'<meta[^>]*property=[\'"]product:price:amount[\'"][^>]*content=[\'"](.*?)[\'"]', html, re.IGNORECASE) or \
|
||||
re.search(r'<meta[^>]*property=[\'"]og:price:amount[\'"][^>]*content=[\'"](.*?)[\'"]', html, re.IGNORECASE)
|
||||
curr_m = re.search(r'<meta[^>]*property=[\'"]product:price:currency[\'"][^>]*content=[\'"](.*?)[\'"]', html, re.IGNORECASE) or \
|
||||
re.search(r'<meta[^>]*property=[\'"]og:price:currency[\'"][^>]*content=[\'"](.*?)[\'"]', html, re.IGNORECASE)
|
||||
img_m = re.search(r'<meta[^>]*property=[\'"]og:image[\'"][^>]*content=[\'"](.*?)[\'"]', html, re.IGNORECASE)
|
||||
|
||||
title = title_m.group(1).strip() if title_m else ""
|
||||
price = price_m.group(1).strip() if price_m else None
|
||||
currency = curr_m.group(1).strip() if curr_m else "EUR"
|
||||
image = img_m.group(1).strip() if img_m else None
|
||||
|
||||
if title or price:
|
||||
return {
|
||||
"title": title,
|
||||
"price": float(price) if price and price.replace(".", "", 1).isdigit() else price,
|
||||
"currency": currency,
|
||||
"image": image,
|
||||
"extraction_tier": "opengraph"
|
||||
}
|
||||
return {}
|
||||
|
||||
def extract(self, url: str) -> Dict[str, Any]:
|
||||
"""Main entry point: fetches URL, extracts JSON-LD, falls back to OpenGraph."""
|
||||
html = self.fetch_html(url)
|
||||
jsonld_products = self.extract_from_jsonld(html)
|
||||
if jsonld_products:
|
||||
res = jsonld_products[0]
|
||||
res["source_url"] = url
|
||||
return res
|
||||
|
||||
og_product = self.extract_from_opengraph(html)
|
||||
if og_product:
|
||||
og_product["source_url"] = url
|
||||
return og_product
|
||||
|
||||
return {
|
||||
"error": "No structured product metadata found (JSON-LD or OpenGraph)",
|
||||
"source_url": url,
|
||||
"extraction_tier": "failed"
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: universal_product_extractor.py <product_url>")
|
||||
sys.exit(1)
|
||||
|
||||
url = sys.argv[1]
|
||||
extractor = UniversalProductExtractor()
|
||||
result = extractor.extract(url)
|
||||
print(json.dumps(result, indent=2, ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,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.
|
||||
Executable
+61
@@ -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()
|
||||
Executable
+224
@@ -0,0 +1,224 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Spotify OAuth Helper for OpenClaw
|
||||
Generates a permanent refresh_token for OpenClaw's Spotify MCP Server.
|
||||
|
||||
Usage:
|
||||
python3 spotify_auth.py --client-id <ID> --client-secret <SECRET>
|
||||
Or simply:
|
||||
python3 spotify_auth.py
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import webbrowser
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
DEFAULT_PORT = 8888
|
||||
DEFAULT_REDIRECT_URI = f"http://127.0.0.1:{DEFAULT_PORT}/callback"
|
||||
|
||||
SPOTIFY_AUTH_URL = "https://accounts.spotify.com/authorize"
|
||||
SPOTIFY_TOKEN_URL = "https://accounts.spotify.com/api/token"
|
||||
SPOTIFY_ME_URL = "https://api.spotify.com/v1/me"
|
||||
|
||||
SCOPES = [
|
||||
# User profile & account
|
||||
"user-read-private",
|
||||
"user-read-email",
|
||||
# Playback & Devices
|
||||
"user-read-playback-state",
|
||||
"user-modify-playback-state",
|
||||
"user-read-currently-playing",
|
||||
"user-read-recently-played",
|
||||
# Library
|
||||
"user-library-read",
|
||||
"user-library-modify",
|
||||
# Top stats & history
|
||||
"user-top-read",
|
||||
"user-read-playback-position",
|
||||
# Playlists
|
||||
"playlist-read-private",
|
||||
"playlist-read-collaborative",
|
||||
"playlist-modify-public",
|
||||
"playlist-modify-private",
|
||||
# Follow
|
||||
"user-follow-read",
|
||||
"user-follow-modify",
|
||||
]
|
||||
|
||||
|
||||
class OAuthCallbackHandler(BaseHTTPRequestHandler):
|
||||
auth_code = None
|
||||
error = None
|
||||
|
||||
def do_GET(self):
|
||||
parsed_url = urllib.parse.urlparse(self.path)
|
||||
if parsed_url.path == "/callback":
|
||||
params = urllib.parse.parse_qs(parsed_url.query)
|
||||
if "code" in params:
|
||||
OAuthCallbackHandler.auth_code = params["code"][0]
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.end_headers()
|
||||
html = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Spotify Auth Success</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #121212; color: #fff; text-align: center; }
|
||||
.card { background: #181818; padding: 40px; border-radius: 12px; box-shadow: 0 8px 24px rgba(0,0,0,0.5); border: 1px solid #282828; }
|
||||
h1 { color: #1DB954; margin-bottom: 12px; }
|
||||
p { color: #b3b3b3; font-size: 16px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>✅ Authentication Successful!</h1>
|
||||
<p>OpenClaw has successfully connected to your Spotify account.</p>
|
||||
<p>You can now close this browser window and check your terminal.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
self.wfile.write(html.encode("utf-8"))
|
||||
else:
|
||||
err_msg = params.get("error", ["Unknown error"])[0]
|
||||
OAuthCallbackHandler.error = err_msg
|
||||
self.send_response(400)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.end_headers()
|
||||
self.wfile.write(f"<h1>Authentication Failed</h1><p>{err_msg}</p>".encode("utf-8"))
|
||||
else:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
|
||||
def log_message(self, format, *args):
|
||||
# Silence standard HTTP access logging
|
||||
pass
|
||||
|
||||
|
||||
def exchange_code_for_tokens(client_id: str, client_secret: str, code: str, redirect_uri: str) -> dict:
|
||||
auth_header = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
|
||||
data = urllib.parse.urlencode({
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": redirect_uri,
|
||||
}).encode("utf-8")
|
||||
|
||||
req = urllib.request.Request(
|
||||
SPOTIFY_TOKEN_URL,
|
||||
data=data,
|
||||
headers={
|
||||
"Authorization": f"Basic {auth_header}",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
method="POST"
|
||||
)
|
||||
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
|
||||
def fetch_user_profile(access_token: str) -> dict:
|
||||
req = urllib.request.Request(
|
||||
SPOTIFY_ME_URL,
|
||||
headers={"Authorization": f"Bearer {access_token}"}
|
||||
)
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Spotify OAuth Helper for OpenClaw")
|
||||
parser.add_argument("--client-id", help="Spotify Developer App Client ID")
|
||||
parser.add_argument("--client-secret", help="Spotify Developer App Client Secret")
|
||||
parser.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"Local callback port (default: {DEFAULT_PORT})")
|
||||
args = parser.parse_args()
|
||||
|
||||
print("\n🎵 ===========================================")
|
||||
print(" OpenClaw Spotify OAuth Token Generator")
|
||||
print("===========================================\n")
|
||||
|
||||
client_id = args.client_id or os.environ.get("SPOTIFY_CLIENT_ID")
|
||||
client_secret = args.client_secret or os.environ.get("SPOTIFY_CLIENT_SECRET")
|
||||
|
||||
if not client_id:
|
||||
client_id = input("Enter Spotify Client ID: ").strip()
|
||||
if not client_secret:
|
||||
client_secret = input("Enter Spotify Client Secret: ").strip()
|
||||
|
||||
if not client_id or not client_secret:
|
||||
print("❌ Error: Client ID and Client Secret are required.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
redirect_uri = f"http://127.0.0.1:{args.port}/callback"
|
||||
|
||||
# Start local HTTP server for OAuth callback
|
||||
try:
|
||||
server = HTTPServer(("127.0.0.1", args.port), OAuthCallbackHandler)
|
||||
except OSError as e:
|
||||
print(f"❌ Error: Port {args.port} is already in use. Try specifying another port with --port.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Build auth URL
|
||||
auth_params = {
|
||||
"client_id": client_id,
|
||||
"response_type": "code",
|
||||
"redirect_uri": redirect_uri,
|
||||
"scope": " ".join(SCOPES),
|
||||
"show_dialog": "true",
|
||||
}
|
||||
auth_url = f"{SPOTIFY_AUTH_URL}?{urllib.parse.urlencode(auth_params)}"
|
||||
|
||||
print(f"\n🌐 Opening browser to authorize Spotify account...")
|
||||
print(f"If the browser doesn't open automatically, visit this URL:\n\n{auth_url}\n")
|
||||
webbrowser.open(auth_url)
|
||||
|
||||
print(f"Waiting for authorization on {redirect_uri} ...")
|
||||
while OAuthCallbackHandler.auth_code is None and OAuthCallbackHandler.error is None:
|
||||
server.handle_request()
|
||||
|
||||
if OAuthCallbackHandler.error:
|
||||
print(f"\n❌ Authorization rejected or failed: {OAuthCallbackHandler.error}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
code = OAuthCallbackHandler.auth_code
|
||||
print("🔄 Exchanging authorization code for permanent tokens...")
|
||||
|
||||
try:
|
||||
token_data = exchange_code_for_tokens(client_id, client_secret, code, redirect_uri)
|
||||
except urllib.error.HTTPError as e:
|
||||
print(f"\n❌ Failed to exchange code for tokens: {e.read().decode('utf-8')}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
access_token = token_data.get("access_token")
|
||||
refresh_token = token_data.get("refresh_token")
|
||||
|
||||
# Fetch user profile to display confirmation
|
||||
user_name = "Spotify User"
|
||||
try:
|
||||
profile = fetch_user_profile(access_token)
|
||||
user_name = f"{profile.get('display_name', 'User')} ({profile.get('id', '')})"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print("\n🎉 ===================================================")
|
||||
print(f" Successfully Authenticated as: {user_name}")
|
||||
print("===================================================\n")
|
||||
print("Add these lines to your k8s/openclaw/openclaw.secret.yaml:")
|
||||
print("---------------------------------------------------")
|
||||
print(f" spotify-client-id: \"{client_id}\"")
|
||||
print(f" spotify-client-secret: \"{client_secret}\"")
|
||||
print(f" spotify-refresh-token: \"{refresh_token}\"")
|
||||
print("---------------------------------------------------\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,90 @@
|
||||
---
|
||||
name: text-to-audio
|
||||
description: Transformiert Quelltexte, Recherchen, Dossiers, Artikel oder Notizen in ein hochwertiges Audio-Dossier. Erstellt standardmäßig ein Solo-Briefing mit natürlicher Stimme (Google Gemini 3.1 Flash TTS). Schaltet nur dann auf einen Zwei-Sprecher-Dialog um, wenn der Begriff "Podcast" explizit genannt wird.
|
||||
---
|
||||
|
||||
# 🎙️ Text-to-Audio Dossier & Podcast Producer
|
||||
|
||||
Dieser Skill transformiert beliebige Quelltexte (Recherchen, Restaurant-Infos, Artikel, Notizen, Reports) in ein professionelles Audio-Dossier mit **Google Gemini 3.1 Flash TTS**.
|
||||
|
||||
---
|
||||
|
||||
## 🧭 Kern-Regeln für das Format
|
||||
|
||||
### 1. Standard-Modus: Solo-Monolog (DEFAULT)
|
||||
* **Immer Standard für:** Audio-Dossiers, Audio-Briefings, Zusammenfassungen, "Lies mir das vor", "Erstelle ein Audio zu X", tägliche News.
|
||||
* **Ein einzelner, charismatischer Sprecher:** Kein künstlicher Dialog, keine erfundenen Zwischenfragen. Ein flüssiger, strukturierter, gut erzählter Vortrag.
|
||||
* **Vorausgewählte Stimmen:**
|
||||
* **Deutsch:** `Puck` (lebendig, warm, dynamisch) oder `Charon` (tief, ruhig, sachlich).
|
||||
* **Englisch:** `Zephyr` (natürlich, ausgewogen, klar).
|
||||
|
||||
### 2. Dialog-Modus: Nur bei explizitem "Podcast"-Wunsch
|
||||
* **NUR verwenden, wenn:** Der User explizit das Wort **"Podcast"** (oder "Zwei Moderatoren", "Dialog") verwendet!
|
||||
* **Zwei Sprecher (Host & Co-Host):**
|
||||
* **Host:** `Puck` (moderiert an, leitet durch die Themen).
|
||||
* **Co-Host / Expertin:** `Aoede` (bringt Details, Einschätzungen und Praxisbeispiele).
|
||||
|
||||
---
|
||||
|
||||
## ⏱️ Zieldauer & Pacing (~130 Wörter / Minute)
|
||||
|
||||
Ermittle die gewünschte Dauer oder nutze den Standard:
|
||||
* **Kurz-Dossier (`2m`):** ca. 250–300 Wörter.
|
||||
* **Standard-Dossier (`3m`–`4m`, Default):** ca. 400–550 Wörter.
|
||||
* **Ausführlicher Deep Dive (`5m`–`8m`):** ca. 650–1.000 Wörter.
|
||||
|
||||
### Redaktionelles Skripting
|
||||
* **Wichtigkeit vor Quantität:** Kernaussagen und Aha-Momente priorisieren, Füllmaterial und Wiederholungen streichen.
|
||||
* **Für das Ohr schreiben:** Keine Markdown-Artefakte (Sterne, Listen, rohe URLs, Tabellen). Klare akustische Übergänge nutzen.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Ausführung & Synthese
|
||||
|
||||
Nutze das Skript `scripts/text_to_audio.py` (bzw. `/home/node/mop-skills/mop-skills/skills/text-to-audio/scripts/text_to_audio.py` im Cluster).
|
||||
|
||||
### A. Solo-Modus (Standard)
|
||||
Schreibe das Skript am besten in eine Datei, um Shell-Escaping-Probleme zu vermeiden:
|
||||
|
||||
```bash
|
||||
# 1. Skript in Datei schreiben
|
||||
cat << 'TXT_EOF' > /tmp/dossier_script.txt
|
||||
Dein aufbereitetes Skript ohne Markdown-Artefakte...
|
||||
TXT_EOF
|
||||
|
||||
# 2. Synthese starten (läuft parallel & fällt bei Gemini-Timeouts automatisch auf MiniMax zurück)
|
||||
python3 /home/node/mop-skills/mop-skills/skills/text-to-audio/scripts/text_to_audio.py \
|
||||
--file /tmp/dossier_script.txt \
|
||||
--voice Puck \
|
||||
--lang de \
|
||||
--title "Titel des Dossiers" \
|
||||
--output /tmp/audio-dossier.mp3
|
||||
```
|
||||
|
||||
### B. Dialog-Modus (NUR wenn "Podcast" explizit verlangt)
|
||||
```bash
|
||||
cat << 'JSON_EOF' > /tmp/podcast_dialog.json
|
||||
[
|
||||
{"speaker": "host", "text": "Willkommen zum Podcast. Heute sprechen wir über..."},
|
||||
{"speaker": "cohost", "text": "Hi! Ja, das Thema ist super spannend, weil..."}
|
||||
]
|
||||
JSON_EOF
|
||||
|
||||
python3 /home/node/mop-skills/mop-skills/skills/text-to-audio/scripts/text_to_audio.py \
|
||||
--dialog-file /tmp/podcast_dialog.json \
|
||||
--lang de \
|
||||
--title "Podcast Titel" \
|
||||
--output /tmp/audio-dossier.mp3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📱 Auslieferung an den User
|
||||
|
||||
Das Skript gibt am Ende `MEDIA:/tmp/...mp3` aus. Antworte in Telegram:
|
||||
|
||||
```markdown
|
||||
Hier ist dein Audio-Dossier zu **[Thema]** ([Dauer] Min.):
|
||||
|
||||
MEDIA:/tmp/audio-dossier.mp3
|
||||
```
|
||||
+595
@@ -0,0 +1,595 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
text_to_audio.py - High-Quality Text-to-Speech & Podcast Synthesizer for OpenClaw
|
||||
|
||||
Features:
|
||||
- Primary TTS: Google Gemini 3.1 Flash TTS (natural, expressive, low cost).
|
||||
- Automatic Fast Fallback: MiniMax Speech 02 HD (instant, 100% reliable studio quality).
|
||||
- Parallel Synthesis: Multi-threaded chunk processing (ThreadPoolExecutor) for fast generation.
|
||||
- Full Voice Normalization: Maps any voice name or role (Puck, Julius, Lara, Host, etc.) safely.
|
||||
- Live Progress: Unbuffered stdout logging so OpenClaw process watchdogs never timeout.
|
||||
- Format Guarantee: Standard monologue by default; multi-speaker dialog only when requested.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import wave
|
||||
from pathlib import Path
|
||||
|
||||
OPENROUTER_SPEECH_URL = "https://openrouter.ai/api/v1/audio/speech"
|
||||
DEFAULT_PRIMARY_MODEL = "google/gemini-3.1-flash-tts-preview"
|
||||
DEFAULT_FALLBACK_MODEL = "minimax/speech-2.8-hd"
|
||||
|
||||
VOICE_CONFIG = {
|
||||
"de": {
|
||||
"gemini": {
|
||||
"solo": "Puck",
|
||||
"host": "Puck",
|
||||
"cohost": "Aoede",
|
||||
"deep": "Charon",
|
||||
},
|
||||
"minimax": {
|
||||
"solo": "presenter_male",
|
||||
"host": "presenter_male",
|
||||
"cohost": "presenter_female",
|
||||
"deep": "audiobook_male_1",
|
||||
},
|
||||
},
|
||||
"en": {
|
||||
"gemini": {
|
||||
"solo": "Zephyr",
|
||||
"host": "Zephyr",
|
||||
"cohost": "Aoede",
|
||||
"deep": "Charon",
|
||||
},
|
||||
"minimax": {
|
||||
"solo": "presenter_male",
|
||||
"host": "presenter_male",
|
||||
"cohost": "presenter_female",
|
||||
"deep": "audiobook_male_1",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def log_progress(msg: str) -> None:
|
||||
"""Print immediate progress line with flush for OpenClaw process watchers."""
|
||||
print(f"[text-to-audio] {msg}", flush=True)
|
||||
|
||||
|
||||
def find_ffmpeg() -> str:
|
||||
"""Locate the ffmpeg binary across common container and host locations."""
|
||||
env_path = os.environ.get("FFMPEG_PATH")
|
||||
if env_path and os.path.exists(env_path):
|
||||
return env_path
|
||||
|
||||
which_path = shutil.which("ffmpeg")
|
||||
if which_path:
|
||||
return which_path
|
||||
|
||||
candidates = [
|
||||
"/home/node/.openclaw/bin/ffmpeg",
|
||||
"/opt/homebrew/bin/ffmpeg",
|
||||
"/usr/local/bin/ffmpeg",
|
||||
"/usr/bin/ffmpeg",
|
||||
]
|
||||
for c in candidates:
|
||||
if os.path.exists(c) and os.access(c, os.X_OK):
|
||||
return c
|
||||
|
||||
raise RuntimeError("ffmpeg binary not found. Please ensure ffmpeg is installed or set FFMPEG_PATH.")
|
||||
|
||||
|
||||
def normalize_role(role_or_voice: str) -> str:
|
||||
"""Normalize any role, speaker name, or legacy voice string into a canonical role key."""
|
||||
v = (role_or_voice or "").strip().lower()
|
||||
if any(x in v for x in ["cohost", "lara", "female", "expert", "aoede", "guest", "shaonv"]):
|
||||
return "cohost"
|
||||
if any(x in v for x in ["deep", "charon", "doku", "news", "audiobook"]):
|
||||
return "deep"
|
||||
return "solo"
|
||||
|
||||
|
||||
def resolve_model_voice(role_or_voice: str, model: str, lang: str = "de") -> str:
|
||||
"""Resolve a speaker role/name into the exact valid voice parameter for a specific TTS model."""
|
||||
lang_key = "en" if lang.startswith("en") else "de"
|
||||
is_gemini = "gemini" in model.lower()
|
||||
model_family = "gemini" if is_gemini else "minimax"
|
||||
|
||||
# Direct passthrough if already a valid exact model voice
|
||||
if is_gemini and role_or_voice in ["Puck", "Aoede", "Charon", "Zephyr", "Kore", "Fenrir"]:
|
||||
return role_or_voice
|
||||
if not is_gemini and role_or_voice in [
|
||||
"presenter_male",
|
||||
"presenter_female",
|
||||
"audiobook_male_1",
|
||||
"audiobook_female_1",
|
||||
"male-qn-qingse",
|
||||
"female-shaonv",
|
||||
]:
|
||||
return role_or_voice
|
||||
|
||||
role = normalize_role(role_or_voice)
|
||||
return VOICE_CONFIG[lang_key][model_family][role]
|
||||
|
||||
|
||||
def split_text_into_chunks(text: str, max_chars: int = 1200) -> list[str]:
|
||||
"""Split text into natural sentence-level chunks to satisfy API limits and natural pacing."""
|
||||
text = text.strip()
|
||||
if not text:
|
||||
return []
|
||||
if len(text) <= max_chars:
|
||||
return [text]
|
||||
|
||||
sentences = re.split(r"(?<=[.?!;:\n])\s+", text)
|
||||
chunks = []
|
||||
current = []
|
||||
current_len = 0
|
||||
|
||||
for s in sentences:
|
||||
s = s.strip()
|
||||
if not s:
|
||||
continue
|
||||
if current_len + len(s) + 1 > max_chars and current:
|
||||
chunks.append(" ".join(current))
|
||||
current = [s]
|
||||
current_len = len(s)
|
||||
else:
|
||||
current.append(s)
|
||||
current_len += len(s) + 1
|
||||
|
||||
if current:
|
||||
chunks.append(" ".join(current))
|
||||
|
||||
return chunks
|
||||
|
||||
|
||||
def _call_openrouter_speech(
|
||||
model: str,
|
||||
text: str,
|
||||
voice: str,
|
||||
response_format: str,
|
||||
api_key: str,
|
||||
timeout_sec: float,
|
||||
) -> bytes:
|
||||
"""Single HTTP call to OpenRouter /api/v1/audio/speech."""
|
||||
payload = {
|
||||
"model": model,
|
||||
"input": text,
|
||||
"voice": voice,
|
||||
"response_format": response_format,
|
||||
}
|
||||
req = urllib.request.Request(
|
||||
OPENROUTER_SPEECH_URL,
|
||||
data=json.dumps(payload).encode("utf-8"),
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "OpenClaw-TextToAudio/2.1",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=timeout_sec) as resp:
|
||||
content = resp.read()
|
||||
if not content or len(content) < 100:
|
||||
raise RuntimeError("Received empty audio stream from TTS provider.")
|
||||
return content
|
||||
|
||||
|
||||
def synthesize_chunk_to_wav(
|
||||
chunk_idx: int,
|
||||
total_chunks: int,
|
||||
text: str,
|
||||
speaker_or_voice: str,
|
||||
output_wav_path: str,
|
||||
ffmpeg_bin: str,
|
||||
preferred_model: str = DEFAULT_PRIMARY_MODEL,
|
||||
fallback_model: str = DEFAULT_FALLBACK_MODEL,
|
||||
lang: str = "de",
|
||||
api_key: str | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Synthesizes a text chunk to a standardized 24kHz mono 16-bit WAV file.
|
||||
Tries preferred_model first; on timeout/error, immediately falls back to fallback_model.
|
||||
"""
|
||||
key = api_key or os.environ.get("OPENROUTER_API_KEY")
|
||||
if not key:
|
||||
raise ValueError("OPENROUTER_API_KEY environment variable is not set.")
|
||||
|
||||
t0 = time.time()
|
||||
voice_pref = resolve_model_voice(speaker_or_voice, model=preferred_model, lang=lang)
|
||||
pref_is_gemini = "gemini" in preferred_model.lower()
|
||||
pref_format = "pcm" if pref_is_gemini else "mp3"
|
||||
|
||||
log_progress(f"Chunk {chunk_idx + 1}/{total_chunks}: synthesizing ({len(text)} chars, {preferred_model} / {voice_pref})...")
|
||||
|
||||
# Try preferred model (timeout: 16 seconds)
|
||||
try:
|
||||
content = _call_openrouter_speech(
|
||||
model=preferred_model,
|
||||
text=text,
|
||||
voice=voice_pref,
|
||||
response_format=pref_format,
|
||||
api_key=key,
|
||||
timeout_sec=16.0,
|
||||
)
|
||||
if pref_is_gemini:
|
||||
# Wrap raw 24kHz s16le PCM into standard WAV
|
||||
with wave.open(output_wav_path, "wb") as w:
|
||||
w.setnchannels(1)
|
||||
w.setsampwidth(2)
|
||||
w.setframerate(24000)
|
||||
w.writeframes(content)
|
||||
else:
|
||||
# Re-encode MP3 to uniform 24kHz mono WAV
|
||||
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as tf:
|
||||
tf.write(content)
|
||||
tmp_mp3 = tf.name
|
||||
try:
|
||||
subprocess.run(
|
||||
[ffmpeg_bin, "-y", "-i", tmp_mp3, "-ar", "24000", "-ac", "1", output_wav_path],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=True,
|
||||
)
|
||||
finally:
|
||||
if os.path.exists(tmp_mp3):
|
||||
os.remove(tmp_mp3)
|
||||
|
||||
dt = time.time() - t0
|
||||
log_progress(f"Chunk {chunk_idx + 1}/{total_chunks}: generated via {preferred_model} in {dt:.1f}s")
|
||||
return
|
||||
except Exception as err:
|
||||
log_progress(f"Chunk {chunk_idx + 1}/{total_chunks}: primary {preferred_model} failed ({err}). Falling back to {fallback_model}...")
|
||||
|
||||
# Immediate fallback
|
||||
t1 = time.time()
|
||||
voice_fb = resolve_model_voice(speaker_or_voice, model=fallback_model, lang=lang)
|
||||
fb_is_gemini = "gemini" in fallback_model.lower()
|
||||
fb_format = "pcm" if fb_is_gemini else "mp3"
|
||||
|
||||
try:
|
||||
content = _call_openrouter_speech(
|
||||
model=fallback_model,
|
||||
text=text,
|
||||
voice=voice_fb,
|
||||
response_format=fb_format,
|
||||
api_key=key,
|
||||
timeout_sec=15.0,
|
||||
)
|
||||
if fb_is_gemini:
|
||||
with wave.open(output_wav_path, "wb") as w:
|
||||
w.setnchannels(1)
|
||||
w.setsampwidth(2)
|
||||
w.setframerate(24000)
|
||||
w.writeframes(content)
|
||||
else:
|
||||
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as tf:
|
||||
tf.write(content)
|
||||
tmp_mp3 = tf.name
|
||||
try:
|
||||
subprocess.run(
|
||||
[ffmpeg_bin, "-y", "-i", tmp_mp3, "-ar", "24000", "-ac", "1", output_wav_path],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=True,
|
||||
)
|
||||
finally:
|
||||
if os.path.exists(tmp_mp3):
|
||||
os.remove(tmp_mp3)
|
||||
|
||||
dt = time.time() - t1
|
||||
log_progress(f"Chunk {chunk_idx + 1}/{total_chunks}: generated via fallback {fallback_model} in {dt:.1f}s")
|
||||
return
|
||||
except Exception as fb_err:
|
||||
raise RuntimeError(
|
||||
f"Chunk {chunk_idx + 1}/{total_chunks} synthesis failed on both primary ({err}) and fallback ({fb_err})"
|
||||
)
|
||||
|
||||
|
||||
def generate_silence_wav(duration_sec: float, output_path: str, sample_rate: int = 24000) -> None:
|
||||
"""Generate a clean silence WAV file."""
|
||||
num_frames = int(duration_sec * sample_rate)
|
||||
silence_bytes = b"\x00\x00" * num_frames
|
||||
with wave.open(output_path, "wb") as w:
|
||||
w.setnchannels(1)
|
||||
w.setsampwidth(2)
|
||||
w.setframerate(sample_rate)
|
||||
w.writeframes(silence_bytes)
|
||||
|
||||
|
||||
def stitch_audio_segments(
|
||||
ffmpeg_bin: str,
|
||||
wav_files: list[str],
|
||||
output_file: str,
|
||||
title: str = "Audio Dossier",
|
||||
artist: str = "OpenClaw",
|
||||
album: str = "OpenClaw Audio Briefings",
|
||||
) -> None:
|
||||
"""Concatenate uniform WAV segments and produce high-quality 128k MP3 with metadata."""
|
||||
if not wav_files:
|
||||
raise ValueError("No audio segments to stitch.")
|
||||
|
||||
log_progress(f"Stitching {len(wav_files)} segment(s) into MP3 with metadata...")
|
||||
|
||||
if len(wav_files) == 1:
|
||||
cmd = [
|
||||
ffmpeg_bin,
|
||||
"-y",
|
||||
"-i",
|
||||
wav_files[0],
|
||||
"-metadata",
|
||||
f"title={title}",
|
||||
"-metadata",
|
||||
f"artist={artist}",
|
||||
"-metadata",
|
||||
f"album={album}",
|
||||
"-c:a",
|
||||
"libmp3lame",
|
||||
"-b:a",
|
||||
"128k",
|
||||
output_file,
|
||||
]
|
||||
res = subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True)
|
||||
if res.returncode != 0:
|
||||
raise RuntimeError(f"FFmpeg export failed: {res.stderr}")
|
||||
return
|
||||
|
||||
# Concat demuxer with single-quoted file paths
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False) as list_file:
|
||||
for f in wav_files:
|
||||
safe_path = f.replace("'", "'\\''")
|
||||
list_file.write(f"file '{safe_path}'\n")
|
||||
list_file_path = list_file.name
|
||||
|
||||
try:
|
||||
cmd = [
|
||||
ffmpeg_bin,
|
||||
"-y",
|
||||
"-f",
|
||||
"concat",
|
||||
"-safe",
|
||||
"0",
|
||||
"-i",
|
||||
list_file_path,
|
||||
"-metadata",
|
||||
f"title={title}",
|
||||
"-metadata",
|
||||
f"artist={artist}",
|
||||
"-metadata",
|
||||
f"album={album}",
|
||||
"-c:a",
|
||||
"libmp3lame",
|
||||
"-b:a",
|
||||
"128k",
|
||||
output_file,
|
||||
]
|
||||
res = subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True)
|
||||
if res.returncode != 0:
|
||||
raise RuntimeError(f"FFmpeg concatenation failed: {res.stderr}")
|
||||
finally:
|
||||
if os.path.exists(list_file_path):
|
||||
os.remove(list_file_path)
|
||||
|
||||
|
||||
def process_solo_text(
|
||||
text: str,
|
||||
ffmpeg_bin: str,
|
||||
voice_or_role: str,
|
||||
primary_model: str,
|
||||
fallback_model: str,
|
||||
lang: str,
|
||||
output_file: str,
|
||||
title: str,
|
||||
max_workers: int = 4,
|
||||
) -> None:
|
||||
"""Generate audio for a single-speaker text in parallel (Default for all audio dossiers)."""
|
||||
chunks = split_text_into_chunks(text)
|
||||
if not chunks:
|
||||
raise ValueError("No text provided to synthesize.")
|
||||
|
||||
log_progress(f"Solo text synthesis started: {len(chunks)} chunk(s), ~{len(text.split())} words, lang={lang}")
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="audio_solo_") as tmpdir:
|
||||
wav_tasks = []
|
||||
for i, chunk in enumerate(chunks):
|
||||
chunk_wav = os.path.join(tmpdir, f"chunk_{i:04d}.wav")
|
||||
wav_tasks.append((i, len(chunks), chunk, voice_or_role, chunk_wav))
|
||||
|
||||
with ThreadPoolExecutor(max_workers=min(len(chunks), max_workers)) as pool:
|
||||
futures = [
|
||||
pool.submit(
|
||||
synthesize_chunk_to_wav,
|
||||
idx,
|
||||
tot,
|
||||
chk,
|
||||
v,
|
||||
path,
|
||||
ffmpeg_bin,
|
||||
primary_model,
|
||||
fallback_model,
|
||||
lang,
|
||||
)
|
||||
for idx, tot, chk, v, path in wav_tasks
|
||||
]
|
||||
for fut in futures:
|
||||
fut.result()
|
||||
|
||||
wav_files = [task[4] for task in wav_tasks]
|
||||
stitch_audio_segments(
|
||||
ffmpeg_bin=ffmpeg_bin,
|
||||
wav_files=wav_files,
|
||||
output_file=output_file,
|
||||
title=title,
|
||||
)
|
||||
|
||||
|
||||
def process_dialog(
|
||||
dialog_items: list[dict],
|
||||
ffmpeg_bin: str,
|
||||
primary_model: str,
|
||||
fallback_model: str,
|
||||
lang: str,
|
||||
output_file: str,
|
||||
title: str,
|
||||
pause_duration: float = 0.35,
|
||||
max_workers: int = 4,
|
||||
) -> None:
|
||||
"""Generate audio for multi-speaker dialog turns in parallel (used only when 'podcast' requested)."""
|
||||
if not dialog_items:
|
||||
raise ValueError("Dialog list is empty.")
|
||||
|
||||
log_progress(f"Dialog synthesis started: {len(dialog_items)} turn(s), lang={lang}")
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="audio_dialog_") as tmpdir:
|
||||
silence_file = os.path.join(tmpdir, "pause.wav")
|
||||
generate_silence_wav(pause_duration, silence_file)
|
||||
|
||||
# Flatten dialog items into chunks if any turn exceeds max_chars
|
||||
flattened_items = []
|
||||
for d in dialog_items:
|
||||
speaker = d.get("speaker", "host")
|
||||
text = d.get("text", "").strip()
|
||||
if not text:
|
||||
continue
|
||||
chunks = split_text_into_chunks(text)
|
||||
for c in chunks:
|
||||
flattened_items.append((speaker, c))
|
||||
|
||||
wav_tasks = []
|
||||
for i, (speaker, chunk_text) in enumerate(flattened_items):
|
||||
turn_wav = os.path.join(tmpdir, f"turn_{i:04d}.wav")
|
||||
wav_tasks.append((i, len(flattened_items), chunk_text, speaker, turn_wav))
|
||||
|
||||
with ThreadPoolExecutor(max_workers=min(len(flattened_items), max_workers)) as pool:
|
||||
futures = [
|
||||
pool.submit(
|
||||
synthesize_chunk_to_wav,
|
||||
idx,
|
||||
tot,
|
||||
chk,
|
||||
spk,
|
||||
path,
|
||||
ffmpeg_bin,
|
||||
primary_model,
|
||||
fallback_model,
|
||||
lang,
|
||||
)
|
||||
for idx, tot, chk, spk, path in wav_tasks
|
||||
]
|
||||
for fut in futures:
|
||||
fut.result()
|
||||
|
||||
# Interleave silence between dialog turns
|
||||
stitched_wavs = []
|
||||
for task in wav_tasks:
|
||||
stitched_wavs.append(task[4])
|
||||
stitched_wavs.append(silence_file)
|
||||
|
||||
if stitched_wavs and stitched_wavs[-1] == silence_file:
|
||||
stitched_wavs.pop()
|
||||
|
||||
stitch_audio_segments(
|
||||
ffmpeg_bin=ffmpeg_bin,
|
||||
wav_files=stitched_wavs,
|
||||
output_file=output_file,
|
||||
title=title,
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="OpenClaw Text-to-Audio Dossier Generator")
|
||||
parser.add_argument("--text", help="Plaintext to synthesize (default solo mode)")
|
||||
parser.add_argument("--file", help="Path to plaintext file (default solo mode)")
|
||||
parser.add_argument("--dialog-json", help="JSON string of dialog turns (only if 'podcast' requested)")
|
||||
parser.add_argument("--dialog-file", help="Path to JSON file with dialog turns")
|
||||
parser.add_argument("--output", "-o", help="Target output MP3 path")
|
||||
parser.add_argument("--title", default="Audio Dossier", help="Audio title for ID3 metadata")
|
||||
parser.add_argument("--voice", help="Voice or speaker role (e.g. Puck, Zephyr, Aoede, host, solo)")
|
||||
parser.add_argument("--lang", default="de", choices=["de", "en"], help="Language preset (de/en)")
|
||||
parser.add_argument("--model", default=DEFAULT_PRIMARY_MODEL, help=f"Primary model (default: {DEFAULT_PRIMARY_MODEL})")
|
||||
parser.add_argument("--fallback-model", default=DEFAULT_FALLBACK_MODEL, help=f"Fallback model (default: {DEFAULT_FALLBACK_MODEL})")
|
||||
parser.add_argument("--pause", type=float, default=0.35, help="Pause in seconds between dialog turns")
|
||||
parser.add_argument("--workers", type=int, default=4, help="Max concurrent synthesis threads")
|
||||
parser.add_argument("--media-tag", action="store_true", default=True, help="Print MEDIA:<path> tag to stdout")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
ffmpeg_bin = find_ffmpeg()
|
||||
|
||||
if args.output:
|
||||
out_path = Path(args.output).expanduser().resolve()
|
||||
else:
|
||||
ts = int(time.time())
|
||||
out_path = Path(f"/tmp/audio-dossier-{ts}.mp3")
|
||||
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
t_start = time.time()
|
||||
|
||||
dialog_data = None
|
||||
if args.dialog_file:
|
||||
with open(args.dialog_file, "r", encoding="utf-8") as f:
|
||||
dialog_data = json.load(f)
|
||||
elif args.dialog_json:
|
||||
dialog_data = json.loads(args.dialog_json)
|
||||
|
||||
if dialog_data:
|
||||
if not isinstance(dialog_data, list):
|
||||
raise ValueError("Dialog input must be a JSON array of objects with 'speaker' and 'text'.")
|
||||
process_dialog(
|
||||
dialog_items=dialog_data,
|
||||
ffmpeg_bin=ffmpeg_bin,
|
||||
primary_model=args.model,
|
||||
fallback_model=args.fallback_model,
|
||||
lang=args.lang,
|
||||
output_file=str(out_path),
|
||||
title=args.title,
|
||||
pause_duration=args.pause,
|
||||
max_workers=args.workers,
|
||||
)
|
||||
else:
|
||||
# Solo monologue mode (Strict DEFAULT)
|
||||
raw_text = ""
|
||||
if args.file:
|
||||
with open(args.file, "r", encoding="utf-8") as f:
|
||||
raw_text = f.read()
|
||||
elif args.text:
|
||||
raw_text = args.text
|
||||
elif not sys.stdin.isatty():
|
||||
raw_text = sys.stdin.read()
|
||||
else:
|
||||
parser.error("Must provide either --text, --file, --dialog-json, --dialog-file, or pipe text to stdin.")
|
||||
|
||||
chosen_voice = args.voice or "solo"
|
||||
process_solo_text(
|
||||
text=raw_text,
|
||||
ffmpeg_bin=ffmpeg_bin,
|
||||
voice_or_role=chosen_voice,
|
||||
primary_model=args.model,
|
||||
fallback_model=args.fallback_model,
|
||||
lang=args.lang,
|
||||
output_file=str(out_path),
|
||||
title=args.title,
|
||||
max_workers=args.workers,
|
||||
)
|
||||
|
||||
total_time = time.time() - t_start
|
||||
log_progress(f"Finished in {total_time:.1f}s: {out_path} ({out_path.stat().st_size} bytes)")
|
||||
|
||||
if args.media_tag:
|
||||
print(f"MEDIA:{out_path}", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
name: youtube
|
||||
description: Search YouTube videos, retrieve video metadata, and extract full transcripts/subtitles with timestamps for instant AI summarization, recipes, or research.
|
||||
---
|
||||
|
||||
# 📺 YouTube Integration (Search & Transcripts)
|
||||
|
||||
Full YouTube search and transcript extraction engine for OpenClaw:
|
||||
|
||||
1. 🔍 **`youtube_search(query, max_results=5, video_duration="any")`**:
|
||||
- Searches YouTube via YouTube Data API v3.
|
||||
- Returns title, channel, video ID, formatted duration, view count, published date, and direct link.
|
||||
- Durations: `any`, `short` (< 4 min), `medium` (4-20 min), `long` (> 20 min).
|
||||
|
||||
2. 📜 **`youtube_get_transcript(video_url_or_id, languages=["de", "en"], include_timestamps=True)`**:
|
||||
- **Unlimited & 100% Free**: Powered by `youtube-transcript-api` (zero GCP quota consumption).
|
||||
- Fetches formatted subtitles and speech transcripts with `[MM:SS]` timestamps.
|
||||
- Automatically prioritizes German (`de`) and English (`en`), including auto-generated subtitles.
|
||||
- Perfect for:
|
||||
- Summarizing interviews, podcasts, tech talks, and keynotes.
|
||||
- Extracting cooking steps and recipes from YouTube food videos for `koch`.
|
||||
- Extracting command-line instructions, code, or homelab setup guides for `itguy`.
|
||||
|
||||
3. ℹ️ **`youtube_video_details(video_url_or_id)`**:
|
||||
- Fetches full description, tags, view count, like count, and publication details.
|
||||
|
||||
---
|
||||
|
||||
## 🧭 Prompt Routing & Best Practices
|
||||
|
||||
- **When the user pastes a YouTube URL** (e.g. *"Was wird in diesem Video erklärt? https://youtu.be/..."* or *"Fass mir das Video kurz zusammen"*):
|
||||
- Do NOT waste a turn searching. Directly call `youtube_get_transcript` with the URL!
|
||||
- Provide a concise executive summary with key takeaways, timestamps for notable moments, and actionable conclusions.
|
||||
- **For Koch / Recipes**:
|
||||
- When the user asks for a recipe from a video, pull the transcript via `youtube_get_transcript`, extract the ingredients and steps, and offer to add missing items to the Bring! shopping list.
|
||||
- **For Research / Recherche**:
|
||||
- Search relevant videos via `youtube_search` to find relevant lectures, documentaries, or news reports, then extract the transcript of the top hit for deep grounded analysis.
|
||||
Executable
+568
@@ -0,0 +1,568 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
YouTube MCP Server & CLI Tool for OpenClaw.
|
||||
|
||||
Integrates YouTube search, video metadata, and free transcript extraction
|
||||
(subtitles) via YouTube Data API v3 and youtube-transcript-api.
|
||||
|
||||
Features:
|
||||
- youtube_search: Search videos by keyword with title, channel, duration, and views
|
||||
- youtube_get_transcript: Fetch German and English subtitles with timestamps (100% free, zero quota)
|
||||
- youtube_video_details: Fetch full video metadata and description
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
import json
|
||||
import argparse
|
||||
from typing import Dict, Any, List, Optional
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
# Environment
|
||||
YOUTUBE_API_KEY = os.environ.get("YOUTUBE_API_KEY", "").strip()
|
||||
|
||||
|
||||
def extract_video_id(url_or_id: str) -> Optional[str]:
|
||||
"""Extract 11-character YouTube video ID from various URL formats or raw ID."""
|
||||
if not url_or_id:
|
||||
return None
|
||||
url_or_id = url_or_id.strip()
|
||||
# If already an 11-char alphanumeric ID
|
||||
if re.match(r"^[a-zA-Z0-9_-]{11}$", url_or_id):
|
||||
return url_or_id
|
||||
|
||||
# Common URL formats:
|
||||
# https://www.youtube.com/watch?v=VIDEO_ID
|
||||
# https://youtu.be/VIDEO_ID
|
||||
# https://www.youtube.com/shorts/VIDEO_ID
|
||||
# https://www.youtube.com/embed/VIDEO_ID
|
||||
parsed = urlparse(url_or_id)
|
||||
if "youtube.com" in parsed.netloc:
|
||||
if parsed.path == "/watch":
|
||||
qs = parse_qs(parsed.query)
|
||||
if "v" in qs and qs["v"]:
|
||||
return qs["v"][0]
|
||||
elif parsed.path.startswith("/shorts/"):
|
||||
parts = parsed.path.split("/")
|
||||
if len(parts) >= 3 and len(parts[2]) == 11:
|
||||
return parts[2]
|
||||
elif parsed.path.startswith("/embed/"):
|
||||
parts = parsed.path.split("/")
|
||||
if len(parts) >= 3 and len(parts[2]) == 11:
|
||||
return parts[2]
|
||||
elif "youtu.be" in parsed.netloc:
|
||||
path = parsed.path.lstrip("/")
|
||||
# Path might have query params or /
|
||||
video_id = path.split("?")[0].split("/")[0]
|
||||
if len(video_id) == 11:
|
||||
return video_id
|
||||
|
||||
# Regex fallback
|
||||
match = re.search(r"[a-zA-Z0-9_-]{11}", url_or_id)
|
||||
if match:
|
||||
return match.group(0)
|
||||
return None
|
||||
|
||||
|
||||
def parse_iso_duration(duration_str: str) -> str:
|
||||
"""Convert ISO 8601 duration (PT1H2M30S or PT5M12S) to human readable (01:02:30 or 05:12)."""
|
||||
if not duration_str or not duration_str.startswith("PT"):
|
||||
return duration_str or ""
|
||||
hours = 0
|
||||
minutes = 0
|
||||
seconds = 0
|
||||
|
||||
pattern = re.compile(r"PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?")
|
||||
match = pattern.match(duration_str)
|
||||
if match:
|
||||
h, m, s = match.groups()
|
||||
hours = int(h) if h else 0
|
||||
minutes = int(m) if m else 0
|
||||
seconds = int(s) if s else 0
|
||||
if hours > 0:
|
||||
return f"{hours}:{minutes:02d}:{seconds:02d}"
|
||||
return f"{minutes:02d}:{seconds:02d}"
|
||||
return duration_str
|
||||
|
||||
|
||||
def format_seconds(seconds: float) -> str:
|
||||
"""Format seconds into HH:MM:SS or MM:SS."""
|
||||
s = int(seconds)
|
||||
hours = s // 3600
|
||||
minutes = (s % 3600) // 60
|
||||
secs = s % 60
|
||||
if hours > 0:
|
||||
return f"{hours:02d}:{minutes:02d}:{secs:02d}"
|
||||
return f"{minutes:02d}:{secs:02d}"
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# YouTube Operations
|
||||
# ==============================================================================
|
||||
|
||||
def search_videos(
|
||||
query: str,
|
||||
max_results: int = 5,
|
||||
video_duration: str = "any",
|
||||
api_key: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Search YouTube videos using YouTube Data API v3."""
|
||||
key = api_key or YOUTUBE_API_KEY
|
||||
if not key:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "YOUTUBE_API_KEY is not set. Please provide a valid Google Cloud API key.",
|
||||
"videos": [],
|
||||
}
|
||||
|
||||
try:
|
||||
from googleapiclient.discovery import build
|
||||
youtube = build("youtube", "v3", developerKey=key, cache_discovery=False)
|
||||
|
||||
search_kwargs = {
|
||||
"q": query,
|
||||
"part": "snippet",
|
||||
"type": "video",
|
||||
"maxResults": min(max(1, max_results), 20),
|
||||
}
|
||||
if video_duration in ["short", "medium", "long"]:
|
||||
search_kwargs["videoDuration"] = video_duration
|
||||
|
||||
search_resp = youtube.search().list(**search_kwargs).execute()
|
||||
items = search_resp.get("items", [])
|
||||
if not items:
|
||||
return {"success": True, "query": query, "count": 0, "videos": []}
|
||||
|
||||
video_ids = [it["id"]["videoId"] for it in items if "videoId" in it.get("id", {})]
|
||||
|
||||
# Batch fetch details (duration, view count, stats) - only 1 unit!
|
||||
details_map = {}
|
||||
if video_ids:
|
||||
videos_resp = youtube.videos().list(
|
||||
id=",".join(video_ids),
|
||||
part="contentDetails,statistics,snippet"
|
||||
).execute()
|
||||
for v in videos_resp.get("items", []):
|
||||
details_map[v["id"]] = v
|
||||
|
||||
videos = []
|
||||
for item in items:
|
||||
vid = item["id"].get("videoId")
|
||||
if not vid:
|
||||
continue
|
||||
snip = item.get("snippet", {})
|
||||
v_detail = details_map.get(vid, {})
|
||||
cd = v_detail.get("contentDetails", {})
|
||||
stats = v_detail.get("statistics", {})
|
||||
|
||||
duration_raw = cd.get("duration", "")
|
||||
duration_formatted = parse_iso_duration(duration_raw)
|
||||
|
||||
videos.append({
|
||||
"video_id": vid,
|
||||
"title": snip.get("title", ""),
|
||||
"channel": snip.get("channelTitle", ""),
|
||||
"published_at": snip.get("publishedAt", ""),
|
||||
"duration": duration_formatted,
|
||||
"view_count": stats.get("viewCount", "N/A"),
|
||||
"url": f"https://www.youtube.com/watch?v={vid}",
|
||||
"description_snippet": snip.get("description", "")[:200],
|
||||
"thumbnail": snip.get("thumbnails", {}).get("high", {}).get("url", ""),
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"query": query,
|
||||
"count": len(videos),
|
||||
"videos": videos,
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"YouTube search failed: {str(e)}",
|
||||
"videos": [],
|
||||
}
|
||||
|
||||
|
||||
def get_transcript(
|
||||
video_url_or_id: str,
|
||||
languages: Optional[List[str]] = None,
|
||||
include_timestamps: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
"""Fetch video transcript/subtitles using youtube-transcript-api."""
|
||||
video_id = extract_video_id(video_url_or_id)
|
||||
if not video_id:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Could not extract a valid YouTube video ID from '{video_url_or_id}'.",
|
||||
}
|
||||
|
||||
lang_pref = languages or ["de", "en"]
|
||||
|
||||
try:
|
||||
from youtube_transcript_api import YouTubeTranscriptApi
|
||||
|
||||
api = YouTubeTranscriptApi()
|
||||
|
||||
# Try fetching with preferred languages
|
||||
transcript_obj = None
|
||||
detected_lang = None
|
||||
is_generated = False
|
||||
|
||||
# Use .list(video_id) to inspect available tracks and find best match
|
||||
try:
|
||||
transcript_list = api.list(video_id)
|
||||
# Find best match or auto-generated
|
||||
try:
|
||||
transcript_obj = transcript_list.find_transcript(lang_pref)
|
||||
except Exception:
|
||||
# If no direct match in lang_pref, try any available transcript and translate if possible
|
||||
for t in transcript_list:
|
||||
transcript_obj = t
|
||||
break
|
||||
|
||||
if transcript_obj:
|
||||
detected_lang = transcript_obj.language_code
|
||||
is_generated = transcript_obj.is_generated
|
||||
snippets = transcript_obj.fetch()
|
||||
else:
|
||||
# Fallback to direct fetch
|
||||
snippets = api.fetch(video_id, languages=lang_pref)
|
||||
except Exception:
|
||||
# Fallback to direct fetch
|
||||
snippets = api.fetch(video_id, languages=lang_pref)
|
||||
|
||||
formatted_lines = []
|
||||
full_text_chunks = []
|
||||
total_duration = 0.0
|
||||
|
||||
for item in snippets:
|
||||
# item may be an object (FetchedTranscriptSnippet) or dict
|
||||
if hasattr(item, "text"):
|
||||
text = item.text.strip()
|
||||
start = getattr(item, "start", 0.0)
|
||||
duration = getattr(item, "duration", 0.0)
|
||||
elif isinstance(item, dict):
|
||||
text = item.get("text", "").strip()
|
||||
start = item.get("start", 0.0)
|
||||
duration = item.get("duration", 0.0)
|
||||
else:
|
||||
text = str(item).strip()
|
||||
start = 0.0
|
||||
duration = 0.0
|
||||
|
||||
if not text:
|
||||
continue
|
||||
|
||||
timestamp = format_seconds(start)
|
||||
formatted_lines.append(f"[{timestamp}] {text}")
|
||||
full_text_chunks.append(text)
|
||||
total_duration = max(total_duration, start + duration)
|
||||
|
||||
full_text = " ".join(full_text_chunks)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"video_id": video_id,
|
||||
"url": f"https://www.youtube.com/watch?v={video_id}",
|
||||
"language": detected_lang or "auto",
|
||||
"is_generated": is_generated,
|
||||
"duration_seconds": round(total_duration, 1),
|
||||
"duration_formatted": format_seconds(total_duration),
|
||||
"snippet_count": len(formatted_lines),
|
||||
"formatted_transcript": "\n".join(formatted_lines) if include_timestamps else full_text,
|
||||
"full_text": full_text,
|
||||
}
|
||||
except Exception as e:
|
||||
err_msg = str(e)
|
||||
if "TranscriptsDisabled" in err_msg or "Subtitles are disabled" in err_msg:
|
||||
return {
|
||||
"success": False,
|
||||
"video_id": video_id,
|
||||
"error": "Transcripts are disabled for this video by the uploader.",
|
||||
}
|
||||
elif "NoTranscriptFound" in err_msg:
|
||||
return {
|
||||
"success": False,
|
||||
"video_id": video_id,
|
||||
"error": f"No transcript found in requested languages ({lang_pref}).",
|
||||
}
|
||||
return {
|
||||
"success": False,
|
||||
"video_id": video_id,
|
||||
"error": f"Failed to retrieve transcript: {err_msg}",
|
||||
}
|
||||
|
||||
|
||||
def get_video_details(
|
||||
video_url_or_id: str,
|
||||
api_key: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Fetch complete video details and description."""
|
||||
video_id = extract_video_id(video_url_or_id)
|
||||
if not video_id:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Could not extract a valid YouTube video ID from '{video_url_or_id}'.",
|
||||
}
|
||||
|
||||
key = api_key or YOUTUBE_API_KEY
|
||||
if not key:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "YOUTUBE_API_KEY is not set.",
|
||||
}
|
||||
|
||||
try:
|
||||
from googleapiclient.discovery import build
|
||||
youtube = build("youtube", "v3", developerKey=key, cache_discovery=False)
|
||||
resp = youtube.videos().list(
|
||||
id=video_id,
|
||||
part="snippet,contentDetails,statistics"
|
||||
).execute()
|
||||
|
||||
items = resp.get("items", [])
|
||||
if not items:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Video '{video_id}' not found.",
|
||||
}
|
||||
|
||||
item = items[0]
|
||||
snip = item.get("snippet", {})
|
||||
cd = item.get("contentDetails", {})
|
||||
stats = item.get("statistics", {})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"video_id": video_id,
|
||||
"title": snip.get("title", ""),
|
||||
"channel": snip.get("channelTitle", ""),
|
||||
"published_at": snip.get("publishedAt", ""),
|
||||
"description": snip.get("description", ""),
|
||||
"duration": parse_iso_duration(cd.get("duration", "")),
|
||||
"view_count": stats.get("viewCount", "0"),
|
||||
"like_count": stats.get("likeCount", "0"),
|
||||
"comment_count": stats.get("commentCount", "0"),
|
||||
"tags": snip.get("tags", []),
|
||||
"url": f"https://www.youtube.com/watch?v={video_id}",
|
||||
"thumbnail": snip.get("thumbnails", {}).get("maxres", snip.get("thumbnails", {}).get("high", {})).get("url", ""),
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Failed to get video details: {str(e)}",
|
||||
}
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# MCP Protocol Handlers (JSON-RPC 2.0 stdio)
|
||||
# ==============================================================================
|
||||
|
||||
TOOLS_DEFINITIONS = [
|
||||
{
|
||||
"name": "youtube_search",
|
||||
"description": "Search YouTube for videos matching a query. Returns video title, channel, video ID, formatted duration, view count, and direct link.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["query"],
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "The search term or topic (e.g. 'Ninja Creami Deluxe Rezepte', 'Kubernetes Helm Tutorial').",
|
||||
},
|
||||
"max_results": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of videos to return (default 5, max 20).",
|
||||
"default": 5,
|
||||
},
|
||||
"video_duration": {
|
||||
"type": "string",
|
||||
"description": "Filter by duration: 'any' (default), 'short' (< 4m), 'medium' (4m-20m), 'long' (> 20m).",
|
||||
"enum": ["any", "short", "medium", "long"],
|
||||
"default": "any",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "youtube_get_transcript",
|
||||
"description": "Fetch video transcript / subtitles with timestamps. Free and unlimited. Essential for summarizing YouTube videos, extracting recipes, interview takeaways, or step-by-step instructions.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["video_url_or_id"],
|
||||
"properties": {
|
||||
"video_url_or_id": {
|
||||
"type": "string",
|
||||
"description": "YouTube URL (e.g. 'https://youtu.be/...' or 'https://www.youtube.com/watch?v=...') or 11-char video ID.",
|
||||
},
|
||||
"languages": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Preferred language codes in priority order (default ['de', 'en']).",
|
||||
"default": ["de", "en"],
|
||||
},
|
||||
"include_timestamps": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to include [HH:MM:SS] timestamps for each segment (default True).",
|
||||
"default": True,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "youtube_video_details",
|
||||
"description": "Fetch complete YouTube video details including full description, channel, publish date, duration, tags, and stats.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["video_url_or_id"],
|
||||
"properties": {
|
||||
"video_url_or_id": {
|
||||
"type": "string",
|
||||
"description": "YouTube video URL or 11-character video ID.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def run_mcp_server():
|
||||
"""Run JSON-RPC 2.0 stdio loop for OpenClaw."""
|
||||
while True:
|
||||
try:
|
||||
line = sys.stdin.readline()
|
||||
if not line:
|
||||
break
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
req = json.loads(line)
|
||||
req_id = req.get("id")
|
||||
method = req.get("method")
|
||||
params = req.get("params", {})
|
||||
|
||||
if method == "initialize":
|
||||
res = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
"result": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {"tools": {}},
|
||||
"serverInfo": {
|
||||
"name": "openclaw-youtube",
|
||||
"version": "1.0.0",
|
||||
},
|
||||
},
|
||||
}
|
||||
print(json.dumps(res), flush=True)
|
||||
|
||||
elif method == "notifications/initialized":
|
||||
pass
|
||||
|
||||
elif method == "tools/list":
|
||||
res = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
"result": {"tools": TOOLS_DEFINITIONS},
|
||||
}
|
||||
print(json.dumps(res), flush=True)
|
||||
|
||||
elif method == "tools/call":
|
||||
tool_name = params.get("name")
|
||||
args = params.get("arguments", {})
|
||||
content = []
|
||||
|
||||
if tool_name == "youtube_search":
|
||||
result = search_videos(
|
||||
query=args.get("query", ""),
|
||||
max_results=args.get("max_results", 5),
|
||||
video_duration=args.get("video_duration", "any"),
|
||||
)
|
||||
content.append({"type": "text", "text": json.dumps(result, indent=2, ensure_ascii=False)})
|
||||
|
||||
elif tool_name == "youtube_get_transcript":
|
||||
result = get_transcript(
|
||||
video_url_or_id=args.get("video_url_or_id", ""),
|
||||
languages=args.get("languages", ["de", "en"]),
|
||||
include_timestamps=args.get("include_timestamps", True),
|
||||
)
|
||||
content.append({"type": "text", "text": json.dumps(result, indent=2, ensure_ascii=False)})
|
||||
|
||||
elif tool_name == "youtube_video_details":
|
||||
result = get_video_details(
|
||||
video_url_or_id=args.get("video_url_or_id", "")
|
||||
)
|
||||
content.append({"type": "text", "text": json.dumps(result, indent=2, ensure_ascii=False)})
|
||||
|
||||
else:
|
||||
content.append({"type": "text", "text": f"Error: Unknown tool '{tool_name}'"})
|
||||
|
||||
res = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
"result": {"content": content},
|
||||
}
|
||||
print(json.dumps(res), flush=True)
|
||||
|
||||
else:
|
||||
res = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
"error": {"code": -32601, "message": f"Method '{method}' not found"},
|
||||
}
|
||||
print(json.dumps(res), flush=True)
|
||||
|
||||
except Exception as e:
|
||||
err_res = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": None,
|
||||
"error": {"code": -32603, "message": f"Internal error: {str(e)}"},
|
||||
}
|
||||
print(json.dumps(err_res), flush=True)
|
||||
|
||||
|
||||
def run_cli():
|
||||
"""CLI testing interface."""
|
||||
parser = argparse.ArgumentParser(description="YouTube MCP Server & CLI Tool")
|
||||
subparsers = parser.add_subparsers(dest="command", help="Subcommand")
|
||||
|
||||
# Search
|
||||
p_search = subparsers.add_parser("search", help="Search videos")
|
||||
p_search.add_argument("query", help="Search query")
|
||||
p_search.add_argument("--limit", "-n", type=int, default=5, help="Max results")
|
||||
p_search.add_argument("--duration", choices=["any", "short", "medium", "long"], default="any")
|
||||
|
||||
# Transcript
|
||||
p_trans = subparsers.add_parser("transcript", help="Get transcript")
|
||||
p_trans.add_argument("video", help="URL or video ID")
|
||||
p_trans.add_argument("--languages", "-l", default="de,en", help="Comma separated language preferences")
|
||||
p_trans.add_argument("--no-timestamps", action="store_true", help="Omit timestamps")
|
||||
|
||||
# Details
|
||||
p_details = subparsers.add_parser("details", help="Get video details")
|
||||
p_details.add_argument("video", help="URL or video ID")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command == "search":
|
||||
res = search_videos(query=args.query, max_results=args.limit, video_duration=args.duration)
|
||||
print(json.dumps(res, indent=2, ensure_ascii=False))
|
||||
elif args.command == "transcript":
|
||||
langs = [l.strip() for l in args.languages.split(",") if l.strip()]
|
||||
res = get_transcript(video_url_or_id=args.video, languages=langs, include_timestamps=not args.no_timestamps)
|
||||
print(json.dumps(res, indent=2, ensure_ascii=False))
|
||||
elif args.command == "details":
|
||||
res = get_video_details(video_url_or_id=args.video)
|
||||
print(json.dumps(res, indent=2, ensure_ascii=False))
|
||||
else:
|
||||
parser.print_help()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) == 1 or (len(sys.argv) == 2 and sys.argv[1] == "--stdio"):
|
||||
run_mcp_server()
|
||||
else:
|
||||
run_cli()
|
||||
@@ -0,0 +1,110 @@
|
||||
---
|
||||
name: zwift-workout
|
||||
description: Create, preview, edit, validate and publish Zwift .zwo workout files for cycling (bike-only). Use when the user asks to build a Zwift workout, generate a .zwo file, convert a training idea into a Zwift session, preview/adjust an existing workout, or publish a workout for import into Zwift.
|
||||
---
|
||||
|
||||
# 🚴 Zwift Workout (.zwo) Builder
|
||||
|
||||
Builds valid Zwift `.zwo` files from a small JSON spec, renders a human-readable
|
||||
preview for confirmation, and publishes the finished file. **Bike-only.**
|
||||
|
||||
## When to use
|
||||
|
||||
- "Bau mir ein Zwift-Workout …" / "Create a .zwo for 5×3min VO2max"
|
||||
- "Zeig mir das Workout" / "änder die Intervalle auf 4×5min"
|
||||
- "Lade das Workout in Zwift" (publish to hosting for import)
|
||||
|
||||
## Core workflow (always follow this order)
|
||||
|
||||
1. **Draft the spec.** Either pick a deterministic template
|
||||
(`zwo.py templates` → `endurance | sweetspot | vo2max | sprint`) or write a
|
||||
JSON spec from the user's request. Keep power as FTP fractions
|
||||
(`0.88` = 88 %, `1.15` = 115 %); durations in **seconds**.
|
||||
2. **Render the preview and ask for confirmation.** Run
|
||||
`python3 {baseDir}/scripts/zwo.py render <spec.json>` (or `build … --preview`)
|
||||
and show the table to the user. **Do not publish before the user confirms.**
|
||||
3. **Apply changes.** Edit the JSON spec and re-render; repeat until confirmed.
|
||||
4. **Build + publish on confirmation.** Run
|
||||
`python3 {baseDir}/scripts/zwo.py build <spec.json> -o <name>.zwo --publish`.
|
||||
This validates, writes the file, and uploads it via the `hosting` skill
|
||||
(`https://hosting.haumdaucher.de/docs/…`).
|
||||
5. **Deliver.** Post the hosting link (and the `.zwo` as `MEDIA:<path>` if the
|
||||
user wants the file directly). Optionally offer a QR code (hosting skill rule).
|
||||
6. **File the result into the wiki** under `syntheses/` or `concepts/` per the
|
||||
Knowledge Wiki protocol (see below).
|
||||
|
||||
## JSON spec format
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "VO2 Max 5x3",
|
||||
"author": "IT Guy",
|
||||
"description": "5 × 3 min @ 115 % FTP, 3 min Pause.",
|
||||
"sportType": "bike",
|
||||
"tags": ["vo2max", "structured"],
|
||||
"steps": [
|
||||
{"type": "Warmup", "duration": 600, "powerLow": 0.50, "powerHigh": 0.75},
|
||||
{"type": "IntervalsT", "repeat": 5, "onDuration": 180, "offDuration": 180,
|
||||
"onPower": 1.15, "offPower": 0.55},
|
||||
{"type": "Cooldown", "duration": 300, "powerLow": 0.75, "powerHigh": 0.40}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Supported step types (bike)
|
||||
|
||||
| `type` | Required fields | Optional |
|
||||
|---|---|---|
|
||||
| `Warmup` | `duration`, `powerLow`, `powerHigh` | `cadence` |
|
||||
| `Cooldown` | `duration`, `powerLow`, `powerHigh` | `cadence` |
|
||||
| `SteadyState` | `duration`, `power` | `cadence` |
|
||||
| `Ramp` | `duration`, `powerLow`, `powerHigh` | — |
|
||||
| `IntervalsT` | `repeat`, `onDuration`, `offDuration`, `onPower`, `offPower` | `cadence`, `cadenceResting` |
|
||||
| `FreeRide` | `duration` | `flatRoad` |
|
||||
| `MaxEffort` | `duration` | — |
|
||||
| `SolidState` | `duration`, `power` | — |
|
||||
| `RestDay` | — | — |
|
||||
|
||||
Power values are **fractions of FTP**; duration is in **seconds**. See
|
||||
`references/zwo-format.md` for the full tag/attribute reference.
|
||||
|
||||
## CLI
|
||||
|
||||
```bash
|
||||
python3 {baseDir}/scripts/zwo.py templates # list templates
|
||||
python3 {baseDir}/scripts/zwo.py from-template vo2max --title "VO2 Max 5x3" --publish
|
||||
python3 {baseDir}/scripts/zwo.py render my.json # preview only
|
||||
python3 {baseDir}/scripts/zwo.py build my.json -o out.zwo --preview --publish
|
||||
python3 {baseDir}/scripts/zwo.py validate existing.zwo # check a file
|
||||
python3 {baseDir}/scripts/zwo.py parse existing.zwo --json # .zwo -> spec (editable)
|
||||
```
|
||||
|
||||
## Guardrails
|
||||
|
||||
- **Power range:** `0.20 – 1.50` FTP. Outside → hard error (catches unit mistakes).
|
||||
- **Durations:** integer seconds, `> 0`; `< 5 s` and `> 4 h` produce warnings.
|
||||
- **Structure:** exactly one `<workout>`, root `<workout_file>`, `name` required,
|
||||
`sportType` must be `bike`.
|
||||
- **Round-trip check:** output XML is re-parsed with `xml.etree` before writing;
|
||||
text fields are escaped, so umlauts/quotes never corrupt the file.
|
||||
- **Never publish without explicit user confirmation** of the rendered preview.
|
||||
|
||||
## Reference & fixtures
|
||||
|
||||
- `references/zwo-format.md` — condensed tag/attribute reference (offline copy).
|
||||
- `assets/example-vo2max.zwo` — known-good fixture; use for regression tests.
|
||||
|
||||
## Knowledge Wiki filing
|
||||
|
||||
After a workout is confirmed and published, file it per the Karpathy LLM Wiki
|
||||
protocol: create/update a `concepts/` or `syntheses/` page recording the workout
|
||||
name, structure, power targets, hosting URL, and rationale, then append a
|
||||
`log.md` entry (`ingest`/`synthesis`). Cross-link the Fitness agent page.
|
||||
|
||||
## Inter-agent synergy
|
||||
|
||||
`agents/fitness.md` owns training intent (Tacx Flux 2, FTP 275 W). This skill is
|
||||
the execution layer for structured cycling sessions. For evidence-based
|
||||
protocols (e.g. SIT 30 s / 4 min), consult the Knowledge Wiki first via
|
||||
`wiki_search`. A future refactor can share one spec module with `garmin-workout`
|
||||
(one source spec → Garmin workout + `.zwo`).
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<workout_file>
|
||||
<author>IT Guy</author>
|
||||
<name>VO2 Max 5x3</name>
|
||||
<description>5 x 3 min @ 115 % FTP, 3 min Pause.</description>
|
||||
<sportType>bike</sportType>
|
||||
<tags>
|
||||
<tag name="vo2max"/>
|
||||
<tag name="zwift"/>
|
||||
</tags>
|
||||
<workout>
|
||||
<Warmup Duration="600" PowerLow="0.5" PowerHigh="0.75"/>
|
||||
<IntervalsT Repeat="5" OnDuration="180" OffDuration="180" OnPower="1.15" OffPower="0.55"/>
|
||||
<Cooldown Duration="300" PowerLow="0.75" PowerHigh="0.4"/>
|
||||
</workout>
|
||||
</workout_file>
|
||||
@@ -0,0 +1,74 @@
|
||||
# Zwift .zwo Format Reference (condensed)
|
||||
|
||||
Offline copy of the essentials from
|
||||
<https://github.com/h4l/zwift-workout-file-reference>. A `.zwo` file is XML; there
|
||||
is **no official Zwift schema**. This is the subset Zwift reliably accepts.
|
||||
|
||||
## Document structure
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<workout_file>
|
||||
<author>IT Guy</author>
|
||||
<name>VO2 Max 5x3</name>
|
||||
<description>5 × 3 min @ 115 % FTP</description>
|
||||
<sportType>bike</sportType>
|
||||
<tags><tag name="vo2max"/></tags>
|
||||
<workout>
|
||||
<Warmup Duration="600" PowerLow="0.50" PowerHigh="0.75"/>
|
||||
<IntervalsT Repeat="5" OnDuration="180" OffDuration="180"
|
||||
OnPower="1.15" OffPower="0.55"/>
|
||||
<Cooldown Duration="300" PowerLow="0.75" PowerHigh="0.40"/>
|
||||
</workout>
|
||||
</workout_file>
|
||||
```
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Power** is a fraction of FTP (`0.50` = 50 %, `1.15` = 115 %). `units="power"`
|
||||
is the implicit default.
|
||||
- **Duration** is always in **seconds**.
|
||||
- `<sportType>` values seen: `bike` (61 %), `run` (37 %), `swim`, `ride`. We use `bike`.
|
||||
- Text cues are children of a step: `<textevent timeoffset="30" message="Push!"/>`.
|
||||
|
||||
## Step elements (child of `<workout>`)
|
||||
|
||||
| Element | Attributes (relevant) | Meaning |
|
||||
|---|---|---|
|
||||
| `Warmup` | `Duration`, `PowerLow`, `PowerHigh`, `Cadence` | linear ramp Low→High |
|
||||
| `Cooldown` | `Duration`, `PowerLow`, `PowerHigh`, `Cadence` | linear ramp High→Low |
|
||||
| `SteadyState` | `Duration`, `Power`, (`PowerLow`,`PowerHigh`), `Cadence` | constant target |
|
||||
| `SolidState` | `Duration`, `Power` | constant power, no ERG variation |
|
||||
| `Ramp` | `Duration`, `PowerLow`, `PowerHigh` | explicit ramp |
|
||||
| `IntervalsT` | `Repeat`, `OnDuration`, `OffDuration`, `OnPower`, `OffPower`, `Cadence`, `CadenceResting`, `OverUnder` | N repeated on/off pairs |
|
||||
| `FreeRide` | `Duration`, `FlatRoad` | ERG off, rider's choice |
|
||||
| `MaxEffort` | `Duration` | all-out, ERG off |
|
||||
| `RestDay` | — | placeholder |
|
||||
| `textevent` | `timeoffset`, `message`, `duration` | on-screen cue |
|
||||
|
||||
## Top-level metadata elements (child of `<workout_file>`)
|
||||
|
||||
`activitySaveName`, `author`, `author_alias`, `authorIcon`, `category`,
|
||||
`categoryIndex`, `description`, `durationType`, `entid`, `ftpOverride`,
|
||||
`ftpMaleOverride`, `ftpFemaleOverride`, `name`, `nameImperial`, `nameMetric`,
|
||||
`painIndex`, `setFtpAtPercentage`, `ShowCP20`, `Skippable`, `sportType`,
|
||||
`subcategory`, `tags`, `test_details`, `visibleAfterTime`, `visibleOutsidePlan`,
|
||||
`workout`, `workoutLength`, `WorkoutPlan`.
|
||||
|
||||
**Minimal viable set we emit:** `author`, `name`, `description`, `sportType`,
|
||||
`tags`, `workout`.
|
||||
|
||||
## Importing a .zwo into Zwift
|
||||
|
||||
1. Copy the file into `~/Documents/Zwift/Workouts/<userID>/` (folder name is your
|
||||
numeric Zwift ID), **or**
|
||||
2. Use the Custom Workouts import on zwift.com, **or**
|
||||
3. Drop it into the Zwift folder sync (Dropbox/OneDrive) if configured.
|
||||
|
||||
Then restart Zwift; the workout appears under **Custom Workouts**.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Malformed XML or unescaped `&`/quotes → Zwift silently ignores the file.
|
||||
- Power outside ~0.20–1.50 FTP is almost always a unit mistake.
|
||||
- Duplicate/unknown attributes are tolerated but ignored; keep it minimal.
|
||||
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env python3
|
||||
"""zwo.py — CLI for creating, validating, previewing and publishing Zwift .zwo workouts.
|
||||
|
||||
Commands
|
||||
--------
|
||||
build <spec.json> [-o out.zwo] [--preview] [--publish]
|
||||
from-template <name> [--title T] [-o out.zwo] [--preview] [--publish]
|
||||
validate <file.zwo>
|
||||
parse <file.zwo> [--json]
|
||||
render <file.zwo|spec.json>
|
||||
templates
|
||||
|
||||
Exit codes: 0 = ok, 1 = validation/usage error.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import zwo_builder as zb # noqa: E402
|
||||
|
||||
HOSTING_SCRIPT = Path("/home/node/mop-skills/mop-skills/skills/hosting/scripts/publish_file.py")
|
||||
|
||||
|
||||
def _slug(text: str) -> str:
|
||||
s = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
|
||||
return s or "workout"
|
||||
|
||||
|
||||
def _load_spec(path: str) -> dict:
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
return json.load(fh)
|
||||
|
||||
|
||||
def _publish(zwo_path: Path, title: str) -> str | None:
|
||||
"""Publish the .zwo via the hosting skill. Returns the public URL or None."""
|
||||
if not HOSTING_SCRIPT.exists():
|
||||
print(f"⚠️ hosting publisher not found at {HOSTING_SCRIPT}", file=sys.stderr)
|
||||
return None
|
||||
cmd = [sys.executable, str(HOSTING_SCRIPT), "--file", str(zwo_path),
|
||||
"--category", "docs", "--slug", _slug(title)]
|
||||
try:
|
||||
out = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"⚠️ hosting publish failed: {exc}", file=sys.stderr)
|
||||
return None
|
||||
url = None
|
||||
for line in out.stdout.splitlines():
|
||||
if line.startswith("URL:"):
|
||||
url = line.split("URL:", 1)[1].strip()
|
||||
if not url:
|
||||
print(out.stdout, out.stderr, file=sys.stderr)
|
||||
return url
|
||||
|
||||
|
||||
def cmd_build(args: argparse.Namespace) -> int:
|
||||
spec = _load_spec(args.spec)
|
||||
warnings = zb.validate_spec(spec)
|
||||
out = Path(args.output or f"{_slug(spec['name'])}.zwo")
|
||||
zb.write_zwo(spec, str(out))
|
||||
print(f"✅ {out} ({out.stat().st_size} bytes)")
|
||||
for w in warnings:
|
||||
print(f"⚠️ {w}")
|
||||
if args.preview:
|
||||
print()
|
||||
print(zb.render_text(spec))
|
||||
if args.publish:
|
||||
url = _publish(out, spec["name"])
|
||||
if url:
|
||||
print(f"🔗 {url}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_from_template(args: argparse.Namespace) -> int:
|
||||
spec = zb.from_template(args.name, args.title)
|
||||
out = Path(args.output or f"{_slug(spec['name'])}.zwo")
|
||||
zb.write_zwo(spec, str(out))
|
||||
print(f"✅ {out} ({out.stat().st_size} bytes)")
|
||||
print()
|
||||
print(zb.render_text(spec))
|
||||
if args.publish:
|
||||
url = _publish(out, spec["name"])
|
||||
if url:
|
||||
print(f"🔗 {url}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_validate(args: argparse.Namespace) -> int:
|
||||
ok, messages = zb.validate_file(args.file)
|
||||
if ok:
|
||||
print(f"✅ {args.file} is a valid .zwo file")
|
||||
for m in messages:
|
||||
print(f"⚠️ {m}")
|
||||
return 0
|
||||
print(f"❌ {args.file} is invalid:")
|
||||
for m in messages:
|
||||
print(f" - {m}")
|
||||
return 1
|
||||
|
||||
|
||||
def cmd_parse(args: argparse.Namespace) -> int:
|
||||
spec = zb.parse_file(args.file)
|
||||
if args.json:
|
||||
print(json.dumps(spec, ensure_ascii=False, indent=2))
|
||||
else:
|
||||
print(zb.render_text(spec))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_render(args: argparse.Namespace) -> int:
|
||||
if args.file.endswith(".json"):
|
||||
spec = _load_spec(args.file)
|
||||
else:
|
||||
spec = zb.parse_file(args.file)
|
||||
print(zb.render_text(spec))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_templates(_: argparse.Namespace) -> int:
|
||||
for t in zb.templates():
|
||||
print(t)
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(prog="zwo.py", description="Zwift .zwo workout toolkit (bike-only)")
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
p = sub.add_parser("build", help="build a .zwo from a JSON spec")
|
||||
p.add_argument("spec")
|
||||
p.add_argument("-o", "--output")
|
||||
p.add_argument("--preview", action="store_true", help="print the human-readable preview")
|
||||
p.add_argument("--publish", action="store_true", help="upload via the hosting skill")
|
||||
p.set_defaults(func=cmd_build)
|
||||
|
||||
p = sub.add_parser("from-template", help="build from a built-in deterministic template")
|
||||
p.add_argument("name")
|
||||
p.add_argument("--title")
|
||||
p.add_argument("-o", "--output")
|
||||
p.add_argument("--publish", action="store_true")
|
||||
p.set_defaults(func=cmd_from_template)
|
||||
|
||||
p = sub.add_parser("validate", help="validate an existing .zwo file")
|
||||
p.add_argument("file")
|
||||
p.set_defaults(func=cmd_validate)
|
||||
|
||||
p = sub.add_parser("parse", help="parse a .zwo into a JSON spec")
|
||||
p.add_argument("file")
|
||||
p.add_argument("--json", action="store_true")
|
||||
p.set_defaults(func=cmd_parse)
|
||||
|
||||
p = sub.add_parser("render", help="render a human-readable preview")
|
||||
p.add_argument("file")
|
||||
p.set_defaults(func=cmd_render)
|
||||
|
||||
p = sub.add_parser("templates", help="list built-in templates")
|
||||
p.set_defaults(func=cmd_templates)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
return args.func(args)
|
||||
except zb.ZwoValidationError as exc:
|
||||
print(f"❌ validation error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
print(f"❌ {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,484 @@
|
||||
"""zwo_builder.py — Build, validate, parse and render Zwift ``.zwo`` workout files.
|
||||
|
||||
Bike-only implementation following the community format reference
|
||||
(https://github.com/h4l/zwift-workout-file-reference). No official Zwift XSD
|
||||
exists; this module encodes the subset that Zwift reliably accepts.
|
||||
|
||||
Public API
|
||||
----------
|
||||
build_spec(spec) -> xml.etree.ElementTree.Element (raises on error)
|
||||
spec_to_xml(spec) -> str
|
||||
validate_spec(spec) -> list[str] (non-fatal warnings)
|
||||
validate_file(path) -> (ok: bool, messages: list[str])
|
||||
parse_file(path) -> dict (spec)
|
||||
render_text(spec) -> str (human-readable preview)
|
||||
estimate(spec) -> dict (duration / NP / IF / TSS)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import Any
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Constants / guardrails
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
VALID_SPORT_TYPES = {"bike"}
|
||||
VALID_DURATION_TYPES = {"time"}
|
||||
|
||||
POWER_MIN = 0.20 # 20 % FTP — below this is almost certainly a mistake
|
||||
POWER_MAX = 1.50 # 150 % FTP — above this is very unusual for a *.zwo target
|
||||
DURATION_MIN = 5 # seconds; shorter steps are usually noise
|
||||
DURATION_MAX = 4 * 3600 # 4 h single step sanity cap
|
||||
|
||||
# spec key -> (XML attribute name, coercion)
|
||||
_STEP_SPECS: dict[str, dict[str, tuple[str, type]]] = {
|
||||
"Warmup": {
|
||||
"duration": ("Duration", int),
|
||||
"powerLow": ("PowerLow", float),
|
||||
"powerHigh": ("PowerHigh", float),
|
||||
"cadence": ("Cadence", int),
|
||||
},
|
||||
"Cooldown": {
|
||||
"duration": ("Duration", int),
|
||||
"powerLow": ("PowerLow", float),
|
||||
"powerHigh": ("PowerHigh", float),
|
||||
"cadence": ("Cadence", int),
|
||||
},
|
||||
"SteadyState": {
|
||||
"duration": ("Duration", int),
|
||||
"power": ("Power", float),
|
||||
"cadence": ("Cadence", int),
|
||||
},
|
||||
"Ramp": {
|
||||
"duration": ("Duration", int),
|
||||
"powerLow": ("PowerLow", float),
|
||||
"powerHigh": ("PowerHigh", float),
|
||||
},
|
||||
"IntervalsT": {
|
||||
"repeat": ("Repeat", int),
|
||||
"onDuration": ("OnDuration", int),
|
||||
"offDuration": ("OffDuration", int),
|
||||
"onPower": ("OnPower", float),
|
||||
"offPower": ("OffPower", float),
|
||||
"cadence": ("Cadence", int),
|
||||
"cadenceResting": ("CadenceResting", int),
|
||||
},
|
||||
"FreeRide": {
|
||||
"duration": ("Duration", int),
|
||||
"flatRoad": ("FlatRoad", int),
|
||||
},
|
||||
"MaxEffort": {
|
||||
"duration": ("Duration", int),
|
||||
},
|
||||
"SolidState": {
|
||||
"duration": ("Duration", int),
|
||||
"power": ("Power", float),
|
||||
},
|
||||
"RestDay": {},
|
||||
"TextEvent": {
|
||||
"timeoffset": ("timeoffset", int),
|
||||
"message": ("message", str),
|
||||
"duration": ("duration", int),
|
||||
},
|
||||
}
|
||||
|
||||
# Elements that carry a target power (used for the TSS estimate)
|
||||
_POWER_STEPS = {"Warmup", "Cooldown", "SteadyState", "Ramp", "IntervalsT", "SolidState"}
|
||||
|
||||
REQUIRED_STEP_ATTRS: dict[str, tuple[str, ...]] = {
|
||||
"Warmup": ("duration", "powerLow", "powerHigh"),
|
||||
"Cooldown": ("duration", "powerLow", "powerHigh"),
|
||||
"SteadyState": ("duration", "power"),
|
||||
"Ramp": ("duration", "powerLow", "powerHigh"),
|
||||
"IntervalsT": ("repeat", "onDuration", "offDuration", "onPower", "offPower"),
|
||||
"FreeRide": ("duration",),
|
||||
"MaxEffort": ("duration",),
|
||||
"SolidState": ("duration", "power"),
|
||||
"RestDay": (),
|
||||
"TextEvent": ("timeoffset", "message"),
|
||||
}
|
||||
|
||||
|
||||
class ZwoValidationError(ValueError):
|
||||
"""Raised when a spec cannot produce a valid .zwo file."""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Validation
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _check_power(value: float, where: str, errors: list[str], warnings: list[str]) -> None:
|
||||
if value < POWER_MIN:
|
||||
errors.append(f"{where}: power {value:.2f} (< {POWER_MIN:.2f} FTP) is implausible")
|
||||
elif value > POWER_MAX:
|
||||
errors.append(f"{where}: power {value:.2f} (> {POWER_MAX:.2f} FTP) is implausible")
|
||||
|
||||
|
||||
def _check_duration(value: int, where: str, errors: list[str], warnings: list[str]) -> None:
|
||||
if value <= 0:
|
||||
errors.append(f"{where}: duration must be > 0 seconds")
|
||||
elif value < DURATION_MIN:
|
||||
warnings.append(f"{where}: duration {value}s is very short")
|
||||
elif value > DURATION_MAX:
|
||||
warnings.append(f"{where}: duration {value}s exceeds the {DURATION_MAX}s single-step cap")
|
||||
|
||||
|
||||
def validate_spec(spec: dict[str, Any]) -> list[str]:
|
||||
"""Validate a workout spec. Raise on fatal errors, return warnings."""
|
||||
errors: list[str] = []
|
||||
warnings: list[str] = []
|
||||
|
||||
if not isinstance(spec, dict):
|
||||
raise ZwoValidationError("spec must be a JSON object")
|
||||
|
||||
name = spec.get("name")
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
errors.append("missing required field: name")
|
||||
|
||||
sport = spec.get("sportType", "bike")
|
||||
if sport not in VALID_SPORT_TYPES:
|
||||
errors.append(f"sportType {sport!r} not supported (bike-only): {sorted(VALID_SPORT_TYPES)}")
|
||||
|
||||
steps = spec.get("steps")
|
||||
if not isinstance(steps, list) or not steps:
|
||||
errors.append("spec must contain a non-empty 'steps' list")
|
||||
steps = []
|
||||
|
||||
for i, step in enumerate(steps, start=1):
|
||||
where = f"step {i}"
|
||||
if not isinstance(step, dict):
|
||||
errors.append(f"{where}: must be an object")
|
||||
continue
|
||||
stype = step.get("type")
|
||||
if stype not in _STEP_SPECS:
|
||||
errors.append(f"{where}: unknown step type {stype!r}")
|
||||
continue
|
||||
for key in REQUIRED_STEP_ATTRS.get(stype, ()):
|
||||
if step.get(key) is None:
|
||||
errors.append(f"{where} ({stype}): missing required field {key!r}")
|
||||
# power sanity
|
||||
for key in ("power", "powerLow", "powerHigh", "onPower", "offPower"):
|
||||
if key in step and step[key] is not None:
|
||||
try:
|
||||
_check_power(float(step[key]), f"{where} ({stype}).{key}", errors, warnings)
|
||||
except (TypeError, ValueError):
|
||||
errors.append(f"{where} ({stype}).{key}: not a number")
|
||||
# ramp direction is only a hint
|
||||
if stype in ("Warmup",) and step.get("powerHigh") is not None and step.get("powerLow") is not None:
|
||||
if float(step["powerHigh"]) < float(step["powerLow"]):
|
||||
warnings.append(f"{where} (Warmup): powerHigh < powerLow")
|
||||
for key in ("duration", "onDuration", "offDuration"):
|
||||
if key in step and step[key] is not None:
|
||||
try:
|
||||
_check_duration(int(step[key]), f"{where} ({stype}).{key}", errors, warnings)
|
||||
except (TypeError, ValueError):
|
||||
errors.append(f"{where} ({stype}).{key}: not an integer")
|
||||
if stype == "IntervalsT" and step.get("repeat") is not None:
|
||||
try:
|
||||
if int(step["repeat"]) < 1:
|
||||
errors.append(f"{where} (IntervalsT): repeat must be >= 1")
|
||||
except (TypeError, ValueError):
|
||||
errors.append(f"{where} (IntervalsT).repeat: not an integer")
|
||||
|
||||
if errors:
|
||||
raise ZwoValidationError("; ".join(errors))
|
||||
return warnings
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Build
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _coerce(value: Any, typ: type) -> str:
|
||||
if typ is int:
|
||||
return str(int(value))
|
||||
if typ is float:
|
||||
# Zwift accepts plain decimals; trim trailing zeros
|
||||
return f"{float(value):g}"
|
||||
return str(value)
|
||||
|
||||
|
||||
def build_spec(spec: dict[str, Any]) -> ET.Element:
|
||||
"""Build and re-validate the XML element tree for ``spec``."""
|
||||
validate_spec(spec)
|
||||
|
||||
root = ET.Element("workout_file")
|
||||
ET.SubElement(root, "author").text = str(spec.get("author", "IT Guy"))
|
||||
ET.SubElement(root, "name").text = str(spec["name"])
|
||||
if spec.get("description"):
|
||||
ET.SubElement(root, "description").text = str(spec["description"])
|
||||
ET.SubElement(root, "sportType").text = spec.get("sportType", "bike")
|
||||
|
||||
tags = spec.get("tags") or []
|
||||
if tags:
|
||||
tag_el = ET.SubElement(root, "tags")
|
||||
for t in tags:
|
||||
ET.SubElement(tag_el, "tag", {"name": str(t)})
|
||||
|
||||
workout = ET.SubElement(root, "workout")
|
||||
for step in spec["steps"]:
|
||||
stype = step["type"]
|
||||
attrs: dict[str, str] = {}
|
||||
for key, (attr, typ) in _STEP_SPECS[stype].items():
|
||||
if step.get(key) is not None:
|
||||
attrs[attr] = _coerce(step[key], typ)
|
||||
ET.SubElement(workout, stype, attrs)
|
||||
|
||||
return root
|
||||
|
||||
|
||||
def spec_to_xml(spec: dict[str, Any]) -> str:
|
||||
"""Serialize a spec to a pretty-printed .zwo XML string (with declaration)."""
|
||||
root = build_spec(spec)
|
||||
ET.indent(root, space=" ")
|
||||
body = ET.tostring(root, encoding="unicode")
|
||||
return '<?xml version="1.0" encoding="UTF-8"?>\n' + body + "\n"
|
||||
|
||||
|
||||
def write_zwo(spec: dict[str, Any], path: str) -> str:
|
||||
"""Write ``spec`` to ``path`` after a round-trip parse check. Returns path."""
|
||||
xml = spec_to_xml(spec)
|
||||
# round-trip: refuse to emit XML that cannot be re-parsed
|
||||
try:
|
||||
ET.fromstring(xml)
|
||||
except ET.ParseError as exc: # pragma: no cover - defensive
|
||||
raise ZwoValidationError(f"generated XML is not well-formed: {exc}") from exc
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
fh.write(xml)
|
||||
return path
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Parse / validate files
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _step_from_element(el: ET.Element) -> dict[str, Any]:
|
||||
stype = el.tag
|
||||
spec = {"type": stype}
|
||||
for key, (attr, typ) in _STEP_SPECS.get(stype, {}).items():
|
||||
if attr in el.attrib:
|
||||
try:
|
||||
spec[key] = typ(el.attrib[attr])
|
||||
except ValueError:
|
||||
spec[key] = el.attrib[attr]
|
||||
return spec
|
||||
|
||||
|
||||
def parse_file(path: str) -> dict[str, Any]:
|
||||
"""Parse an existing .zwo file into our spec representation."""
|
||||
tree = ET.parse(path)
|
||||
root = tree.getroot()
|
||||
if root.tag != "workout_file":
|
||||
raise ZwoValidationError(f"root element is <{root.tag}>, expected <workout_file>")
|
||||
|
||||
def text(tag: str) -> str | None:
|
||||
el = root.find(tag)
|
||||
return el.text.strip() if el is not None and el.text else None
|
||||
|
||||
tags: list[str] = []
|
||||
tags_el = root.find("tags")
|
||||
if tags_el is not None:
|
||||
tags = [t.get("name", "") for t in tags_el.findall("tag")]
|
||||
|
||||
workout = root.find("workout")
|
||||
steps = [_step_from_element(el) for el in (workout or [])]
|
||||
|
||||
return {
|
||||
"name": text("name") or "Unnamed",
|
||||
"author": text("author") or "",
|
||||
"description": text("description") or "",
|
||||
"sportType": text("sportType") or "bike",
|
||||
"tags": tags,
|
||||
"steps": steps,
|
||||
}
|
||||
|
||||
|
||||
def validate_file(path: str) -> tuple[bool, list[str]]:
|
||||
"""Validate an existing .zwo file. Returns (ok, messages)."""
|
||||
try:
|
||||
spec = parse_file(path)
|
||||
except (ET.ParseError, ZwoValidationError, OSError) as exc:
|
||||
return False, [f"parse error: {exc}"]
|
||||
try:
|
||||
warnings = validate_spec(spec)
|
||||
except ZwoValidationError as exc:
|
||||
return False, str(exc).split("; ")
|
||||
return True, warnings
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Render (human-readable preview)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _fmt_time(seconds: int) -> str:
|
||||
m, s = divmod(int(seconds), 60)
|
||||
return f"{m}:{s:02d}"
|
||||
|
||||
|
||||
def _pct(value: float) -> str:
|
||||
return f"{value * 100:g}%"
|
||||
|
||||
|
||||
def estimate(spec: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Estimate total duration, normalised power fraction and TSS."""
|
||||
total = 0
|
||||
weighted = 0.0 # sum(sec * f^2)
|
||||
untracked = 0
|
||||
for step in spec.get("steps", []):
|
||||
stype = step.get("type")
|
||||
if stype == "IntervalsT":
|
||||
reps = int(step.get("repeat", 1))
|
||||
on_d, off_d = int(step["onDuration"]), int(step["offDuration"])
|
||||
total += reps * (on_d + off_d)
|
||||
weighted += reps * (on_d * float(step["onPower"]) ** 2 + off_d * float(step["offPower"]) ** 2)
|
||||
elif stype in _POWER_STEPS:
|
||||
dur = int(step["duration"])
|
||||
if stype in ("Warmup", "Cooldown", "Ramp"):
|
||||
frac = (float(step["powerLow"]) + float(step["powerHigh"])) / 2
|
||||
else:
|
||||
frac = float(step["power"])
|
||||
total += dur
|
||||
weighted += dur * frac ** 2
|
||||
elif stype in ("FreeRide", "MaxEffort"):
|
||||
total += int(step["duration"])
|
||||
untracked += int(step["duration"])
|
||||
tss = (100 * weighted / 3600) if total else 0.0
|
||||
np_frac = (weighted / total) ** 0.5 if total else 0.0
|
||||
return {
|
||||
"duration_s": total,
|
||||
"duration": _fmt_time(total),
|
||||
"np_frac": round(np_frac, 3),
|
||||
"if": round(np_frac, 3),
|
||||
"tss": round(tss, 1),
|
||||
"untracked_s": untracked,
|
||||
}
|
||||
|
||||
|
||||
def _bar(frac: float, width: int = 40, scale: float = 1.5) -> str:
|
||||
filled = max(1, min(width, round(frac / scale * width)))
|
||||
return "█" * filled
|
||||
|
||||
|
||||
def render_text(spec: dict[str, Any]) -> str:
|
||||
"""Human-readable workout preview for confirmation before publishing."""
|
||||
est = estimate(spec)
|
||||
lines: list[str] = []
|
||||
lines.append(f"🏋️ {spec['name']}")
|
||||
if spec.get("description"):
|
||||
lines.append(f" {spec['description']}")
|
||||
lines.append(f" Sport: {spec.get('sportType', 'bike')} Autor: {spec.get('author', '')}")
|
||||
lines.append("")
|
||||
lines.append(f" # {'Step':<13}{'Dauer':>7} Details")
|
||||
lines.append(" " + "-" * 62)
|
||||
|
||||
for i, step in enumerate(spec["steps"], start=1):
|
||||
stype = step["type"]
|
||||
if stype == "IntervalsT":
|
||||
reps = int(step["repeat"])
|
||||
total = reps * (int(step["onDuration"]) + int(step["offDuration"]))
|
||||
detail = (f"{reps}× {_fmt_time(step['onDuration'])} @{_pct(step['onPower'])}"
|
||||
f" / {_fmt_time(step['offDuration'])} @{_pct(step['offPower'])}")
|
||||
dur = _fmt_time(total)
|
||||
elif stype in ("Warmup", "Cooldown", "Ramp"):
|
||||
detail = f"{_pct(step['powerLow'])} → {_pct(step['powerHigh'])}"
|
||||
dur = _fmt_time(int(step["duration"]))
|
||||
elif stype in ("SteadyState", "SolidState"):
|
||||
detail = f"konstant @{_pct(step['power'])}"
|
||||
dur = _fmt_time(int(step["duration"]))
|
||||
elif stype == "FreeRide":
|
||||
detail = "freie Fahrt (ERG aus)"
|
||||
dur = _fmt_time(int(step["duration"]))
|
||||
elif stype == "MaxEffort":
|
||||
detail = "All-out (ERG aus)"
|
||||
dur = _fmt_time(int(step["duration"]))
|
||||
elif stype == "RestDay":
|
||||
detail, dur = "Ruhetag", "—"
|
||||
elif stype == "TextEvent":
|
||||
detail, dur = f"Text: {step.get('message', '')}", "—"
|
||||
else:
|
||||
detail, dur = "", "—"
|
||||
lines.append(f" {i:<3}{stype:<13}{dur:>7} {detail}")
|
||||
|
||||
lines.append("")
|
||||
lines.append(" Power-Profil:")
|
||||
for step in spec["steps"]:
|
||||
stype = step["type"]
|
||||
if stype == "IntervalsT":
|
||||
lines.append(f" ON {_bar(float(step['onPower']))} {_pct(step['onPower'])}")
|
||||
lines.append(f" OFF {_bar(float(step['offPower']))} {_pct(step['offPower'])}")
|
||||
elif stype in ("Warmup", "Cooldown", "Ramp"):
|
||||
lines.append(f" {_bar((float(step['powerLow']) + float(step['powerHigh'])) / 2):<40} "
|
||||
f"{_pct(step['powerLow'])}→{_pct(step['powerHigh'])}")
|
||||
elif stype in ("SteadyState", "SolidState"):
|
||||
lines.append(f" {_bar(float(step['power'])):<40} {_pct(step['power'])}")
|
||||
|
||||
lines.append("")
|
||||
lines.append(f" Gesamt: {est['duration']} NP ≈ {est['np_frac'] * 100:g}% FTP "
|
||||
f"IF {est['if']:.2f} TSS ≈ {est['tss']}")
|
||||
if est["untracked_s"]:
|
||||
lines.append(f" ⚠️ {_fmt_time(est['untracked_s'])} FreeRide/MaxEffort sind nicht in der TSS-Schätzung enthalten.")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Deterministic templates ("from-text")
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
_TEMPLATES: dict[str, dict[str, Any]] = {
|
||||
"endurance": {
|
||||
"description": "Ruhige Grundlagenfahrt (Z2).",
|
||||
"steps": [
|
||||
{"type": "Warmup", "duration": 600, "powerLow": 0.50, "powerHigh": 0.65},
|
||||
{"type": "SteadyState", "duration": 3000, "power": 0.68},
|
||||
{"type": "Cooldown", "duration": 300, "powerLow": 0.65, "powerHigh": 0.45},
|
||||
],
|
||||
},
|
||||
"sweetspot": {
|
||||
"description": "3 × 12 min Sweet Spot (88 % FTP).",
|
||||
"steps": [
|
||||
{"type": "Warmup", "duration": 600, "powerLow": 0.50, "powerHigh": 0.75},
|
||||
{"type": "IntervalsT", "repeat": 3, "onDuration": 720, "offDuration": 240,
|
||||
"onPower": 0.88, "offPower": 0.55},
|
||||
{"type": "Cooldown", "duration": 300, "powerLow": 0.65, "powerHigh": 0.45},
|
||||
],
|
||||
},
|
||||
"vo2max": {
|
||||
"description": "5 × 3 min VO2max (115 % FTP).",
|
||||
"steps": [
|
||||
{"type": "Warmup", "duration": 600, "powerLow": 0.50, "powerHigh": 0.75},
|
||||
{"type": "IntervalsT", "repeat": 5, "onDuration": 180, "offDuration": 180,
|
||||
"onPower": 1.15, "offPower": 0.55},
|
||||
{"type": "Cooldown", "duration": 300, "powerLow": 0.65, "powerHigh": 0.45},
|
||||
],
|
||||
},
|
||||
"sprint": {
|
||||
"description": "SIT: 6 × 30 s all-out, 4 min Pause (wiki-verankertes Protokoll).",
|
||||
"steps": [
|
||||
{"type": "Warmup", "duration": 900, "powerLow": 0.50, "powerHigh": 0.80},
|
||||
{"type": "IntervalsT", "repeat": 6, "onDuration": 30, "offDuration": 240,
|
||||
"onPower": 1.50, "offPower": 0.40},
|
||||
{"type": "Cooldown", "duration": 420, "powerLow": 0.60, "powerHigh": 0.40},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def from_template(name: str, title: str | None = None) -> dict[str, Any]:
|
||||
"""Return a deterministic workout spec from the built-in template set."""
|
||||
key = name.strip().lower()
|
||||
if key not in _TEMPLATES:
|
||||
raise ZwoValidationError(f"unknown template {name!r}; available: {sorted(_TEMPLATES)}")
|
||||
spec = {k: (v.copy() if isinstance(v, list) else v) for k, v in _TEMPLATES[key].items()}
|
||||
spec.update({"name": title or key.title(), "author": "IT Guy", "sportType": "bike",
|
||||
"tags": ["zwift", key]})
|
||||
return spec
|
||||
|
||||
|
||||
def templates() -> list[str]:
|
||||
return sorted(_TEMPLATES)
|
||||
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Focused tests for the zwo builder. Run: python3 tests/test_zwo.py (or pytest)."""
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
|
||||
SKILL_DIR = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(SKILL_DIR / "scripts"))
|
||||
|
||||
import zwo_builder as zb # noqa: E402
|
||||
|
||||
SPEC = {
|
||||
"name": "VO2 Max 5x3",
|
||||
"author": "IT Guy",
|
||||
"description": "5 × 3 min @ 115 % FTP",
|
||||
"sportType": "bike",
|
||||
"tags": ["vo2max"],
|
||||
"steps": [
|
||||
{"type": "Warmup", "duration": 600, "powerLow": 0.50, "powerHigh": 0.75},
|
||||
{"type": "IntervalsT", "repeat": 5, "onDuration": 180, "offDuration": 180,
|
||||
"onPower": 1.15, "offPower": 0.55},
|
||||
{"type": "Cooldown", "duration": 300, "powerLow": 0.75, "powerHigh": 0.40},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_build_is_wellformed_and_roundtrips():
|
||||
xml = zb.spec_to_xml(SPEC)
|
||||
root = ET.fromstring(xml)
|
||||
assert root.tag == "workout_file"
|
||||
assert root.findtext("sportType") == "bike"
|
||||
workout = root.find("workout")
|
||||
assert [c.tag for c in workout] == ["Warmup", "IntervalsT", "Cooldown"]
|
||||
|
||||
parsed = zb.parse_file(_write_tmp(xml))
|
||||
assert parsed["name"] == "VO2 Max 5x3"
|
||||
assert parsed["steps"][1]["repeat"] == 5
|
||||
assert parsed["steps"][1]["onPower"] == 1.15
|
||||
|
||||
|
||||
def test_power_out_of_range_raises():
|
||||
bad = dict(SPEC, steps=[{"type": "SteadyState", "duration": 600, "power": 3.0}])
|
||||
try:
|
||||
zb.build_spec(bad)
|
||||
except zb.ZwoValidationError as exc:
|
||||
assert "implausible" in str(exc)
|
||||
else:
|
||||
raise AssertionError("expected ZwoValidationError")
|
||||
|
||||
|
||||
def test_missing_required_field_raises():
|
||||
bad = dict(SPEC, steps=[{"type": "IntervalsT", "repeat": 5, "onDuration": 180,
|
||||
"offDuration": 180, "onPower": 1.0}]) # offPower missing
|
||||
try:
|
||||
zb.build_spec(bad)
|
||||
except zb.ZwoValidationError as exc:
|
||||
assert "offPower" in str(exc)
|
||||
else:
|
||||
raise AssertionError("expected ZwoValidationError")
|
||||
|
||||
|
||||
def test_sport_type_bike_only():
|
||||
bad = dict(SPEC, sportType="run")
|
||||
try:
|
||||
zb.build_spec(bad)
|
||||
except zb.ZwoValidationError as exc:
|
||||
assert "sportType" in str(exc)
|
||||
else:
|
||||
raise AssertionError("expected ZwoValidationError")
|
||||
|
||||
|
||||
def test_escaping_special_chars():
|
||||
spec = dict(SPEC, name='Push & Pull "hart"', steps=[
|
||||
{"type": "SteadyState", "duration": 300, "power": 0.9}])
|
||||
xml = zb.spec_to_xml(spec)
|
||||
parsed = ET.fromstring(xml)
|
||||
assert parsed.findtext("name") == 'Push & Pull "hart"'
|
||||
|
||||
|
||||
def test_estimate_tss_and_duration():
|
||||
est = zb.estimate(SPEC)
|
||||
assert est["duration_s"] == 600 + 5 * 360 + 300
|
||||
assert 40 < est["tss"] < 120
|
||||
assert abs(est["np_frac"] - est["if"]) < 1e-9
|
||||
|
||||
|
||||
def test_templates_available():
|
||||
assert set(zb.templates()) >= {"endurance", "sweetspot", "vo2max", "sprint"}
|
||||
spec = zb.from_template("vo2max", "Test")
|
||||
assert spec["name"] == "Test"
|
||||
zb.validate_spec(spec)
|
||||
|
||||
|
||||
def test_asset_fixture_valid():
|
||||
asset = SKILL_DIR / "assets" / "example-vo2max.zwo"
|
||||
ok, messages = zb.validate_file(str(asset))
|
||||
assert ok, messages
|
||||
|
||||
|
||||
def test_render_contains_key_lines():
|
||||
text = zb.render_text(SPEC)
|
||||
assert "VO2 Max 5x3" in text
|
||||
assert "IntervalsT" in text
|
||||
assert "TSS" in text
|
||||
|
||||
|
||||
def _write_tmp(xml: str) -> str:
|
||||
fd = tempfile.NamedTemporaryFile("w", suffix=".zwo", delete=False, encoding="utf-8")
|
||||
fd.write(xml)
|
||||
fd.close()
|
||||
return fd.name
|
||||
|
||||
|
||||
def _run_all():
|
||||
tests = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
|
||||
failed = 0
|
||||
for t in tests:
|
||||
try:
|
||||
t()
|
||||
print(f" ✅ {t.__name__}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
failed += 1
|
||||
print(f" ❌ {t.__name__}: {exc}")
|
||||
print(f"\n{len(tests) - failed}/{len(tests)} tests passed")
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(_run_all())
|
||||
Reference in New Issue
Block a user