Skill: hg-hg/arknights-operator-gacha (version 2.0.0)

--- name: arknights-operator-gacha description: Generate an Arknights operator agent based on gacha probabilities. Use when user wants to create a random Arknights character agent with authentic lore and personality. --- # Arknights Operator Gacha Generate a random Arknights operator agent with authentic lore-based personality. ## Architecture This skill uses a **worker script + LLM generation** architecture: 1. **Worker script** (`gacha_worker.py`): Executes all deterministic tasks (roll, fetch, create, download) 2. **LLM (this agent)**: Generates creative content (SOUL.md) and spawns operator ## Language Detection **CRITICAL:** Detect user language from their gacha command: - If command is in **Chinese** (e.g., "抽卡", "召唤干员") → **cn** / **zh** - If command is in **English** (e.g., "gacha", "pull") → **en** Store detected language and use it for ALL subsequent steps (SOUL.md writing, spawn task, etc.). ## Workflow ### Step 1: Execute Worker Script Run the deterministic worker: ```python result = exec( "python3 ~/.openclaw/workspace/skills/arknights-operator-gacha/scripts/gacha_worker.py", timeout=120 ) ``` **Worker performs:** 1. Roll star rating (1-100) 2. Fetch operator list from `https://arknights.fandom.com/wiki/Operator/{N}-star` - Parses the `mrfz-wtable` table to extract operator info - Returns a dict: `{operator_name: {"avatar_url": "...", "detail_url": "..."}}` 3. Randomly select operator (with avatar and detail URLs) 4. Fetch Chinese name from Fandom page (data-source="cnname") 5. Check for duplicates (auto re-roll if exists) 6. Create agent via `openclaw agents add` 7. Create template IDENTITY.md (bilingual) 8. **Download avatar** (using URL from step 2, with domain whitelist and validation) 9. Git commit initial files 10. **Output JSON to stdout** **Worker output format:** ```json { "success": true, "stars": 6, "operator": { "en_name": "Lin", "cn_name": "林", "avatar_url": "https://static.wikia.nocookie.net/.../Lin_icon.png", "en_detail_url": "https://arknights.fandom.com/wiki/Lin", "cn_detail_url": "https://prts.wiki/w/%E6%9E%97" }, "agent_name": "lin", "workspace": "~/.openclaw/workspace-lin", "duplicate": false, "dialogue_url": "https://arknights.fandom.com/wiki/Lin/Dialogue" } ``` **Note:** The worker provides both English and Chinese URLs: - `en_detail_url`: Fandom wiki page (English lore) - `cn_detail_url`: PRTS wiki page (Chinese lore) - `dialogue_url`: Fandom Dialogue page (voice lines, always English) - `avatar_url`: Operator icon from Fandom ### Step 2: Handle Result **If `duplicate: true`:** - Inform user: "检测到重复干员，正在重新抽取..." / "Duplicate operator detected, re-rolling..." - Return to Step 1 (worker will re-roll) **If `success: false`:** - Show error message - Stop workflow **If `success: true`:** - Announce: "恭喜你抽到了 [cn_name] ([stars]★)!" / "Congratulations! You've pulled [en_name] ([stars]★)!" - Continue to Step 3 ### Step 3: Generate SOUL.md **Fetch lore from URLs provided by worker:** ```python # Fetch English lore from Fandom (multiple subpages) en_file = web_fetch(f"{result['operator']['en_detail_url']}/File") # Basic profile, stats, files en_story = web_fetch(f"{result['operator']['en_detail_url']}/Story") # Story appearances, plot involvement en_trivia = web_fetch(f"{result['operator']['en_detail_url']}/Trivia") # Trivia, relationships, misc info # Fetch Chinese lore from PRTS zh_lore = web_fetch(result["operator"]["cn_detail_url"]) # Fetch voice lines from Dialogue page dialogue = web_fetch(result["operator"]["dialogue_url"]) ``` **Parse lore sections from both sources:** - **File/档案**: Basic background, origin, personality - **Story**: Detailed story appearances across main chapters and side stories; shows character's actions, decisions, and development in actual plot contexts - **Dialogue**: Voice lines that reveal character personality - **Trivia/模组**: Additional character details, relationships, and module stories **Generate comprehensive SOUL.md:** **CRITICAL - Write SOUL.md in detected language** Structure: 1. **Core Identity** - Background, motivation, personality (blend EN+CN sources) 2. **Voice and Mannerisms** - Speech patterns, catchphrases (from Dialogue) 3. **Relationships** - Connections to other characters 4. **Themes** - Internal conflicts, philosophy 5. **How to Embody** - Acting guidance 6. **Reference: Original Voice Lines** - Key quotes (EN with CN) Write to: `[workspace]/SOUL.md` ### Step 4: Update IDENTITY.md Fill in the `[TO_BE_FILLED_BY_LLM]` fields with actual Class and Faction from fetched lore. ### Step 5: Final Git Commit ```bash cd ~/.openclaw/workspace-{agent_name} git add -A git commit -m "Add SOUL.md: {en_name} ({cn_name}) ({stars}★)" ``` ### Step 6: Roleplay Scene - Operator Arrival Spawn the operator in a roleplay scenario consistent with Arknights game lore. In this universe, operators join Rhodes Island through standard recruitment channels and are unaware of the meta "gacha" system. **For Chinese users:** ```python sessions_spawn( task="你现在是罗德岛的一名干员，刚刚完成入职手续，前来向博士报到。用你最自然的口吻打招呼，展示你的性格特点。参考你的语音记录中的'干员报到'或'交谈'部分的语气。\n\n⚠️ 重要：直接输出角色台词即可，不要包含任何元叙述、解释性文字或场景描述。只说你作为干员应该说的话。", agentId="{operator-name}", mode="run", timeoutSeconds=60 ) ``` **For English users:** ```python sessions_spawn( task="You are an operator of Rhodes Island who has just completed onboarding, now reporting to the Doctor. Introduce yourself naturally, showcasing your personality. Reference your 'Introduction' or 'Talk' voice lines for tone.\n\n⚠️ IMPORTANT: Output ONLY the character's dialogue. Do NOT include meta-commentary, explanations, or scene descriptions. Just speak as the operator would.", agentId="{operator-name}", mode="run", timeoutSeconds=60 ) ``` ### Step 7: Present Summary Report to user: - Operator name (both EN and CN) - Rarity (Stars) - Key personality traits - Workspace path - Available via `agentId="{agent_name}"` ## Worker Script Security The worker script (`gacha_worker.py`) implements: - **Input validation**: Agent names sanitized (alphanumeric + hyphens only) - **Path traversal prevention**: Rejects `..`, `/`, `\` in names - **URL whitelist**: Only `static.wikia.nocookie.net` and `media.prts.wiki` - **HTTPS enforced**: All downloads use HTTPS - **Content validation**: Only image/png, jpeg, webp accepted - **Size limits**: Max 5MB for avatars - **Safe subprocess**: All commands use argument lists, `shell=False` ## Scripts | Script | Purpose | |--------|---------| | `gacha_worker.py` | Deterministic tasks (roll, fetch, create, download, commit) | ## Data Flow ``` User Request ↓ Exec Worker Script ↓ Worker Output JSON (bilingual URLs) ↓ LLM Fetches EN+CN Lore → Generates SOUL.md ↓ Spawn Operator (报到) ↓ User sees operator greeting ``` ## Notes - **Worker does**: All deterministic operations (HTTP, filesystem, subprocess), provides bilingual URLs - **LLM does**: Creative generation (SOUL.md writing), fetches lore from provided URLs, interpretation, spawning - **Progress visibility**: Worker prints to stderr; user sees real-time updates - **Security**: All external inputs validated in worker before use - **Bilingual**: Both English and Chinese sources are always fetched; LLM can blend them creatively

