feat: add hosting and qr-code skills

This commit is contained in:
Moritz Graf
2026-09-16 08:59:54 +02:00
parent 5376ddbc20
commit bb31002e19
6 changed files with 403 additions and 0 deletions
+72
View File
@@ -0,0 +1,72 @@
---
name: hosting
description: Upload and host static HTML websites, PDFs, images, recipes, and dossiers on public S3 storage (https://hosting.haumdaucher.de).
---
# Public S3 File & Website Hosting 🌐
Publishes files and standalone static web pages to **`https://hosting.haumdaucher.de`**.
## When to Use
- **Research Dossiers & Websites**: After compiling an in-depth research report or summary, build an HTML page (styled with clean CSS) and upload it so the user can view it in their browser or share it with friends.
- **Recipes & Cooking Plans**: For Koch subagent (`agents/koch.md`) when creating shareable recipe cards or dinner menus.
- **Documents & PDFs**: Uploading PDFs, cheat sheets, or notes.
- **Images**: Sharing generated images, diagrams, or charts via a clean link.
- **Link Collections**: Hosting curated bookmarks or event overviews.
## URL Structure & Conventions
Artifacts are automatically named with the required pattern:
```
https://hosting.haumdaucher.de/{category}/{YYYYMMDD}-{slug}.{ext}
```
- **Categories**: `research`, `recipes`, `images`, `docs`, `shared`.
- **Date**: Auto-generated in `YYYYMMDD` format (e.g. `20260916`).
- **Slug**: Short, lowercase, alphanumeric hyphenated identifier (e.g. `haumdaucher-agent-framework`).
## CLI Execution
Run the publisher via `uv run`:
### 1. Uploading a Local File
```bash
uv run /home/node/mop-skills/mop-skills/skills/hosting/scripts/publish_file.py \
--file /tmp/my-report.html \
--category research \
--slug ai-agent-framework
```
### 2. Publishing Inline HTML Content Directly
```bash
uv run /home/node/mop-skills/mop-skills/skills/hosting/scripts/publish_file.py \
--content "<!DOCTYPE html><html><head><meta charset='utf-8'><title>Rezept</title></head><body><h1>Pistazien-Eis</h1></body></html>" \
--category recipes \
--slug pistazien-eis \
--ext html
```
### 3. Deleting an Artifact
```bash
uv run /home/node/mop-skills/mop-skills/skills/hosting/scripts/publish_file.py \
--delete "research/20260916-ai-agent-framework.html"
```
## Mandatory Workflow: QR Code Offer
Whenever you host an artifact and present the public link to the user:
1. Present the link clearly in your message:
```markdown
Hier ist deine veröffentlichte Website / dein Dokument:
🔗 https://hosting.haumdaucher.de/research/20260916-ai-agent-framework.html
```
2. **Immediately follow up with the `ask_user` tool** to ask whether a QR code should be generated:
```json
{
"question": "Möchtest du für diesen Link einen QR-Code generieren?",
"options": ["Ja, QR-Code erstellen", "Nein, danke"]
}
```
3. If the user selects **"Ja, QR-Code erstellen"**:
Run the `qr-code` skill:
```bash
uv run /home/node/mop-skills/mop-skills/skills/qr-code/scripts/generate_qr.py "https://hosting.haumdaucher.de/research/20260916-ai-agent-framework.html"
```
And deliver the image using `MEDIA:<path>` in your response.
+158
View File
@@ -0,0 +1,158 @@
#!/usr/bin/env python3
# /// script
# dependencies = [
# "boto3>=1.34.0",
# ]
# ///
"""
Publish files and static HTML documents to public S3 hosting at hosting.haumdaucher.de.
Organizes artifacts under /{category}/{YYYYMMDD}-{slug}.{ext}.
"""
import argparse
import datetime
import mimetypes
import os
import re
import sys
from pathlib import Path
import boto3
from botocore.client import Config
# Configuration defaults
DEFAULT_ENDPOINT = os.environ.get("MINIO_ENDPOINT", "http://minio.minio.svc.cluster.local:9000")
DEFAULT_ACCESS_KEY = os.environ.get("MINIO_ACCESS_KEY", "openclaw")
DEFAULT_SECRET_KEY = os.environ.get("MINIO_SECRET_KEY", "5uu23t69xC2Oigf8Bky0KTlI")
DEFAULT_BUCKET = os.environ.get("MINIO_BUCKET", "hosting")
DEFAULT_BASE_URL = os.environ.get("HOSTING_BASE_URL", "https://hosting.haumdaucher.de").rstrip("/")
MIME_OVERRIDES = {
".html": "text/html; charset=utf-8",
".htm": "text/html; charset=utf-8",
".md": "text/markdown; charset=utf-8",
".json": "application/json; charset=utf-8",
".pdf": "application/pdf",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".svg": "image/svg+xml",
".txt": "text/plain; charset=utf-8",
}
def get_s3_client():
if not DEFAULT_SECRET_KEY:
raise ValueError("MINIO_SECRET_KEY environment variable is not configured.")
return boto3.client(
"s3",
endpoint_url=DEFAULT_ENDPOINT,
aws_access_key_id=DEFAULT_ACCESS_KEY,
aws_secret_access_key=DEFAULT_SECRET_KEY,
config=Config(signature_version="s3v4"),
region_name="us-east-1",
)
def slugify(text: str) -> str:
"""Converts raw string to URL-safe slug."""
text = text.strip().lower()
text = re.sub(r"[^\w\s-]", "", text)
text = re.sub(r"[\s_-]+", "-", text)
return text.strip("-") or "document"
def detect_mime(ext: str) -> str:
ext_lower = ext.lower()
if ext_lower in MIME_OVERRIDES:
return MIME_OVERRIDES[ext_lower]
guessed, _ = mimetypes.guess_type(f"file{ext_lower}")
return guessed or "application/octet-stream"
def upload_artifact(file_path: str = None, content: str = None, category: str = "research", slug: str = None, ext: str = None) -> dict:
s3 = get_s3_client()
now_str = datetime.datetime.now().strftime("%Y%m%d")
# Determine file data and extension
if file_path:
p = Path(file_path)
if not p.exists():
raise FileNotFoundError(f"File not found: {file_path}")
with open(p, "rb") as f:
data = f.read()
chosen_ext = (ext or p.suffix).lstrip(".")
chosen_slug = slug or slugify(p.stem)
elif content is not None:
data = content.encode("utf-8")
chosen_ext = (ext or "html").lstrip(".")
chosen_slug = slug or f"doc-{datetime.datetime.now().strftime('%H%M%S')}"
else:
raise ValueError("Either --file or --content must be specified.")
chosen_slug = slugify(chosen_slug)
category_slug = slugify(category)
# Construct standard object key: {category}/{YYYYMMDD}-{slug}.{ext}
filename = f"{now_str}-{chosen_slug}.{chosen_ext}"
object_key = f"{category_slug}/{filename}"
content_type = detect_mime(f".{chosen_ext}")
s3.put_object(
Bucket=DEFAULT_BUCKET,
Key=object_key,
Body=data,
ContentType=content_type,
)
public_url = f"{DEFAULT_BASE_URL}/{object_key}"
return {
"url": public_url,
"key": object_key,
"category": category_slug,
"filename": filename,
"content_type": content_type,
"bytes": len(data),
}
def delete_artifact(object_key: str):
s3 = get_s3_client()
s3.delete_object(Bucket=DEFAULT_BUCKET, Key=object_key)
print(f"Deleted: {object_key}")
def main():
parser = argparse.ArgumentParser(description="Publish files to hosting.haumdaucher.de.")
parser.add_argument("--file", "-f", help="Local file path to upload")
parser.add_argument("--content", "-c", help="Inline text or HTML string to publish")
parser.add_argument("--category", default="research", help="Category folder (e.g. research, recipes, images, docs)")
parser.add_argument("--slug", "-s", help="Readable slug identifier for the document")
parser.add_argument("--ext", help="File extension override (e.g. html, pdf, png)")
parser.add_argument("--delete", help="Delete an object key from the hosting bucket")
parser.add_argument("--url-only", action="store_true", help="Print only the public URL to stdout")
args = parser.parse_args()
if args.delete:
delete_artifact(args.delete)
return
if not args.file and args.content is None:
parser.error("You must provide either --file or --content.")
result = upload_artifact(
file_path=args.file,
content=args.content,
category=args.category,
slug=args.slug,
ext=args.ext,
)
if args.url_only:
print(result["url"])
else:
print(f"URL: {result['url']}")
print(f"KEY: {result['key']}")
print(f"TYPE: {result['content_type']}")
print(f"SIZE: {result['bytes']} bytes")
if __name__ == "__main__":
main()