diff --git a/AGENTS.md b/AGENTS.md index 141861a..7257905 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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:`. +- **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. diff --git a/skills/hosting/SKILL.md b/skills/hosting/SKILL.md new file mode 100644 index 0000000..e83d4bd --- /dev/null +++ b/skills/hosting/SKILL.md @@ -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 "Rezept

Pistazien-Eis

" \ + --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:` in your response. diff --git a/skills/hosting/scripts/publish_file.py b/skills/hosting/scripts/publish_file.py new file mode 100755 index 0000000..3578cef --- /dev/null +++ b/skills/hosting/scripts/publish_file.py @@ -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() diff --git a/skills/qr-code/SKILL.md b/skills/qr-code/SKILL.md new file mode 100644 index 0000000..7c1d851 --- /dev/null +++ b/skills/qr-code/SKILL.md @@ -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 "" +``` + +### Script Arguments: +- ``: The URL or string to encode (mandatory). +- `--output `: Optional target file path (defaults to `/tmp/qr_.png`). +- `--logo `: 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:` 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 +``` diff --git a/skills/qr-code/assets/haumdaucher_logo.png b/skills/qr-code/assets/haumdaucher_logo.png new file mode 100644 index 0000000..39bf1f4 Binary files /dev/null and b/skills/qr-code/assets/haumdaucher_logo.png differ diff --git a/skills/qr-code/scripts/generate_qr.py b/skills/qr-code/scripts/generate_qr.py new file mode 100755 index 0000000..741f4f7 --- /dev/null +++ b/skills/qr-code/scripts/generate_qr.py @@ -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: 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_.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()