path: scripts/README.md
content: # Arknights Operator Gacha Scripts

Worker script for the Arknights Operator Gacha skill.

## Architecture

This skill uses a **worker script + LLM generation** architecture:

| Component | Responsibility |
|-----------|----------------|
| **Worker script** (`gacha_worker.py`) | Deterministic tasks: roll, fetch data, create agent, download avatar, git operations |
| **LLM (agent)** | Creative tasks: generate SOUL.md, interpret lore, spawn operator for roleplay |

## Worker Script

### gacha_worker.py

Executes all deterministic tasks and outputs JSON for LLM consumption.

**Usage:**
```bash
# Random operator (auto-roll stars)
python3 gacha_worker.py

# Force specific operator (requires --stars)
python3 gacha_worker.py --operator "Lin" --stars 6

# Dry run (no agent creation, only output JSON)
python3 gacha_worker.py --operator "Provence" --stars 5 --dry-run
```

**Output:** JSON to stdout with bilingual operator data

```json
{
  "success": true,
  "stars": 6,
  "operator": {
    "en_name": "Lin",
    "cn_name": "林",
    "avatar_url": "https://static.wikia.nocookie.net/.../Lin_icon.png",
    "en_detail_url": "https://arknights.fandom.com/wiki/Lin",
    "cn_detail_url": "https://prts.wiki/w/%E6%9E%97"
  },
  "agent_name": "lin",
  "workspace": "~/.openclaw/workspace-lin",
  "duplicate": false,
  "dialogue_url": "https://arknights.fandom.com/wiki/Lin/Dialogue"
}
```

