Skill: cpojer/hn-digest (version 1.0.1)

--- name: hn-digest description: "Fetch and send Hacker News front-page posts on demand. Use when the user asks for HN, says 'hn', 'pull HN', 'hn 10', or specifies a topic like 'hn health', 'hn hacking', or 'hn tech'. Sends N (default 5) posts as individual messages with Title + Link. Exclude crypto." --- # HN Digest ## Command format Interpret a user message that starts with `hn` as a request for a Hacker News front-page digest. Supported forms: - `hn` → default 5 posts - `hn <n>` → n posts - `hn <topic>` → filter/boost by topic - `hn <n> <topic>` → both - If the user asks for “more” after already seeing some (e.g. “show me top 10–15 since we already did top 10”), treat it as an offset request and use `--offset` (e.g. offset 10, count 10). Topics: - `tech` (default) - `health` - `hacking` - `life` / `lifehacks` ## Output requirements - Do **not** send any extra commentary/preamble/epilogue. - Send results as **individual messages**. - Each post message must be exactly: - first line: the post title - second line: `<age> · <commentCount> comments` (age like `45m ago`, `6h ago`, `3d ago`) - third line: the Hacker News comments link (`https://news.ycombinator.com/item?id=...`) - After the post messages, send **one final message** that is the generated image. - If the chat provider requires non-empty text for media, use a minimal caption `.`. - Hard exclude crypto. ## Procedure 1. Parse `n` and `topic` from the user message. 2. Fetch + rank items: - Run `node skills/hn-digest/scripts/hn.mjs --count <n> --offset <offset> --topic <topic> --format json`. - Default `offset` is 0 unless the user explicitly asks for “more/next” after a previous batch. 3. Send results as **N individual messages** in the required 3-line format. 4. Then generate a **delightful mood image** via Nano Banana, inspired by the posts you just sent: - Use `skills/hn-digest/scripts/mood_prompt.mjs` to build a prompt from the JSON items. - Add 3–4 subtle Easter eggs derived from the post themes (no text/logos; keep it fun). - Generate and attach the image by running: - `skills/hn-digest/scripts/generate_mood_nano_banana.sh ./tmp/hn-mood/hn-mood.png <topic> <n> <offset>` - Send the generated image as one additional message. If fetching/ranking fails or returns 0 items: - Use `https://news.ycombinator.com/` in the browser tool, pick N non-crypto items by judgment, and send them in the same 3-line format. - Still generate a mood image (general “HN tech deep dives” vibe) with a banana Easter egg.

path: scripts/generate_mood_nano_banana.sh
content: #!/bin/sh
set -eu

OUT_PATH="$1"
TOPIC="${2:-tech}"
COUNT="${3:-5}"
OFFSET="${4:-0}"

# Ensure we have a venv with dependencies (avoid touching system python).
VENV_DIR="./tmp/hn-venv"
PY="$VENV_DIR/bin/python"

if [ ! -x "$PY" ]; then
  python3 -m venv "$VENV_DIR"
  "$PY" -m pip install --quiet --disable-pip-version-check google-genai pillow
fi

JSON="$(node skills/hn-digest/scripts/hn.mjs --count "$COUNT" --offset "$OFFSET" --topic "$TOPIC" --format json)"
PROMPT="$(printf "%s" "$JSON" | node skills/hn-digest/scripts/mood_prompt.mjs --topic "$TOPIC")"

"$PY" skills/hn-digest/scripts/nano_banana_mood.py --out "$OUT_PATH" --resolution 1K --prompt "$PROMPT"


path: scripts/mood_image.py
content: #!/usr/bin/env python3
import argparse
import base64
import json
import os
import sys
import urllib.request
from pathlib import Path


def request_image(api_key: str, prompt: str, model: str, size: str, quality: str) -> dict:
    url = "https://api.openai.com/v1/images/generations"
    body = json.dumps(
        {
            "model": model,
            "prompt": prompt,
            "size": size,
            "quality": quality,
            "n": 1,
        }
    ).encode("utf-8")

    req = urllib.request.Request(
        url,
        method="POST",
        headers={
            "Authorization": f"Bearer {api_key}",
            "Content-Type": "application/json",
        },
        data=body,
    )

    try:
        with urllib.request.urlopen(req, timeout=300) as resp:
            return json.loads(resp.read().decode("utf-8"))
    except urllib.error.HTTPError as e:
        try:
            payload = e.read().decode("utf-8")
        except Exception:
            payload = ""
        raise RuntimeError(
            f"Images API failed ({e.code} {e.reason})" + (f": {payload}" if payload else "")
        ) from e


