feat: add hosting and qr-code skills
This commit is contained in:
@@ -67,6 +67,17 @@ Two-way Google Calendar integration for Family Appointments and Regensburg Local
|
||||
- **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`.
|
||||
|
||||
### [skill-creator](skills/skill-creator/SKILL.md)
|
||||
Guidelines and scripts to help developers build high-quality, token-efficient Claude skills.
|
||||
|
||||
|
||||
@@ -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,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()
|
||||
Reference in New Issue
Block a user