**Output fields:**
- `operator.en_name`: Operator name in English
- `operator.cn_name`: Operator name in Chinese (from Fandom data-source="cnname")
- `operator.avatar_url`: Icon URL from Fandom
- `operator.en_detail_url`: Fandom wiki page for English lore
- `operator.cn_detail_url`: PRTS wiki page for Chinese lore
- `dialogue_url`: Fandom Dialogue page (voice lines, always in English)
- `agent_name`: Sanitized agent ID (filesystem-safe, lowercase)
- `workspace`: Path to agent workspace
- `duplicate`: True if agent already exists (needs re-roll)

**Security features:**
- Input validation (agent names: alphanumeric + hyphens only)
- Path traversal prevention
- URL domain whitelist
- HTTPS enforcement
- Content-type validation
- File size limits
- Safe subprocess (argument lists, not shell)

## Data Flow

```
User: "抽卡！" / "Gacha!"
  ↓
Agent execs: gacha_worker.py [--operator X --stars N] [--dry-run]
  ↓
Worker: Roll → Fetch list → Select → Fetch CN name → Create agent → Download → Git commit
  ↓
Worker outputs JSON (bilingual URLs)
  ↓
Agent fetches lore from en_detail_url + cn_detail_url + dialogue_url
  ↓
Agent generates SOUL.md (blending EN+CN sources)
  ↓
Agent spawns operator for 报道
  ↓
User sees operator greeting
```

## Requirements

- Python 3.7+
- requests library (`pip install requests`)
- openclaw CLI configured
- git

## Why This Architecture?

1. **Efficiency**: Worker handles all deterministic operations without LLM tokens
2. **Security**: All external inputs validated in isolated script
3. **Reliability**: Worker can be tested independently
4. **Token efficiency**: LLM only used for creative generation (SOUL.md)
5. **Progress visibility**: Worker prints progress to stderr in real-time
6. **Bilingual support**: Both English and Chinese sources always provided


path: scripts/gacha_worker.py
content: #!/usr/bin/env python3
"""
Arknights Operator Gacha Worker (Simplified)
Outputs JSON with operator info and URLs for manual lore fetching.
"""

import argparse
import json
import random
import re
import subprocess
import sys
import urllib.parse
from pathlib import Path

import requests

# Configuration
WORKSPACE_BASE = Path.home() / ".openclaw"
FANDOM_LIST_URL = "https://arknights.fandom.com/wiki/Operator/{star}-star"


def sanitize_agent_name(name: str) -> str:
    """Validate agent name - alphanumeric and hyphens only"""
    if not name or len(name) > 50:
        raise ValueError(f"Invalid name length: {name}")
    normalized = name.lower().replace(" ", "-").replace("_", "-")
    sanitized = re.sub(r'[^a-z0-9\-]', '', normalized)
    if sanitized != normalized or ".." in sanitized:
        raise ValueError(f"Invalid characters in name: {name}")
    return sanitized


def roll_star_rating() -> int:
    """Roll 1-100 and return star rating"""
    roll = random.randint(1, 100)
    if roll <= 2: return 6
    if roll <= 10: return 5
    if roll <= 60: return 4
    return 3