def write_png_from_response(out_path: Path, res: dict) -> None:
    data0 = (res.get("data") or [{}])[0] or {}

    b64 = data0.get("b64_json")
    if isinstance(b64, str) and b64:
        out_path.write_bytes(base64.b64decode(b64))
        return

    url = data0.get("url")
    if isinstance(url, str) and url:
        with urllib.request.urlopen(url, timeout=300) as resp:
            out_path.write_bytes(resp.read())
        return

    raise RuntimeError(f"Unexpected Images API response: {json.dumps(res)[:500]}")


def main() -> int:
    ap = argparse.ArgumentParser(description="Generate one mood image for hn-digest.")
    ap.add_argument("--prompt", required=True)
    ap.add_argument("--model", default="gpt-image-1")
    ap.add_argument("--size", default="1024x1024")
    ap.add_argument("--quality", default="high")
    ap.add_argument("--out", required=True)
    args = ap.parse_args()

    [REDACTED_SECRET]"OPENAI_API_KEY") or "").strip()
    if not [REDACTED_SECRET]"Missing OPENAI_API_KEY", file=sys.stderr)
        return 2

    out_path = Path(args.out).expanduser()
    out_path.parent.mkdir(parents=True, exist_ok=True)

    res = request_image(api_key, args.prompt, args.model, args.size, args.quality)
    write_png_from_response(out_path, res)

    print(out_path.as_posix())
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/nano_banana_mood.py
content: #!/usr/bin/env python3
import argparse
import json
import os
import sys
from pathlib import Path
from typing import Optional


def load_gemini_key() -> Optional[str]:
    """Load Gemini API key.

    Order:
    1) GEMINI_API_KEY env var
    2) ~/.openclaw/openclaw.json (OpenClaw config)
    """

    # Prefer env var if set.
    env_key = (os.environ.get("GEMINI_API_KEY") or "").strip()
    if env_key:
        return env_key

    cfg_paths = [
        Path.home() / ".openclaw" / "openclaw.json",
    ]

    for cfg_path in cfg_paths:
        try:
            raw = cfg_path.read_text(encoding="utf-8")
            cfg = json.loads(raw)
            api_key = (
                cfg.get("skills", {})
                .get("entries", {})
                .get("nano-banana-pro", {})
                .get("apiKey")
                or ""
            ).strip()
            if [REDACTED_SECRET] api_key
        except Exception:
            continue

    return None