def fetch_operator_list(stars: int) -> dict[str, dict]:
    """Fetch operator list from Fandom and return dict with avatar and detail URLs"""
    url = FANDOM_LIST_URL.format(star=stars)
    try:
        resp = requests.get(url, timeout=30, headers={"User-Agent": "Mozilla/5.0"})
        resp.raise_for_status()
        html = resp.text
        table_match = re.search(
            r'<table[^>]*class="[^"]*mrfz-wtable[^"]*"[^>]*>(.*?)</table>',
            html, re.DOTALL
        )
        if not table_match:
            print("[ERROR] Could not find operator table (mrfz-wtable)", file=sys.stderr)
            return {}
        table_html = table_match.group(1)
        operators = {}
        rows = re.findall(r'<tr[^>]*>(.*?)</tr>', table_html, re.DOTALL)
        for row in rows:
            if '<th' in row:
                continue
            cells = re.findall(r'<td[^>]*>(.*?)</td>', row, re.DOTALL)
            if len(cells) < 2:
                continue
            avatar_cell = cells[0]
            avatar_match = re.search(
                r'data-src="(https://static\.wikia\.nocookie\.net/[^"]+)"',
                avatar_cell
            ) or re.search(
                r'src="(https://static\.wikia\.nocookie\.net/[^"]+)"',
                avatar_cell
            )
            avatar_url = avatar_match.group(1) if avatar_match else None
            name_cell = cells[1]
            name_match = re.search(
                r'<a[^>]*href="(/wiki/[^"]+)"[^>]*>([^<]+)</a>',
                name_cell
            )
            if name_match:
                detail_path = name_match.group(1)
                operator_name = name_match.group(2).strip()
                skip_names = ['operator', 'promotion', 'chip', 'skill', 'talent',
                             'class', 'lmd', 'recruitment', 'data supplement']
                if (operator_name and
                    len(operator_name) > 1 and
                    len(operator_name) < 50 and
                    not any(x in operator_name.lower() for x in skip_names)):
                    operators[operator_name] = {
                        "avatar_url": avatar_url,
                        "detail_path": detail_path,
                        "detail_url": f"https://arknights.fandom.com{detail_path}"
                    }
        return operators
    except Exception as e:
        print(f"[ERROR] Failed to fetch operator list: {e}", file=sys.stderr)
        return {}


def select_random_operator(operators: dict[str, dict]) -> tuple[str, dict]:
    if not operators:
        raise RuntimeError("No operators available to select")
    operator_name = random.choice(list(operators.keys()))
    return operator_name, operators[operator_name]


def check_agent_exists(agent_name: str) -> bool:
    """Check if agent workspace or config exists"""
    try:
        sanitized = sanitize_agent_name(agent_name)
        return (
            (WORKSPACE_BASE / "agents" / sanitized).exists() or
            (WORKSPACE_BASE / f"workspace-{sanitized}").exists()
        )
    except ValueError:
        return False


def create_agent(agent_name: str) -> tuple[bool, str]:
    """Create agent via openclaw CLI"""
    try:
        sanitized = sanitize_agent_name(agent_name)
        workspace = WORKSPACE_BASE / f"workspace-{sanitized}"
        result = subprocess.run(
            ["openclaw", "agents", "add", sanitized,
             "--workspace", str(workspace), "--non-interactive"],
            capture_output=True, text=True, timeout=30, shell=False
        )
        return result.returncode == 0, result.stderr if result.returncode != 0 else "OK"
    except Exception as e:
        return False, str(e)


def download_avatar(agent_name: str, avatar_url: str) -> tuple[bool, str]:
    """Download avatar with validation"""
    try:
        sanitized = sanitize_agent_name(agent_name)
        parsed = urllib.parse.urlparse(avatar_url)
        allowed_domains = ["static.wikia.nocookie.net", "media.prts.wiki"]
        if parsed.netloc not in allowed_domains or parsed.scheme != "https":
            return False, "Invalid avatar domain"
        workspace = WORKSPACE_BASE / f"workspace-{sanitized}"
        avatars_dir = workspace / "avatars"
        avatars_dir.mkdir(parents=True, exist_ok=True)
        avatar_path = avatars_dir / f"{sanitized}.png"
        resp = requests.get(avatar_url, timeout=30, stream=True)
        resp.raise_for_status()
        content_type = resp.headers.get('content-type', '')
        if not any(ct in content_type for ct in ['image/png', 'image/jpeg', 'image/webp']):
            return False, f"Invalid content type: {content_type}"
        downloaded = 0
        MAX_SIZE = 5 * 1024 * 1024
        with open(avatar_path, 'wb') as f:
            for chunk in resp.iter_content(chunk_size=8192):
                downloaded += len(chunk)
                if downloaded > MAX_SIZE:
                    avatar_path.unlink()
                    return False, "File too large"
                f.write(chunk)
        return True, str(avatar_path)
    except Exception as e:
        return False, str(e)


def git_commit(agent_name: str, message: str) -> bool:
    """Git add and commit"""
    try:
        sanitized = sanitize_agent_name(agent_name)
        workspace = WORKSPACE_BASE / f"workspace-{sanitized}"
        subprocess.run(["git", "add", "-A"], cwd=workspace, check=True, timeout=10)
        result = subprocess.run(
            ["git", "commit", "-m", message],
            cwd=workspace, capture_output=True, timeout=10
        )
        return result.returncode == 0 or "nothing to commit" in result.stderr.decode().lower()
    except Exception:
        return False