def main() -> int:
    ap = argparse.ArgumentParser(description="Generate a mood image via Nano Banana Pro.")
    ap.add_argument("--prompt", required=True)
    ap.add_argument("--out", required=True)
    ap.add_argument("--resolution", choices=["1K", "2K", "4K"], default="1K")
    args = ap.parse_args()

    [REDACTED_SECRET])
    if not [REDACTED_SECRET]
            "Missing GEMINI_API_KEY (env) or nano-banana-pro apiKey in ~/.openclaw/openclaw.json",
            file=sys.stderr,
        )
        return 2

    out_path = Path(args.out).expanduser().resolve()
    out_path.parent.mkdir(parents=True, exist_ok=True)

    from google import genai
    from google.genai import types
    from PIL import Image as PILImage
    from io import BytesIO

    client = genai.Client([REDACTED_SECRET])

    response = client.models.generate_content(
        model="gemini-3-pro-image-preview",
        contents=args.prompt,
        config=types.GenerateContentConfig(
            response_modalities=["TEXT", "IMAGE"],
            image_config=types.ImageConfig(image_size=args.resolution),
        ),
    )

    image_saved = False
    for part in getattr(response, "parts", []) or []:
        inline = getattr(part, "inline_data", None)
        if inline is None:
            continue
        data = getattr(inline, "data", None)
        if not data:
            continue

        if isinstance(data, str):
            import base64

            data = base64.b64decode(data)

        image = PILImage.open(BytesIO(data))
        if image.mode == "RGBA":
            rgb = PILImage.new("RGB", image.size, (255, 255, 255))
            rgb.paste(image, mask=image.split()[3])
            rgb.save(str(out_path), "PNG")
        else:
            image.convert("RGB").save(str(out_path), "PNG")
        image_saved = True
        break

    if not image_saved:
        print("No image returned by model", file=sys.stderr)
        return 1

    print(out_path.as_posix())
    # Clawdbot can auto-attach files when it sees MEDIA tokens.
    print(f"MEDIA: {out_path.as_posix()}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/mood_prompt.mjs
content: #!/usr/bin/env node

function readStdin() {
  return new Promise((resolve, reject) => {
    let data = "";
    process.stdin.setEncoding("utf8");
    process.stdin.on("data", (chunk) => (data += chunk));
    process.stdin.on("end", () => resolve(data));
    process.stdin.on("error", reject);
  });
}

function pickEasterEggs(items) {
  const eggs = [];

  // Always: banana sticker (Nano Banana) + subtle lion cameo.
  eggs.push("a tiny banana sticker hidden on the laptop");
  eggs.push("a small friendly lion figurine on a shelf");

  const titles = items.map((i) => String(i.title || "")).join(" \n ");

  if (/performance|gap|\bfast\b|\bbenchmark\b/i.test(titles)) {
    eggs.push("a comically oversized speedometer with the needle pegged, surrounded by hundreds of tiny sparkles (hinting at a huge speedup)"
    );
  }

  if (/github|object|id\b|identifier/i.test(titles)) {
    eggs.push(
      "a blueprint page filled with paired geometric tokens (two different shapes per item), suggesting 'two IDs' without using any logos or text"
    );
  }

  if (/management|team|anti-pattern/i.test(titles)) {
    eggs.push(
      "a whiteboard with simple stick-figure team diagrams and a few playful 'anti-pattern' doodles (no words)"
    );
  }

  // Keep it to ~3-4 eggs.
  return eggs.slice(0, 4);
}

function main() {
  const args = process.argv.slice(2);
  const topicIndex = args.indexOf("--topic");
  const topic = topicIndex !== -1 ? (args[topicIndex + 1] || "tech") : "tech";

  readStdin()
    .then((raw) => {
      const parsed = JSON.parse(raw);
      const items = Array.isArray(parsed?.items) ? parsed.items : [];
      const eggs = pickEasterEggs(items);

      const postObjects = items.slice(0, 5).map((item) => {
        const title = String(item.title || "");
        // Convert title to a visual object hint, but keep it general.
        if (/performance|gap|\bfix\b/i.test(title)) return "a performance graph and a pile of neatly cut paper strips (hinting at a small fix)";
        if (/github|object|id\b/i.test(title)) return "abstract geometric ID tokens and a tidy diagram";
        if (/management|team|anti-pattern/i.test(title)) return "a simple team flow diagram on a whiteboard";
        return "a curious gadget or schematic related to modern tech";
      });

      const prompt = [
        "Cartoony editorial illustration, bright clean colors, playful but thoughtful mood.",
        "Scene: a cozy developer workshop desk with warm lighting, inviting and delightful.",
        "Main vibe: practical engineering deep dives, systems/tooling internals, and curious tinkering.",
        `Theme focus: Hacker News front page (${topic}).`,
        "Include visual references inspired by these post themes (no text, no logos):",
        ...postObjects.map((o) => `- ${o}`),
        "Add 3-4 subtle Easter eggs to reward close inspection (no text):",
        ...eggs.map((e) => `- ${e}`),
        "Constraints: no text, no brand logos, no crypto imagery, not dark/menacing.",
        "Style: 2D cartoon, crisp linework, soft shading, high detail.",
      ].join("\n");

      process.stdout.write(prompt);
      process.stdout.write("\n");
    })
    .catch((err) => {
      process.stderr.write(String(err?.stack ?? err));
      process.stderr.write("\n");
      process.exitCode = 1;
    });
}

main();


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: suspicious | SkillSpector: suspicious (CRITICAL)