def fetch_chinese_name(english_name: str) -> str:
    """从 Fandom 页面提取中文名 (data-source="cnname")"""
    url = f"https://arknights.fandom.com/wiki/{urllib.parse.quote(english_name)}"
    try:
        resp = requests.get(url, timeout=10)
        resp.raise_for_status()
        html = resp.text
        m = re.search(
            r'<div[^>]*data-source="cnname"[^>]*>.*?<div[^>]*class="[^"]*pi-data-value[^"]*"[^>]*>([^<]+)</div>',
            html,
            re.DOTALL
        )
        if m:
            return m.group(1).strip()
    except Exception as e:
        print(f"[ERROR] fetch_chinese_name failed for {english_name}: {e}", file=sys.stderr)
    return None


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--operator", help="Force specific operator (for testing)")
    parser.add_argument("--stars", type=int, choices=[3,4,5,6], help="Force star rating (required when --operator is specified)")
    parser.add_argument("--dry-run", action="store_true", help="Dry run - skip agent creation, identity file, and git commit")
    args = parser.parse_args()
    
    # Validation: if operator is specified, stars must be provided
    if args.operator and args.stars is None:
        print("ERROR: When --operator is specified, --stars is required", file=sys.stderr)
        sys.exit(2)

    result = {
        "success": False,
        "operator": None,
        "error": None
    }

    try:
        # Step 1: Roll
        stars = args.stars or roll_star_rating()
        result["stars"] = stars
        print(f"[ROLL] Star rating: {stars}★", file=sys.stderr)

        # Step 2: Select operator
        operator_info = None
        cn_name = None
        if args.operator:
            op_name = args.operator
            print(f"[SELECT] Forced operator: {op_name}", file=sys.stderr)
            # Even when forcing operator, we need to fetch the list to get detail_url and avatar_url
            print(f"[FETCH] Getting {stars}★ operator list from fandom...", file=sys.stderr)
            operators = fetch_operator_list(stars)
            if operators and op_name in operators:
                operator_info = operators[op_name]
                print(f"[FETCH] Found operator info in {stars}★ list", file=sys.stderr)
            else:
                print(f"[WARN] Operator {op_name} not found in {stars}★ list, using basic URLs", file=sys.stderr)
        else:
            print(f"[FETCH] Getting {stars}★ operator list from fandom...", file=sys.stderr)
            operators = fetch_operator_list(stars)
            if not operators:
                raise RuntimeError(f"Failed to fetch {stars}★ operator list from fandom")
            print(f"[FETCH] Found {len(operators)} operators", file=sys.stderr)
            op_name, operator_info = select_random_operator(operators)
            print(f"[SELECT] Randomly selected: {op_name}", file=sys.stderr)

        # Build operator info - always fetch both English and Chinese
        result["operator"] = {
            "en_name": op_name
        }
        if operator_info:
            result["operator"]["avatar_url"] = operator_info.get("avatar_url")
            result["operator"]["en_detail_url"] = operator_info.get("detail_url")

        # Always fetch Chinese name
        print(f"[LANG] Resolving Chinese name via Fandom...", file=sys.stderr)
        cn_name = fetch_chinese_name(op_name)
        if cn_name:
            result["operator"]["cn_name"] = cn_name
            print(f"[LANG] Chinese name: {cn_name}", file=sys.stderr)
            result["operator"]["cn_detail_url"] = f"https://prts.wiki/w/{urllib.parse.quote(cn_name)}"
        else:
            print(f"[WARN] Could not resolve Chinese name for {op_name}", file=sys.stderr)

        # Step 3: Check duplicates and sanitize
        agent_name = sanitize_agent_name(op_name)
        if check_agent_exists(agent_name):
            result["duplicate"] = True
            print(f"[DUPLICATE] {agent_name} exists, needs re-roll", file=sys.stderr)
            print(json.dumps(result))
            sys.exit(0)

        result["agent_name"] = agent_name
        result["workspace"] = str(WORKSPACE_BASE / f"workspace-{agent_name}")
        result["dialogue_url"] = f"https://arknights.fandom.com/wiki/{urllib.parse.quote(op_name)}/Dialogue"

        # Step 4: Create agent (skip in dry-run)
        if args.dry_run:
            print(f"[DRY-RUN] Skipping agent creation", file=sys.stderr)
        else:
            print(f"[CREATE] Agent: {agent_name}", file=sys.stderr)
            ok, msg = create_agent(agent_name)
            if not ok:
                if "already exists" in msg.lower():
                    result["duplicate"] = True
                    print(json.dumps(result))
                    sys.exit(0)
                raise RuntimeError(f"Create agent failed: {msg}")

        # Step 5: Create IDENTITY.md (skip in dry-run)
        if args.dry_run:
            print(f"[DRY-RUN] Skipping IDENTITY.md creation", file=sys.stderr)
        else:
            identity_path = WORKSPACE_BASE / f"workspace-{agent_name}" / "IDENTITY.md"
            cn_display = cn_name if cn_name else "[TO_BE_FILLED_BY_LLM]"
            identity_content = f"""# IDENTITY.md - Who Am I?

- **Name (EN):** {op_name}
- **Name (CN):** {cn_display}
- **Class:** [TO_BE_FILLED_BY_LLM]
- **Faction:** [TO_BE_FILLED_BY_LLM]
- **Avatar:** avatars/{agent_name}.png
"""
            identity_path.write_text(identity_content, encoding='utf-8')
            print(f"[IDENTITY] Created template", file=sys.stderr)

        # Step 6: Download avatar (always execute, but in dry-run just record the URL)
        avatar_url = result["operator"].get("avatar_url")
        if avatar_url:
            if args.dry_run:
                print(f"[DRY-RUN] Avatar URL: {avatar_url}", file=sys.stderr)
                result["operator"]["avatar_url"] = avatar_url
            else:
                print(f"[AVATAR] Downloading from {avatar_url}...", file=sys.stderr)
                ok, msg = download_avatar(agent_name, avatar_url)
                if ok:
                    print(f"[AVATAR] Downloaded: {msg}", file=sys.stderr)
                else:
                    print(f"[AVATAR] Failed: {msg}", file=sys.stderr)
        else:
            print(f"[AVATAR] No avatar URL available", file=sys.stderr)

        # Step 7: Note lore will be fetched separately by LLM using provided URLs
        # No lore fetching here

        # Step 8: Git commit (skip in dry-run)
        if not args.dry_run:
            print(f"[GIT] Committing...", file=sys.stderr)
            cn_display = cn_name if cn_name else "N/A"
            git_commit(agent_name, f"Initial: {op_name} ({cn_display}) ({stars}★)")

        result["success"] = True
        if args.dry_run:
            print(f"[DONE] Dry run completed", file=sys.stderr)
        else:
            print(f"[DONE] Worker completed", file=sys.stderr)

    except Exception as e:
        result["success"] = False
        result["error"] = str(e)
        print(f"[FATAL] {e}", file=sys.stderr)

    # Output raw JSON to stdout
    print(json.dumps(result))


if __name__ == "__main__":
    main()


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