Skill: dhruvkar/youtube-comment-moderator (version 1.0.1)

--- name: youtube-comment-moderator description: > AI-powered YouTube comment moderation. Fetches comments, classifies them (spam, question, praise, hate, neutral, constructive), drafts replies, and deletes spam. Works with any YouTube channel via OAuth. Use when moderating YouTube comments, setting up auto-replies, analyzing comment sentiment, managing spam, or building a comment moderation pipeline. env: - name: YOUTUBE_API_KEY required: true description: YouTube Data API v3 key (free, 10K units/day) - name: GEMINI_API_KEY required: true description: Gemini API key for comment classification - name: YT_MOD_CLIENT_ID required: true description: Google OAuth client ID (for reply/delete operations) - name: YT_MOD_CLIENT_SECRET required: true description: Google OAuth client secret (for reply/delete operations) --- # YouTube Comment Moderator Classify, reply to, and delete YouTube comments automatically using Gemini Flash + YouTube Data API v3. ## Setup (Agent-Driven) When a user wants to set up YouTube moderation, walk them through this conversationally. Do not tell them to run shell commands. ### 1. Collect Channel Info Ask: "What's your YouTube channel URL or channel ID?" Resolve it: ```bash source .env python3 scripts/fetch_comments.py --channel-id <ID> --max-videos 1 --max-comments 0 ``` This validates the channel exists. If they give a URL like `youtube.com/@handle`, extract the handle and look up the channel ID via the API. ### 2. Check Environment Keys Four keys are needed. Check which are missing: ```bash echo "YOUTUBE_API_KEY=${YOUTUBE_API_KEY:+SET}" echo "GEMINI_API_KEY=${GEMINI_API_KEY:+SET}" echo "YT_MOD_CLIENT_ID=${YT_MOD_CLIENT_ID:+SET}" echo "YT_MOD_CLIENT_SECRET=${YT_MOD_CLIENT_SECRET:+SET}" ``` For any missing keys, tell the user what to do (not how to run scripts): - **YOUTUBE_API_KEY**: "Go to [Google Cloud Console → Credentials](https://console.cloud.google.com/apis/credentials), create an API key, and add `YOUTUBE_API_KEY=...` to your `.env` file." - **GEMINI_API_KEY**: "Go to [Google AI Studio](https://aistudio.google.com/apikey), create a key, and add `GEMINI_API_KEY=...` to your `.env` file." - **YT_MOD_CLIENT_ID + YT_MOD_CLIENT_SECRET**: These require OAuth setup. Walk them through `references/oauth-setup.md` conversationally, or summarize: "In Google Cloud Console, enable YouTube Data API, create an OAuth consent screen (External, Testing mode), add yourself as test user, create a Web Application OAuth client with redirect URI `http://127.0.0.1:8976/callback`, then add the Client ID and Client Secret to your `.env`." After they confirm keys are added, restart the gateway so env vars reload. ### 3. OAuth Authorization Generate the auth URL and send it to the user: ```bash source .env && python3 scripts/setup.py --auth-url ``` Tell them: "Open this link, sign in with the Google account that owns your YouTube channel, and click Allow." **If user has a local browser (desktop):** The script automatically catches the callback on `http://127.0.0.1:8976/callback` and saves the token. Tell them: "After clicking Allow, the browser should show a success page. You're done — I'll verify the token was saved." **If user is headless/remote (VPS, Telegram):** Tell them: "After clicking Allow, your browser will redirect to a URL starting with `http://127.0.0.1:8976/callback?code=...`. That page won't load — that's expected. Copy ONLY the `code` parameter value from the URL (the part between `code=` and the next `&`), and paste just that code here. Do NOT paste the full URL — it contains sensitive auth data." When they provide the code: ```bash source .env && python3 scripts/setup.py --exchange-code "http://127.0.0.1:8976/callback?code=<CODE>" ``` ⚠️ **Security note:** The OAuth code is single-use and expires in minutes, but avoid logging or echoing the full callback URL. If the user accidentally pastes a full URL in chat, exchange it immediately, then advise them the code is already consumed and useless. ### 4. Configure Ask them conversationally: - "What moderation mode? **Auto** (replies and deletes automatically), **Approval** (drafts everything for your review), or **Monitor** (classify and report only)?" - "How do you typically talk to your audience? Casual? Professional? Funny?" (this sets voice_style) - "Any common questions you get? I can pre-load answers so replies are accurate." (optional FAQ) Then create the config: ```bash python3 scripts/setup.py --create-config \ --channel-id <ID> \ --channel-name "<NAME>" \ --mode <approval|auto|monitor> \ --voice "<VOICE_STYLE>" ``` ### 5. Test Run a dry run and show them the results: ```bash source .env && python3 scripts/moderate.py --all-videos --dry-run ``` Summarize: "Found X videos, Y new comments. Here's what I'd do: Z spam deleted, W questions answered, etc." Show a few examples. ### 6. Go Live Once they approve: ```bash source .env && python3 scripts/moderate.py --all-videos ``` Optionally set up a cron job to run every 15-30 minutes for ongoing moderation. ## Scripts Reference | Script | Purpose | |--------|---------| | `setup.py` | Setup wizard (interactive or `--auth-url` / `--exchange-code` / `--create-config`) | | `moderate.py` | Main pipeline: fetch → classify → act. Also: `--stats`, `--queue`, `--approve`, `--all-videos` | | `fetch_comments.py` | Standalone comment fetcher (API key only) | | `classify_comments.py` | Standalone classifier (JSON in → JSON out) | | `db.py` | SQLite persistence layer | ## Moderation Modes - **monitor** — classify + report only (no OAuth needed) - **approval** — drafts replies and queues deletions for review (`--queue` to see, `--approve` to execute) - **auto** — auto-replies to questions, auto-deletes spam/hate ## Classification Categories | Category | Auto Action | Description | |----------|-------------|-------------| | spam | delete | Promotional links, scam offers, bot text, self-promo | | question | reply | Genuine question about video/creator/topic | | praise | thank | Positive feedback, compliments | | hate | delete | Hateful, abusive, harassing content | | neutral | skip | Generic reactions, timestamps | | constructive | flag | Thoughtful criticism, suggestions | ## Architecture - **State:** SQLite (`data/youtube-comment-moderator/moderator.db`) - **Classification:** Gemini 2.0 Flash (~$0.001/comment) - **Read:** YouTube Data API v3 (API key) - **Write:** YouTube Data API v3 (OAuth) - **Deduplication:** comment_id primary key, already-processed comments skipped ## Environment Variables Required: - `YOUTUBE_API_KEY` — YouTube Data API v3 key (free, 10K units/day) - `GEMINI_API_KEY` — Gemini Flash for classification For write operations (reply/delete): - `YT_MOD_CLIENT_ID` — Google OAuth client ID - `YT_MOD_CLIENT_SECRET` — Google OAuth client secret Optional overrides: - `YT_MOD_DB` — SQLite DB path (default: `data/youtube-comment-moderator/moderator.db`) - `YT_MOD_OAUTH` — OAuth token path (default: `skills/youtube-comment-moderator/oauth.json`) - `YT_MOD_CONFIG` — Config path (default: `skills/youtube-comment-moderator/config.json`) ## OAuth Setup Details See [references/oauth-setup.md](references/oauth-setup.md) for the full step-by-step Google Cloud setup guide with screenshots-level detail and troubleshooting.

path: scripts/setup.py
content: #!/usr/bin/env python3
"""
Interactive setup wizard for YouTube Comment Moderator skill.
Walks creator through: channel ID, API key, OAuth, preferences.
"""

import json
import os
import sys
import re
import threading
import webbrowser
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.request import urlopen, Request
from urllib.parse import urlencode, parse_qs, urlparse
from urllib.error import HTTPError

CONFIG_PATH = "skills/youtube-comment-moderator/config.json"
OAUTH_PATH = "skills/youtube-comment-moderator/oauth.json"

# Google OAuth endpoints
GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"

# OAuth credentials from environment (each user creates their own)
# See references/oauth-setup.md for instructions
CLIENT_ID = os.environ.get("YT_MOD_CLIENT_ID", "")
CLIENT_SECRET = os.environ.get("YT_MOD_CLIENT_SECRET", "")

YOUTUBE_SCOPES = [
    "https://www.googleapis.com/auth/youtube.force-ssl"  # read + write comments
]

OAUTH_REDIRECT_PORT = 8976


def extract_channel_id(url_or_id):
    """Extract channel ID from a YouTube URL or return as-is if already an ID."""
    if url_or_id.startswith("UC") and len(url_or_id) == 24:
        return url_or_id
    
    # Try to extract from URL patterns
    patterns = [
        r"youtube\.com/channel/(UC[\w-]{22})",
        r"youtube\.com/@([\w.-]+)",
        r"youtube\.com/c/([\w.-]+)",
    ]
    
    for pattern in patterns:
        match = re.search(pattern, url_or_id)
        if match:
            result = match.group(1)
            if result.startswith("UC"):
                return result
            # Handle @username - need API lookup
            return resolve_handle(result, url_or_id)
    
    return url_or_id


def resolve_handle(handle, original_input):
    """Resolve a YouTube handle/username to channel ID via API."""
    [REDACTED_SECRET]"YOUTUBE_API_KEY")
    if not [REDACTED_SECRET]"  Can't resolve @{handle} without YOUTUBE_API_KEY. Please provide channel ID directly.")
        return original_input
    
    # Try handle resolution
    url = f"https://www.googleapis.com/youtube/v3/channels?part=snippet&forHandle={handle}&key={api_key}"
    req = Request(url, headers={"User-Agent": "YT-Moderator/1.0"})
    try:
        with urlopen(req, timeout=15) as resp:
            data = json.loads(resp.read())
        if data.get("items"):
            channel_id = data["items"][0]["id"]
            channel_name = data["items"][0]["snippet"]["title"]
            print(f"  Found: {channel_name} ({channel_id})")
            return channel_id
    except Exception as e:
        print(f"  Error resolving handle: {e}")
    
    return original_input


def get_channel_info(channel_id, api_key):
    """Get channel name and stats."""
    url = f"https://www.googleapis.com/youtube/v3/channels?part=snippet,statistics&id={channel_id}&key={api_key}"
    req = Request(url, headers={"User-Agent": "YT-Moderator/1.0"})
    try:
        with urlopen(req, timeout=15) as resp:
            data = json.loads(resp.read())
        if data.get("items"):
            item = data["items"][0]
            return {
                "name": item["snippet"]["title"],
                "subscribers": int(item["statistics"].get("subscriberCount", 0)),
                "videos": int(item["statistics"].get("videoCount", 0))
            }
    except Exception:
        pass
    return None


class OAuthCallbackHandler(BaseHTTPRequestHandler):
    """Tiny HTTP handler that captures the OAuth callback code."""
    auth_code = None
    error = None

    def do_GET(self):
        query = parse_qs(urlparse(self.path).query)
        if "code" in query:
            OAuthCallbackHandler.auth_code = query["code"][0]
            self.send_response(200)
            self.send_header("Content-Type", "text/html")
            self.end_headers()
            self.wfile.write(b"""<html><body style="font-family:system-ui;text-align:center;padding:60px">
                <h1>&#9989; Authorization successful!</h1>
                <p>You can close this tab and return to the terminal.</p>
                </body></html>""")
        else:
            OAuthCallbackHandler.error = query.get("error", ["unknown"])[0]
            self.send_response(400)
            self.send_header("Content-Type", "text/html")
            self.end_headers()
            self.wfile.write(f"<html><body><h1>Authorization failed: {OAuthCallbackHandler.error}</h1></body></html>".encode())

    def log_message(self, format, *args):
        pass  # silence request logs


def find_free_port():
    """Find an available port for the OAuth callback server."""
    import socket
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.bind(("127.0.0.1", 0))
        return s.getsockname()[1]


def is_headless():
    """Detect if we're running without a display (VPS, SSH, container)."""
    if os.environ.get("SSH_CLIENT") or os.environ.get("SSH_TTY"):
        return True
    if not os.environ.get("DISPLAY") and not os.environ.get("WAYLAND_DISPLAY"):
        return True
    if os.environ.get("OPENCLAW_HEADLESS"):
        return True
    return False


def extract_code_from_url(url_or_code):
    """Extract the auth code from a full callback URL or bare code string."""
    url_or_code = url_or_code.strip()
    if "code=" in url_or_code:
        parsed = parse_qs(urlparse(url_or_code).query)
        if "code" in parsed:
            return parsed["code"][0]
    return url_or_code


def do_oauth():
    """Walk user through OAuth flow. Auto-detects headless vs desktop."""
    print("\n=== YouTube Authorization ===")
    print("This lets the moderator reply to and delete comments on your behalf.")
    print("You can skip this for read-only mode (classification + reports only).\n")
    
    skip = input("Skip OAuth for now? (y/n, default: n): ").strip().lower()
    if skip == "y":
        return None

    if not CLIENT_ID or not CLIENT_SECRET:
        print("  ❌ OAuth credentials not found in environment.")
        print("  Set YT_MOD_CLIENT_ID and YT_MOD_CLIENT_SECRET in your .env file.")
        print("  See references/oauth-setup.md for step-by-step instructions.")
        print("  Skipping OAuth for now.\n")
        return None
    
    headless = is_headless()
    port = OAUTH_REDIRECT_PORT
    redirect_uri = f"http://127.0.0.1:{port}/callback"
    
    # Build auth URL
    params = {
        "client_id": CLIENT_ID,
        "redirect_uri": redirect_uri,
        "response_type": "code",
        "scope": " ".join(YOUTUBE_SCOPES),
        "access_type": "offline",
        "prompt": "consent"
    }
    auth_url = f"{GOOGLE_AUTH_URL}?{urlencode(params)}"
    
    if headless:
        # Headless mode: user opens URL on their own machine, pastes redirect URL back
        print("  Headless environment detected (no local browser).\n")
        print("  1. Open this URL in a browser on your computer:\n")
        print(f"     {auth_url}\n")
        print("  2. Sign in and click 'Allow'")
        print("  3. Your browser will redirect to a localhost URL that won't load.")
        print("     That's expected! Copy the ENTIRE URL from your browser's address bar.")
        print("     It looks like: http://127.0.0.1:.../callback?code=4/0A...\n")
        
        pasted = input("  Paste the full URL here: ").strip()
        if not pasted:
            print("  No input provided, skipping OAuth")
            return None
        
        code = extract_code_from_url(pasted)
        if not code:
            print("  Couldn't extract auth code from input, skipping OAuth")
            return None
        
        print("  Got it! Exchanging for tokens...")
    else:
        # Desktop mode: spin up localhost server, open browser automatically
        server = HTTPServer(("127.0.0.1", port), OAuthCallbackHandler)
        OAuthCallbackHandler.auth_code = None
        OAuthCallbackHandler.error = None
        
        print(f"\nOpening browser for authorization...\n")
        try:
            webbrowser.open(auth_url)
            print("  If the browser didn't open, visit this URL manually:\n")
        except Exception:
            print("  Couldn't open browser. Visit this URL:\n")
        print(f"  {auth_url}\n")
        print("  Waiting for authorization (5 min timeout)...")
        
        # Wait for the callback
        server.timeout = 300
        while OAuthCallbackHandler.auth_code is None and OAuthCallbackHandler.error is None:
            server.handle_request()
        
        server.server_close()
        
        if OAuthCallbackHandler.error:
            print(f"\n  OAuth error: {OAuthCallbackHandler.error}")
            return None
        
        code = OAuthCallbackHandler.auth_code
        if not code:
            print("  No authorization code received, skipping OAuth")
            return None
        
        print("  Authorization code received! Exchanging for tokens...")
    
    # Exchange code for tokens
    token_data = urlencode({
        "client_id": CLIENT_ID,
        "client_secret": CLIENT_SECRET,
        "code": code,
        "grant_type": "authorization_code",
        "redirect_uri": redirect_uri
    }).encode()
    
    req = Request(GOOGLE_TOKEN_URL, data=token_data, headers={
        "Content-Type": "application/x-www-form-urlencoded"
    })
    
    try:
        with urlopen(req, timeout=15) as resp:
            tokens = json.loads(resp.read())
        
        if "error" in tokens:
            print(f"OAuth error: {tokens.get('error_description', tokens['error'])}")
            return None
        
        oauth = {
            "access_token": tokens["access_token"],
            "refresh_token": tokens.get("refresh_token"),
            "expires_in": tokens.get("expires_in", 3600),
            "scope": tokens.get("scope", "")
        }
        
        # Save tokens
        os.makedirs(os.path.dirname(OAUTH_PATH), exist_ok=True)
        with open(OAUTH_PATH, "w") as f:
            json.dump(oauth, f, indent=2)
        
        print(f"\n  OAuth successful! Tokens saved to {OAUTH_PATH}")
        return oauth
        
    except HTTPError as e:
        body = e.read().decode()
        print(f"Token exchange failed: {body[:200]}")
        return None


def generate_auth_url():
    """Generate OAuth URL for non-interactive use (agent-driven setup).
    Prints the URL and instructions. User opens URL, pastes redirect URL back.
    Then call: setup.py --exchange-code <CODE_OR_URL>
    """
    if not CLIENT_ID:
        print("Error: YT_MOD_CLIENT_ID not set in environment", file=sys.stderr)
        sys.exit(1)

    redirect_uri = f"http://127.0.0.1:{OAUTH_REDIRECT_PORT}/callback"
    params = {
        "client_id": CLIENT_ID,
        "redirect_uri": redirect_uri,
        "response_type": "code",
        "scope": " ".join(YOUTUBE_SCOPES),
        "access_type": "offline",
        "prompt": "consent"
    }
    auth_url = f"{GOOGLE_AUTH_URL}?{urlencode(params)}"
    print(auth_url)
    return auth_url


def exchange_code(code_or_url):
    """Exchange an auth code (or full callback URL) for tokens. Non-interactive."""
    if not CLIENT_ID or not CLIENT_SECRET:
        print("Error: YT_MOD_CLIENT_ID and YT_MOD_CLIENT_SECRET required", file=sys.stderr)
        sys.exit(1)

    code = extract_code_from_url(code_or_url)
    redirect_uri = f"http://127.0.0.1:{OAUTH_REDIRECT_PORT}/callback"

    token_data = urlencode({
        "client_id": CLIENT_ID,
        "client_secret": CLIENT_SECRET,
        "code": code,
        "grant_type": "authorization_code",
        "redirect_uri": redirect_uri
    }).encode()

    req = Request(GOOGLE_TOKEN_URL, data=token_data, headers={
        "Content-Type": "application/x-www-form-urlencoded"
    })

    try:
        with urlopen(req, timeout=15) as resp:
            tokens = json.loads(resp.read())
    except HTTPError as e:
        body = e.read().decode()
        print(f"Token exchange failed: {body[:300]}", file=sys.stderr)
        sys.exit(1)

    if "error" in tokens:
        print(f"OAuth error: {tokens.get('error_description', tokens['error'])}", file=sys.stderr)
        sys.exit(1)

    oauth = {
        "access_token": tokens["access_token"],
        "refresh_token": tokens.get("refresh_token"),
        "expires_in": tokens.get("expires_in", 3600),
        "scope": tokens.get("scope", "")
    }

    os.makedirs(os.path.dirname(OAUTH_PATH), exist_ok=True)
    with open(OAUTH_PATH, "w") as f:
        json.dump(oauth, f, indent=2)

    print(f"OAuth tokens saved to {OAUTH_PATH}")
    return oauth


def create_config(channel_id, channel_name, mode="approval", voice_style="friendly, casual, helpful", faq=None):
    """Create config.json non-interactively."""
    [REDACTED_SECRET]"YOUTUBE_API_KEY", "")
    config = {
        "channel_id": channel_id,
        "channel_name": channel_name,
        "mode": mode,
        "auto_delete_spam": mode in ("auto", "approval"),
        "auto_reply_questions": mode == "auto",
        "auto_reply_praise": False,
        "voice_style": voice_style,
        "faq": faq or [],
        "blocked_patterns": [
            "check out my channel", "dm me for",
            "I made $", "work from home", "crypto", "forex"
        ]
    }
    os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True)
    with open(CONFIG_PATH, "w") as f:
        json.dump(config, f, indent=2)
    print(json.dumps(config, indent=2))
    return config


def main():
    print("=" * 50)
    print("  YouTube Comment Moderator - Setup Wizard")
    print("=" * 50)
    
    # Load existing config if any
    config = {}
    if os.path.exists(CONFIG_PATH):
        with open(CONFIG_PATH) as f:
            config = json.load(f)
        print(f"\nExisting config found for: {config.get('channel_name', 'unknown')}")
        reconfigure = input("Reconfigure? (y/n, default: n): ").strip().lower()
        if reconfigure != "y":
            print("Keeping existing config.")
            return
    
    # Step 1: API Key
    print("\n=== Step 1: YouTube API Key ===")
    [REDACTED_SECRET]"YOUTUBE_API_KEY", "")
    if [REDACTED_SECRET]"  ✅ Found YOUTUBE_API_KEY in environment ({api_key[:8]}...)")
    else:
        print("  ❌ YOUTUBE_API_KEY not found in environment.")
        print("  Add it to your .env file. See references/oauth-setup.md for instructions.")
        sys.exit(1)
    
    # Step 2: Channel
    print("\n=== Step 2: Your YouTube Channel ===")
    channel_input = input("  Channel URL or ID (e.g. https://youtube.com/@YourChannel): ").strip()
    channel_id = extract_channel_id(channel_input)
    
    info = get_channel_info(channel_id, api_key)
    if info:
        print(f"\n  Channel: {info['name']}")
        print(f"  Subscribers: {info['subscribers']:,}")
        print(f"  Videos: {info['videos']}")
    else:
        print(f"  Warning: couldn't verify channel {channel_id}")
    
    channel_name = info["name"] if info else input("  Channel name: ").strip()
    
    # Step 3: Mode
    print("\n=== Step 3: Moderation Mode ===")
    print("  1. Monitor only — classify + daily report (no API write access needed)")
    print("  2. Approval — drafts replies, you approve before posting")
    print("  3. Full auto — auto-replies to questions, auto-deletes spam")
    mode_choice = input("  Choose mode (1/2/3, default: 2): ").strip() or "2"
    mode_map = {"1": "monitor", "2": "approval", "3": "auto"}
    mode = mode_map.get(mode_choice, "approval")
    
    # Step 4: Voice/style
    print("\n=== Step 4: Your Voice ===")
    print("  How do you typically reply to comments? (e.g. 'casual and friendly',")
    print("  'professional but warm', 'short and direct')")
    voice = input("  Your style: ").strip() or "friendly, casual, appreciative"
    
    # Step 5: FAQ
    print("\n=== Step 5: FAQ (optional) ===")
    print("  Add common questions and your preferred answers.")
    print("  Press Enter with empty question to finish.")
    faq = []
    while True:
        q = input("  Q: ").strip()
        if not q:
            break
        a = input("  A: ").strip()
        if a:
            faq.append({"q": q, "a": a})
    
    # Step 6: OAuth (if not monitor-only)
    oauth = None
    if mode != "monitor":
        oauth = do_oauth()
        if not oauth and mode == "auto":
            print("\n  No OAuth — falling back to approval mode")
            mode = "approval"
    
    # Build config
    config = {
        "channel_id": channel_id,
        "channel_name": channel_name,
        "youtube_api_key": api_key,
        "mode": mode,
        "check_interval_minutes": 30,
        "max_videos_per_check": 5,
        "max_comments_per_video": 100,
        "auto_delete_spam": mode in ("auto", "approval"),
        "auto_reply_questions": mode == "auto",
        "auto_reply_praise": False,
        "voice_style": voice,
        "faq": faq,
        "blocked_patterns": [
            "check out my channel",
            "dm me for",
            "I made $",
            "work from home",
            "crypto",
            "forex"
        ],
        "has_oauth": oauth is not None
    }
    
    # Save
    os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True)
    with open(CONFIG_PATH, "w") as f:
        json.dump(config, f, indent=2)
    
    print(f"\n{'=' * 50}")
    print(f"  Setup complete!")
    print(f"{'=' * 50}")
    print(f"\n  Channel: {channel_name} ({channel_id})")
    print(f"  Mode: {mode}")
    print(f"  OAuth: {'yes' if oauth else 'no (read-only)'}")
    print(f"  FAQ entries: {len(faq)}")
    print(f"  Config saved: {CONFIG_PATH}")
    
    if mode != "monitor" and not oauth:
        print(f"\n  ⚠️  Run setup again to add OAuth when ready for write access")
    
    print(f"\n  Test it:")
    print(f"  python3 skills/youtube-comment-moderator/scripts/fetch_comments.py --channel-id {channel_id} --max-videos 1")
    print(f"  python3 skills/youtube-comment-moderator/scripts/classify_comments.py")


if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser(description="YouTube Moderator Setup")
    parser.add_argument("--auth-url", action="store_true",
                        help="Print OAuth URL (non-interactive)")
    parser.add_argument("--exchange-code", metavar="CODE_OR_URL",
                        help="Exchange auth code for tokens (non-interactive)")
    parser.add_argument("--create-config", action="store_true",
                        help="Create config non-interactively")
    parser.add_argument("--channel-id", help="Channel ID for --create-config")
    parser.add_argument("--channel-name", help="Channel name for --create-config")
    parser.add_argument("--mode", default="approval",
                        choices=["monitor", "approval", "auto"])
    parser.add_argument("--voice", default="friendly, casual, helpful")
    args = parser.parse_args()

    if args.auth_url:
        generate_auth_url()
    elif args.exchange_code:
        exchange_code(args.exchange_code)
    elif args.create_config:
        if not args.channel_id:
            print("Error: --channel-id required", file=sys.stderr)
            sys.exit(1)
        create_config(args.channel_id, args.channel_name or args.channel_id,
                      args.mode, args.voice)
    else:
        main()


path: scripts/moderate.py
content: #!/usr/bin/env python3
"""
YouTube Comment Moderator — main pipeline.
Fetches new comments, classifies with Gemini Flash, takes action via OAuth.
All state persisted in SQLite.

Usage:
  # Moderate a specific video (dry run)
  python3 scripts/moderate.py --video-id VIDEO_ID --dry-run

  # Moderate a channel's recent videos
  python3 scripts/moderate.py --channel-id UC... --max-videos 5

  # Full auto on configured channel
  python3 scripts/moderate.py

  # Show stats
  python3 scripts/moderate.py --stats

  # Show approval queue
  python3 scripts/moderate.py --queue
"""

import argparse
import hashlib
import json
import os
import sys
import time
from datetime import datetime, timezone
from urllib.request import urlopen, Request
from urllib.parse import urlencode
from urllib.error import HTTPError

# Sibling imports
sys.path.insert(0, os.path.dirname(__file__))
from db import (
    get_db, init_db, upsert_comment, mark_classified, log_action,
    update_action, get_pending_actions, get_stats, is_known,
    save_channel, update_last_check, upsert_video, mark_video_scanned,
    get_scanned_videos
)

[REDACTED_SECRET]"YOUTUBE_API_KEY")
GEMINI_KEY = os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_AI_API_KEY")
BASE_URL = "https://www.googleapis.com/youtube/v3"
OAUTH_PATH = os.environ.get("YT_MOD_OAUTH", "skills/youtube-comment-moderator/oauth.json")
CONFIG_PATH = os.environ.get("YT_MOD_CONFIG", "skills/youtube-comment-moderator/config.json")

CLIENT_ID = os.environ.get("YT_MOD_CLIENT_ID", "")
CLIENT_SECRET = os.environ.get("YT_MOD_CLIENT_SECRET", "")


# ── YouTube API helpers ──────────────────────────────────────────────

def yt_get(endpoint, params):
    params["key"] = API_KEY
    url = f"{BASE_URL}/{endpoint}?{urlencode(params)}"
    req = Request(url, headers={"User-Agent": "YT-Moderator/2.0"})
    try:
        with urlopen(req, timeout=15) as resp:
            return json.loads(resp.read())
    except HTTPError as e:
        print(f"  API error {e.code}: {e.read().decode()[:200]}", file=sys.stderr)
        return None


def get_oauth_token():
    """Load, test, and refresh OAuth token."""
    if not os.path.exists(OAUTH_PATH):
        return None
    with open(OAUTH_PATH) as f:
        oauth = json.load(f)

    [REDACTED_SECRET]"access_token")
    if not [REDACTED_SECRET] None

    # Quick validity check
    req = Request(
        f"{BASE_URL}/channels?part=id&mine=true",
        headers={"Authorization": f"Bearer {token}"}
    )
    try:
        with urlopen(req, timeout=10):
            return token
    except HTTPError:
        pass

    # Refresh
    refresh = oauth.get("refresh_token")
    if not refresh:
        return None
    data = urlencode({
        "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET,
        "refresh_token": refresh, "grant_type": "refresh_token"
    }).encode()
    req = Request(
        "https://oauth2.googleapis.com/token", data=data,
        headers={"Content-Type": "application/x-www-form-urlencoded"}
    )
    try:
        with urlopen(req, timeout=15) as resp:
            tokens = json.loads(resp.read())
        oauth["access_token"] = tokens["access_token"]
        with open(OAUTH_PATH, "w") as f:
            json.dump(oauth, f, indent=2)
        return tokens["access_token"]
    except Exception as e:
        print(f"  OAuth refresh failed: {e}", file=sys.stderr)
        return None


def yt_post_oauth(endpoint, body, token):
    url = f"{BASE_URL}/{endpoint}"
    req = Request(url, data=json.dumps(body).encode(), headers={
        "Authorization": f"Bearer {token}",
        "Content-Type": "application/json"
    })
    try:
        with urlopen(req, timeout=15) as resp:
            return json.loads(resp.read())
    except HTTPError as e:
        print(f"  OAuth error {e.code}: {e.read().decode()[:200]}", file=sys.stderr)
        return None


def yt_set_moderation(comment_id, status, token):
    """Set moderation status: published, heldForReview, rejected."""
    url = f"{BASE_URL}/comments/setModerationStatus?{urlencode({'id': comment_id, 'moderationStatus': status})}"
    req = Request(url, method="POST", headers={"Authorization": f"Bearer {token}"})
    try:
        with urlopen(req, timeout=15):
            return True
    except HTTPError as e:
        print(f"  Moderation error {e.code}: {e.read().decode()[:200]}", file=sys.stderr)
        return False


# ── Fetch ────────────────────────────────────────────────────────────

def get_channel_videos(channel_id, max_videos=10):
    data = yt_get("channels", {"part": "contentDetails,statistics", "id": channel_id})
    if not data or not data.get("items"):
        return []
    uploads = data["items"][0]["contentDetails"]["relatedPlaylists"]["uploads"]
    total_vids = int(data["items"][0].get("statistics", {}).get("videoCount", 0))
    if max_videos == -1:
        max_videos = max(total_vids, 500)  # -1 = all videos
        print(f"  Fetching all {total_vids} videos...")
    videos, page_token = [], None
    while len(videos) < max_videos:
        params = {"part": "snippet", "playlistId": uploads,
                  "maxResults": min(50, max_videos - len(videos))}
        if page_token:
            params["pageToken"] = page_token
        data = yt_get("playlistItems", params)
        if not data or not data.get("items"):
            break
        for item in data["items"]:
            videos.append({
                "video_id": item["snippet"]["resourceId"]["videoId"],
                "title": item["snippet"]["title"],
                "published_at": item["snippet"].get("publishedAt")
            })
        page_token = data.get("nextPageToken")
        if not page_token:
            break
    return videos[:max_videos] if max_videos > 0 else videos


def get_channel_owner_id(video_id):
    data = yt_get("videos", {"part": "snippet", "id": video_id})
    if data and data.get("items"):
        return data["items"][0]["snippet"]["channelId"]
    return None


def fetch_comments(video_id, max_comments=200):
    comments, page_token = [], None
    while len(comments) < max_comments:
        params = {
            "part": "snippet", "videoId": video_id,
            "maxResults": min(100, max_comments - len(comments)),
            "order": "time", "textFormat": "plainText"
        }
        if page_token:
            params["pageToken"] = page_token
        data = yt_get("commentThreads", params)
        if not data or "items" not in data:
            break
        for item in data["items"]:
            s = item["snippet"]["topLevelComment"]["snippet"]
            comments.append({
                "comment_id": item["id"],
                "video_id": video_id,
                "author": s["authorDisplayName"],
                "author_channel_id": s.get("authorChannelId", {}).get("value", ""),
                "text": s["textDisplay"],
                "like_count": s["likeCount"],
                "reply_count": item["snippet"]["totalReplyCount"],
                "published_at": s["publishedAt"],
            })
        page_token = data.get("nextPageToken")
        if not page_token:
            break
    return comments[:max_comments]


# ── Classify ─────────────────────────────────────────────────────────

def classify_batch(comments, channel_name="", batch_size=50):
    """Classify comments with Gemini Flash. Returns {comment_id: {category, confidence}}."""
    if not comments:
        return {}

    results = {}
    for i in range(0, len(comments), batch_size):
        batch = comments[i:i + batch_size]
        lines = "\n".join(
            f'{j+1}. [id:{c["comment_id"]}] [{c["author"]}] (likes:{c["like_count"]}) {c["text"][:400]}'
            for j, c in enumerate(batch)
        )

        prompt = f"""Classify each YouTube comment on {channel_name}'s channel into exactly one category:
spam, question, praise, hate, neutral, constructive.

Rules:
- spam: promotional links, scam offers, bot-like text, crypto/forex schemes, coordinated spam (same phrase across users), self-promo
- question: genuine question about video content, creator, or topic
- praise: positive feedback, compliments, encouragement
- hate: hateful, abusive, harassing, threatening, or low-effort attacks on creator
- neutral: generic reactions, timestamps, observations, personal anecdotes
- constructive: thoughtful criticism, corrections, experience-based feedback with substance

Return ONLY a JSON array: [{{"index": 1, "category": "...", "confidence": 85}}]

Comments:
{lines}"""

        url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={GEMINI_KEY}"
        body = json.dumps({
            "contents": [{"parts": [{"text": prompt}]}],
            "generationConfig": {"temperature": 0.1, "responseMimeType": "application/json"}
        })
        req = Request(url, data=body.encode(), headers={"Content-Type": "application/json"})

        try:
            with urlopen(req, timeout=60) as resp:
                data = json.loads(resp.read())
            text = data["candidates"][0]["content"]["parts"][0]["text"].strip()
            if text.startswith("```"):
                text = text.split("\n", 1)[1].rsplit("```", 1)[0]
            classifications = json.loads(text)

            for c in classifications:
                idx = c["index"] - 1
                if 0 <= idx < len(batch):
                    cid = batch[idx]["comment_id"]
                    results[cid] = {
                        "category": c["category"],
                        "confidence": c.get("confidence", 80)
                    }
        except Exception as e:
            print(f"  Classification error (batch {i//batch_size+1}): {e}", file=sys.stderr)

        if i + batch_size < len(comments):
            time.sleep(1)

    return results


# ── Reply drafting ───────────────────────────────────────────────────

THANK_OPTIONS = [
    "Thank you! Really appreciate that.",
    "Thanks so much! Glad you enjoyed it.",
    "Appreciate the kind words!",
    "Thank you! More coming soon.",
    "That means a lot, thank you!",
]


def draft_reply(comment, category, config):
    """Draft a reply. Simple templates for praise, Gemini for questions."""
    if category == "praise":
        idx = int(hashlib.md5(comment["comment_id"].encode()).hexdigest(), 16) % len(THANK_OPTIONS)
        return THANK_OPTIONS[idx]

    if category != "question":
        return None

    voice = config.get("voice_style", "friendly, casual, helpful")
    faq = config.get("faq", [])
    faq_text = "\n".join(f"Q: {f['q']}\nA: {f['a']}" for f in faq) if faq else "No FAQ."

    prompt = f"""Reply to a YouTube comment as the channel owner.
Voice: {voice}
Channel: {config.get('channel_name', '')}

FAQ:
{faq_text}

Comment by @{comment['author']}:
"{comment['text']}"

Short reply (1-3 sentences). If unsure, say "Great question! I'll cover that soon." Match the voice.
Reply:"""

    url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={GEMINI_KEY}"
    body = json.dumps({
        "contents": [{"parts": [{"text": prompt}]}],
        "generationConfig": {"temperature": 0.7, "maxOutputTokens": 200}
    })
    req = Request(url, data=body.encode(), headers={"Content-Type": "application/json"})
    try:
        with urlopen(req, timeout=30) as resp:
            data = json.loads(resp.read())
        return data["candidates"][0]["content"]["parts"][0]["text"].strip().strip('"')
    except Exception as e:
        print(f"  Reply draft error: {e}", file=sys.stderr)
        return None


# ── Action execution ─────────────────────────────────────────────────

CATEGORY_ACTION_MAP = {
    "spam": "delete",
    "hate": "delete",
    "question": "reply",
    "praise": "thank",
    "constructive": "flag_review",
    "neutral": "skip",
}


def execute_actions(conn, comments_with_class, config, token, dry_run=False):
    """Execute moderation actions. Returns summary dict."""
    mode = config.get("mode", "approval")
    results = {"replied": 0, "deleted": 0, "flagged": 0, "drafted": 0, "skipped": 0, "errors": 0}
    report = []

    for comment, cls_info in comments_with_class:
        cid = comment["comment_id"]
        category = cls_info["category"]
        confidence = cls_info.get("confidence", 80)
        intended = CATEGORY_ACTION_MAP.get(category, "skip")

        reply_text = None
        if intended in ("reply", "thank"):
            reply_text = draft_reply(comment, category, config)

        # Store classification
        action_label = "pending"

        if intended == "delete":
            if mode == "auto" and not dry_run and token:
                if config.get("auto_delete_spam", True):
                    ok = yt_set_moderation(cid, "rejected", token)
                    action_label = "deleted" if ok else "delete_failed"
                    log_action(conn, cid, action_label, success=ok,
                              error=None if ok else "API call failed")
                    results["deleted" if ok else "errors"] += 1
                else:
                    action_label = "flagged_for_review"
                    results["flagged"] += 1
            else:
                action_label = "flagged_for_review" if not dry_run else "would_delete"
                results["flagged" if not dry_run else "skipped"] += 1

        elif intended in ("reply", "thank") and reply_text:
            if mode == "auto" and not dry_run and token:
                resp = yt_post_oauth("comments?part=snippet", {
                    "snippet": {"parentId": cid, "textOriginal": reply_text}
                }, token)
                action_label = "replied" if resp else "reply_failed"
                log_action(conn, cid, action_label, detail=reply_text,
                          success=resp is not None,
                          error=None if resp else "API call failed")
                results["replied" if resp else "errors"] += 1
            else:
                action_label = "reply_drafted"
                results["drafted"] += 1

        elif intended == "flag_review":
            action_label = "flagged_for_review"
            results["flagged"] += 1

        else:
            action_label = "skipped"
            results["skipped"] += 1

        mark_classified(conn, cid, category, confidence, action_label, reply_text)

        icon = {"spam": "🗑", "question": "❓", "praise": "👍", "hate": "🚫",
                "neutral": "➖", "constructive": "🔧"}.get(category, "?")
        line = f"  {icon} [{category}] @{comment['author']}: {comment['text'][:100]}"
        if action_label not in ("skipped", "pending"):
            line += f" → {action_label}"
        if reply_text and action_label in ("replied", "reply_drafted"):
            line += f'\n    💬 "{reply_text[:120]}"'
        report.append(line)

    conn.commit()
    return results, report


# ── Commands ─────────────────────────────────────────────────────────

def cmd_moderate(args, config):
    """Main moderation pipeline."""
    conn = get_db()
    init_db(conn)

    channel_id = args.channel_id or config.get("channel_id", "")
    if not channel_id and not args.video_id:
        print("Error: provide --video-id or --channel-id (or set in config)", file=sys.stderr)
        sys.exit(1)

    # OAuth
    token = None
    if not args.dry_run:
        [REDACTED_SECRET])
        if not [REDACTED_SECRET]"Warning: no OAuth token, running dry-run", file=sys.stderr)
            args.dry_run = True

    # Collect videos
    if args.video_id:
        videos = [{"video_id": args.video_id, "title": args.video_id}]
    else:
        max_v = -1 if args.all_videos else args.max_videos
        videos = get_channel_videos(channel_id, max_v)
        # Skip already fully-scanned videos when doing --all-videos
        if args.all_videos:
            scanned = get_scanned_videos(conn, channel_id)
            before = len(videos)
            videos = [v for v in videos if v["video_id"] not in scanned]
            if before != len(videos):
                print(f"  Skipping {before - len(videos)} already-scanned videos")
        print(f"Processing {len(videos)} videos")

    if not videos:
        print("No videos to process")
        return

    # Get owner ID for whitelisting
    owner_ids = set()
    owner_id = get_channel_owner_id(videos[0]["video_id"])
    if owner_id:
        owner_ids.add(owner_id)

    # Fetch comments, insert into DB, collect new ones
    new_comments = []
    for vi, video in enumerate(videos):
        vid = video["video_id"]
        # Track video in DB
        upsert_video(conn, vid, channel_id or vid,
                     title=video.get("title"),
                     published_at=video.get("published_at"))

        comments = fetch_comments(vid, args.max_comments)
        new_in_video = 0
        for c in comments:
            # Skip owner comments
            if c.get("author_channel_id") in owner_ids:
                cid_channel = channel_id or vid
                upsert_comment(conn, c, cid_channel)
                mark_classified(conn, c["comment_id"], "owner", 100, "skipped")
                continue
            ch = channel_id or vid
            is_new = upsert_comment(conn, c, ch)
            if is_new:
                new_comments.append(c)
                new_in_video += 1
        if new_in_video:
            print(f"  {video['title'][:60]} -- {new_in_video} new / {len(comments)} total")

        # Mark video as fully scanned
        mark_video_scanned(conn, vid)

        if (vi + 1) % 10 == 0:
            conn.commit()
            print(f"  ... {vi+1}/{len(videos)} videos processed")
    conn.commit()

    if not new_comments:
        stats = get_stats(conn, channel_id)
        print(f"\nNo new comments. ({stats['total']} tracked)")
        update_last_check(conn, channel_id) if channel_id else None
        conn.commit()
        conn.close()
        return

    print(f"\n{len(new_comments)} new comments to classify")

    # Classify
    channel_name = config.get("channel_name", "")
    classifications = classify_batch(new_comments, channel_name)

    # Pair comments with classifications
    paired = []
    for c in new_comments:
        cls_info = classifications.get(c["comment_id"], {"category": "neutral", "confidence": 50})
        paired.append((c, cls_info))

    # Execute
    results, report = execute_actions(conn, paired, config, token, args.dry_run)

    if channel_id:
        update_last_check(conn, channel_id)
        conn.commit()

    # Print report
    mode_label = "DRY RUN" if args.dry_run else config.get("mode", "approval").upper()
    print(f"\n{'='*60}")
    print(f"  Moderation Report ({mode_label})")
    print(f"{'='*60}")
    print(f"  New: {len(new_comments)}  Replied: {results['replied']}  Drafted: {results['drafted']}  Deleted: {results['deleted']}  Flagged: {results['flagged']}  Skipped: {results['skipped']}")
    print(f"{'='*60}\n")
    for line in report:
        print(line)

    conn.close()


def cmd_stats(args, config):
    """Show moderation stats."""
    conn = get_db()
    init_db(conn)
    channel_id = args.channel_id or config.get("channel_id")
    stats = get_stats(conn, channel_id)
    # Fall back to unfiltered if channel filter returns nothing
    if stats["total"] == 0 and channel_id:
        stats = get_stats(conn)

    print(f"\n{'='*40}")
    print(f"  Moderation Stats")
    print(f"{'='*40}")
    print(f"  Total comments: {stats['total']}")
    print(f"  Classified: {stats['classified']}")
    print(f"\n  By classification:")
    for k, v in sorted(stats["by_classification"].items(), key=lambda x: -x[1]):
        print(f"    {k:15s}: {v}")
    print(f"\n  By action:")
    for k, v in sorted(stats["by_action"].items(), key=lambda x: -x[1]):
        print(f"    {k:15s}: {v}")
    conn.close()


def cmd_queue(args, config):
    """Show pending approval queue."""
    conn = get_db()
    init_db(conn)
    channel_id = args.channel_id or config.get("channel_id")
    pending = get_pending_actions(conn, channel_id)

    if not pending:
        print("Approval queue is empty.")
        conn.close()
        return

    print(f"\n{len(pending)} items pending:\n")
    for row in pending:
        icon = {"spam": "🗑", "question": "❓", "praise": "👍", "hate": "🚫",
                "constructive": "🔧"}.get(row["classification"], "?")
        print(f"  {icon} [{row['classification']}] @{row['author']}: {row['text'][:100]}")
        print(f"    Action: {row['action']}  ID: {row['comment_id']}")
        if row["reply_draft"]:
            print(f'    Draft: "{row["reply_draft"][:120]}"')
        print()
    conn.close()


def cmd_approve(args, config):
    """Approve and execute all pending items (or specific comment_id)."""
    conn = get_db()
    init_db(conn)

    [REDACTED_SECRET])
    if not [REDACTED_SECRET]"Error: OAuth required for approvals", file=sys.stderr)
        sys.exit(1)

    channel_id = args.channel_id or config.get("channel_id")
    pending = get_pending_actions(conn, channel_id)

    if args.comment_id:
        pending = [r for r in pending if r["comment_id"] == args.comment_id]

    if not pending:
        print("Nothing to approve.")
        conn.close()
        return

    approved = 0
    for row in pending:
        cid = row["comment_id"]
        if row["action"] == "reply_drafted" and row["reply_draft"]:
            resp = yt_post_oauth("comments?part=snippet", {
                "snippet": {"parentId": cid, "textOriginal": row["reply_draft"]}
            }, token)
            if resp:
                update_action(conn, cid, "replied", row["reply_draft"])
                log_action(conn, cid, "replied", detail=row["reply_draft"])
                print(f"  ✅ Replied to @{row['author']}")
                approved += 1
            else:
                print(f"  ❌ Failed to reply to {cid}")

        elif row["action"] == "flagged_for_review" and row["classification"] in ("spam", "hate"):
            ok = yt_set_moderation(cid, "rejected", token)
            if ok:
                update_action(conn, cid, "deleted")
                log_action(conn, cid, "deleted")
                print(f"  ✅ Deleted @{row['author']}'s {row['classification']}")
                approved += 1
            else:
                print(f"  ❌ Failed to delete {cid}")

        elif row["action"] == "flagged_for_review":
            # Constructive: mark as reviewed
            update_action(conn, cid, "reviewed")
            log_action(conn, cid, "reviewed")
            print(f"  ✅ Marked reviewed: @{row['author']}")
            approved += 1

    conn.commit()
    conn.close()
    print(f"\nApproved {approved}/{len(pending)} items")


# ── Main ─────────────────────────────────────────────────────────────

def main():
    parser = argparse.ArgumentParser(description="YouTube Comment Moderator")
    parser.add_argument("--video-id", help="Moderate a specific video")
    parser.add_argument("--channel-id", help="Channel ID to moderate")
    parser.add_argument("--max-videos", type=int, default=5)
    parser.add_argument("--all-videos", action="store_true", help="Process all videos in channel")
    parser.add_argument("--max-comments", type=int, default=200)
    parser.add_argument("--dry-run", action="store_true", help="Classify only, no writes")
    parser.add_argument("--stats", action="store_true", help="Show stats")
    parser.add_argument("--queue", action="store_true", help="Show approval queue")
    parser.add_argument("--approve", action="store_true", help="Approve pending items")
    parser.add_argument("--comment-id", help="Approve specific comment")
    parser.add_argument("--config", default=CONFIG_PATH)
    args = parser.parse_args()

    if not [REDACTED_SECRET]"Error: YOUTUBE_API_KEY not set", file=sys.stderr)
        sys.exit(1)

    # Load config (optional, for channel defaults)
    config = {}
    cfg_path = args.config
    if os.path.exists(cfg_path):
        with open(cfg_path) as f:
            config = json.load(f)

    if args.stats:
        cmd_stats(args, config)
    elif args.queue:
        cmd_queue(args, config)
    elif args.approve:
        cmd_approve(args, config)
    else:
        if not GEMINI_KEY:
            print("Error: GEMINI_API_KEY not set", file=sys.stderr)
            sys.exit(1)
        cmd_moderate(args, config)


if __name__ == "__main__":
    main()


path: scripts/db.py
content: #!/usr/bin/env python3
"""
SQLite persistence layer for YouTube Comment Moderator.
Single DB per installation. All scripts import from here.
"""

import os
import sqlite3
from datetime import datetime, timezone

DEFAULT_DB_PATH = os.environ.get(
    "YT_MOD_DB",
    os.path.join("data", "youtube-comment-moderator", "moderator.db")
)


def get_db(db_path=None):
    """Get a connection with WAL mode and foreign keys enabled."""
    path = db_path or DEFAULT_DB_PATH
    os.makedirs(os.path.dirname(path), exist_ok=True)
    conn = sqlite3.connect(path)
    conn.row_factory = sqlite3.Row
    conn.execute("PRAGMA journal_mode=WAL")
    conn.execute("PRAGMA foreign_keys=ON")
    return conn


def init_db(conn):
    """Create tables if they don't exist."""
    conn.executescript("""
    CREATE TABLE IF NOT EXISTS comments (
        comment_id    TEXT PRIMARY KEY,
        video_id      TEXT NOT NULL,
        channel_id    TEXT NOT NULL,
        author        TEXT,
        author_channel_id TEXT,
        text          TEXT,
        like_count    INTEGER DEFAULT 0,
        reply_count   INTEGER DEFAULT 0,
        published_at  TEXT,
        fetched_at    TEXT NOT NULL,
        classification TEXT,
        confidence    INTEGER,
        action        TEXT,
        action_detail TEXT,
        reply_draft   TEXT,
        processed_at  TEXT,
        raw_json      TEXT
    );

    CREATE INDEX IF NOT EXISTS idx_comments_video ON comments(video_id);
    CREATE INDEX IF NOT EXISTS idx_comments_channel ON comments(channel_id);
    CREATE INDEX IF NOT EXISTS idx_comments_classification ON comments(classification);
    CREATE INDEX IF NOT EXISTS idx_comments_action ON comments(action);
    CREATE INDEX IF NOT EXISTS idx_comments_processed ON comments(processed_at);

    CREATE TABLE IF NOT EXISTS videos (
        video_id     TEXT PRIMARY KEY,
        channel_id   TEXT NOT NULL,
        title        TEXT,
        published_at TEXT,
        comment_count INTEGER DEFAULT 0,
        view_count   INTEGER DEFAULT 0,
        first_scanned TEXT,
        last_scanned  TEXT,
        fully_scanned INTEGER DEFAULT 0
    );

    CREATE INDEX IF NOT EXISTS idx_videos_channel ON videos(channel_id);

    CREATE TABLE IF NOT EXISTS channels (
        channel_id   TEXT PRIMARY KEY,
        channel_name TEXT,
        config_json  TEXT,
        added_at     TEXT NOT NULL,
        last_check   TEXT
    );

    CREATE TABLE IF NOT EXISTS moderation_log (
        id           INTEGER PRIMARY KEY AUTOINCREMENT,
        comment_id   TEXT NOT NULL,
        action       TEXT NOT NULL,
        detail       TEXT,
        success      INTEGER DEFAULT 1,
        error        TEXT,
        created_at   TEXT NOT NULL,
        FOREIGN KEY (comment_id) REFERENCES comments(comment_id)
    );

    CREATE INDEX IF NOT EXISTS idx_log_comment ON moderation_log(comment_id);
    CREATE INDEX IF NOT EXISTS idx_log_created ON moderation_log(created_at);
    """)
    conn.commit()


def upsert_comment(conn, comment, channel_id):
    """Insert or update a comment. Returns True if new."""
    now = datetime.now(timezone.utc).isoformat()
    try:
        conn.execute("""
            INSERT INTO comments (comment_id, video_id, channel_id, author,
                author_channel_id, text, like_count, reply_count,
                published_at, fetched_at, raw_json)
            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            ON CONFLICT(comment_id) DO UPDATE SET
                like_count = excluded.like_count,
                reply_count = excluded.reply_count,
                fetched_at = excluded.fetched_at
        """, (
            comment["comment_id"], comment["video_id"], channel_id,
            comment.get("author"), comment.get("author_channel_id"),
            comment.get("text"), comment.get("like_count", 0),
            comment.get("reply_count", 0), comment.get("published_at"),
            now, None
        ))
        # rowcount=1 means insert, not update (SQLite quirk: both return 1)
        # Check if it was already classified
        row = conn.execute(
            "SELECT processed_at FROM comments WHERE comment_id = ?",
            (comment["comment_id"],)
        ).fetchone()
        return row["processed_at"] is None
    except sqlite3.IntegrityError:
        return False


def mark_classified(conn, comment_id, classification, confidence, action, reply_draft=None):
    """Mark a comment as classified."""
    now = datetime.now(timezone.utc).isoformat()
    conn.execute("""
        UPDATE comments SET classification = ?, confidence = ?,
            action = ?, reply_draft = ?, processed_at = ?
        WHERE comment_id = ?
    """, (classification, confidence, action, reply_draft, now, comment_id))


def log_action(conn, comment_id, action, detail=None, success=True, error=None):
    """Log a moderation action."""
    now = datetime.now(timezone.utc).isoformat()
    conn.execute("""
        INSERT INTO moderation_log (comment_id, action, detail, success, error, created_at)
        VALUES (?, ?, ?, ?, ?, ?)
    """, (comment_id, action, detail, 1 if success else 0, error, now))


def update_action(conn, comment_id, action, detail=None):
    """Update the action on a comment."""
    conn.execute(
        "UPDATE comments SET action = ?, action_detail = ? WHERE comment_id = ?",
        (action, detail, comment_id)
    )


def get_unprocessed(conn, channel_id=None):
    """Get comments that haven't been classified yet."""
    if channel_id:
        return conn.execute(
            "SELECT * FROM comments WHERE processed_at IS NULL AND channel_id = ?",
            (channel_id,)
        ).fetchall()
    return conn.execute(
        "SELECT * FROM comments WHERE processed_at IS NULL"
    ).fetchall()


def get_pending_actions(conn, channel_id=None):
    """Get classified comments awaiting action (reply_drafted, flagged_for_review)."""
    q = "SELECT * FROM comments WHERE action IN ('reply_drafted', 'flagged_for_review')"
    params = ()
    if channel_id:
        q += " AND channel_id = ?"
        params = (channel_id,)
    return conn.execute(q, params).fetchall()


def get_stats(conn, channel_id=None):
    """Get moderation stats."""
    where = ""
    params = ()
    if channel_id:
        where = "WHERE channel_id = ?"
        params = (channel_id,)

    total = conn.execute(f"SELECT COUNT(*) FROM comments {where}", params).fetchone()[0]

    if channel_id:
        classified = conn.execute(
            "SELECT COUNT(*) FROM comments WHERE channel_id = ? AND processed_at IS NOT NULL",
            (channel_id,)
        ).fetchone()[0]
    else:
        classified = conn.execute(
            "SELECT COUNT(*) FROM comments WHERE processed_at IS NOT NULL"
        ).fetchone()[0]

    rows = conn.execute(f"""
        SELECT classification, action, COUNT(*) as cnt
        FROM comments {where}
        GROUP BY classification, action
    """, params).fetchall()

    by_class = {}
    by_action = {}
    for r in rows:
        c = r["classification"] or "unclassified"
        a = r["action"] or "pending"
        by_class[c] = by_class.get(c, 0) + r["cnt"]
        by_action[a] = by_action.get(a, 0) + r["cnt"]

    return {"total": total, "classified": classified, "by_classification": by_class, "by_action": by_action}


def upsert_video(conn, video_id, channel_id, title=None, published_at=None,
                 comment_count=None, view_count=None):
    """Insert or update a video."""
    now = datetime.now(timezone.utc).isoformat()
    conn.execute("""
        INSERT INTO videos (video_id, channel_id, title, published_at,
            comment_count, view_count, first_scanned, last_scanned)
        VALUES (?, ?, ?, ?, ?, ?, ?, ?)
        ON CONFLICT(video_id) DO UPDATE SET
            title = COALESCE(excluded.title, videos.title),
            comment_count = COALESCE(excluded.comment_count, videos.comment_count),
            view_count = COALESCE(excluded.view_count, videos.view_count),
            last_scanned = excluded.last_scanned
    """, (video_id, channel_id, title, published_at,
          comment_count, view_count, now, now))


def mark_video_scanned(conn, video_id):
    """Mark a video as fully scanned."""
    now = datetime.now(timezone.utc).isoformat()
    conn.execute(
        "UPDATE videos SET fully_scanned = 1, last_scanned = ? WHERE video_id = ?",
        (now, video_id)
    )


def get_scanned_videos(conn, channel_id):
    """Get set of video IDs that have been fully scanned for a channel."""
    rows = conn.execute(
        "SELECT video_id FROM videos WHERE channel_id = ? AND fully_scanned = 1",
        (channel_id,)
    ).fetchall()
    return {r["video_id"] for r in rows}


def is_known(conn, comment_id):
    """Check if a comment has already been processed."""
    row = conn.execute(
        "SELECT processed_at FROM comments WHERE comment_id = ?",
        (comment_id,)
    ).fetchone()
    return row is not None and row["processed_at"] is not None


def save_channel(conn, channel_id, channel_name, config_json=None):
    """Upsert channel info."""
    now = datetime.now(timezone.utc).isoformat()
    conn.execute("""
        INSERT INTO channels (channel_id, channel_name, config_json, added_at)
        VALUES (?, ?, ?, ?)
        ON CONFLICT(channel_id) DO UPDATE SET
            channel_name = excluded.channel_name,
            config_json = COALESCE(excluded.config_json, channels.config_json)
    """, (channel_id, channel_name, config_json, now))


def update_last_check(conn, channel_id):
    """Update the last check timestamp for a channel."""
    now = datetime.now(timezone.utc).isoformat()
    conn.execute(
        "UPDATE channels SET last_check = ? WHERE channel_id = ?",
        (now, channel_id)
    )


path: scripts/fetch_comments.py
content: #!/usr/bin/env python3
"""
Fetch YouTube comments from a video or channel's recent videos.
Uses YouTube Data API v3 (API key only, no OAuth needed for reading public comments).
"""

import argparse
import json
import os
import sys
from datetime import datetime, timezone
from urllib.request import urlopen, Request
from urllib.parse import urlencode
from urllib.error import HTTPError

[REDACTED_SECRET]"YOUTUBE_API_KEY")
BASE_URL = "https://www.googleapis.com/youtube/v3"
OUTPUT_DIR = "data/youtube-comment-moderator"


def api_get(endpoint, params):
    """Make a GET request to the YouTube API."""
    params["key"] = API_KEY
    url = f"{BASE_URL}/{endpoint}?{urlencode(params)}"
    req = Request(url, headers={"User-Agent": "YT-Moderator/1.0"})
    try:
        with urlopen(req, timeout=15) as resp:
            return json.loads(resp.read())
    except HTTPError as e:
        body = e.read().decode()
        print(f"API error {e.code}: {body[:300]}", file=sys.stderr)
        return None


def get_channel_videos(channel_id, max_videos=10):
    """Get recent video IDs from a channel."""
    # First get the uploads playlist
    data = api_get("channels", {
        "part": "contentDetails",
        "id": channel_id
    })
    if not data or not data.get("items"):
        print(f"Channel {channel_id} not found", file=sys.stderr)
        return []
    
    uploads_playlist = data["items"][0]["contentDetails"]["relatedPlaylists"]["uploads"]
    
    # Get videos from uploads playlist
    videos = []
    page_token = None
    while len(videos) < max_videos:
        params = {
            "part": "snippet",
            "playlistId": uploads_playlist,
            "maxResults": min(50, max_videos - len(videos))
        }
        if page_token:
            params["pageToken"] = page_token
        
        data = api_get("playlistItems", params)
        if not data or not data.get("items"):
            break
        
        for item in data["items"]:
            videos.append({
                "video_id": item["snippet"]["resourceId"]["videoId"],
                "title": item["snippet"]["title"],
                "published_at": item["snippet"]["publishedAt"]
            })
        
        page_token = data.get("nextPageToken")
        if not page_token:
            break
    
    return videos[:max_videos]


def get_video_info(video_id):
    """Get video title and stats."""
    data = api_get("videos", {
        "part": "snippet,statistics",
        "id": video_id
    })
    if not data or not data.get("items"):
        return None
    item = data["items"][0]
    return {
        "video_id": video_id,
        "title": item["snippet"]["title"],
        "channel_title": item["snippet"]["channelTitle"],
        "comment_count": int(item["statistics"].get("commentCount", 0)),
        "view_count": int(item["statistics"].get("viewCount", 0))
    }


def get_comments(video_id, max_comments=100):
    """Fetch comment threads for a video."""
    comments = []
    page_token = None
    
    while len(comments) < max_comments:
        params = {
            "part": "snippet",
            "videoId": video_id,
            "maxResults": min(100, max_comments - len(comments)),
            "order": "time",
            "textFormat": "plainText"
        }
        if page_token:
            params["pageToken"] = page_token
        
        data = api_get("commentThreads", params)
        if not data:
            break
        
        if "items" not in data:
            break
        
        for item in data["items"]:
            snippet = item["snippet"]["topLevelComment"]["snippet"]
            comments.append({
                "comment_id": item["id"],
                "video_id": video_id,
                "author": snippet["authorDisplayName"],
                "author_channel_id": snippet.get("authorChannelId", {}).get("value", ""),
                "text": snippet["textDisplay"],
                "like_count": snippet["likeCount"],
                "published_at": snippet["publishedAt"],
                "updated_at": snippet["updatedAt"],
                "reply_count": item["snippet"]["totalReplyCount"]
            })
        
        page_token = data.get("nextPageToken")
        if not page_token:
            break
    
    return comments[:max_comments]


def main():
    parser = argparse.ArgumentParser(description="Fetch YouTube comments")
    parser.add_argument("--video-id", help="Specific video ID to fetch comments from")
    parser.add_argument("--channel-id", help="Channel ID to fetch recent videos from")
    parser.add_argument("--max-videos", type=int, default=10, help="Max videos to check (channel mode)")
    parser.add_argument("--max-comments", type=int, default=100, help="Max comments per video")
    parser.add_argument("--output", default=None, help="Output file path")
    args = parser.parse_args()
    
    if not [REDACTED_SECRET]"Error: YOUTUBE_API_KEY not set", file=sys.stderr)
        sys.exit(1)
    
    os.makedirs(OUTPUT_DIR, exist_ok=True)
    output_path = args.output or os.path.join(OUTPUT_DIR, "comments-raw.json")
    
    all_comments = []
    
    if args.video_id:
        # Single video mode
        info = get_video_info(args.video_id)
        if info:
            print(f"Video: {info['title']} ({info['comment_count']} comments, {info['view_count']} views)")
        
        comments = get_comments(args.video_id, args.max_comments)
        all_comments.extend(comments)
        print(f"Fetched {len(comments)} comments from {args.video_id}")
    
    elif args.channel_id:
        # Channel mode
        videos = get_channel_videos(args.channel_id, args.max_videos)
        print(f"Found {len(videos)} videos from channel {args.channel_id}")
        
        for video in videos:
            comments = get_comments(video["video_id"], args.max_comments)
            all_comments.extend(comments)
            print(f"  {video['title'][:60]}... → {len(comments)} comments")
    
    else:
        print("Error: provide --video-id or --channel-id", file=sys.stderr)
        sys.exit(1)
    
    # Write output
    result = {
        "fetched_at": datetime.now(timezone.utc).isoformat(),
        "total_comments": len(all_comments),
        "comments": all_comments
    }
    
    with open(output_path, "w") as f:
        json.dump(result, f, indent=2)
    
    print(f"\nTotal: {len(all_comments)} comments → {output_path}")


if __name__ == "__main__":
    main()


path: scripts/classify_comments.py
content: #!/usr/bin/env python3
"""
Classify YouTube comments using Gemini Flash.
Categories: spam, question, praise, hate, neutral, constructive
"""

import argparse
import json
import os
import sys
import time
from datetime import datetime, timezone
from urllib.request import urlopen, Request
from urllib.error import HTTPError

GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_AI_API_KEY")
GEMINI_URL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent"
OUTPUT_DIR = "data/youtube-comment-moderator"

SYSTEM_PROMPT = """You are a YouTube comment classifier. Classify each comment into exactly one category:

- spam: Promotional links, scam offers, bot-like repetitive text, crypto/forex schemes, "check out my channel", self-promotion, phishing
- question: Genuine question about the video content, creator, topic, or requesting information
- praise: Positive feedback, compliments, encouragement, expressing enjoyment
- hate: Hateful, abusive, harassing, or threatening content
- neutral: Generic reactions ("lol", "wow", "first"), timestamps, emojis only, simple agreement
- constructive: Thoughtful criticism, suggestions, detailed feedback, corrections

Also rate confidence 0-100 and suggest an action:
- spam → delete
- question → reply
- praise → thank (or ignore if generic)
- hate → delete
- neutral → ignore
- constructive → flag_review

Respond in JSON array format. Each item: {"id": "comment_id", "category": "...", "confidence": 0-100, "action": "...", "reason": "brief reason"}"""


def classify_batch(comments, batch_size=20):
    """Classify a batch of comments using Gemini Flash."""
    if not GEMINI_API_KEY:
        print("Error: GEMINI_API_KEY not set", file=sys.stderr)
        sys.exit(1)
    
    results = []
    
    for i in range(0, len(comments), batch_size):
        batch = comments[i:i + batch_size]
        
        # Build the prompt
        comment_text = "\n".join([
            f"[{c['comment_id']}] @{c['author']}: {c['text'][:500]}"
            for c in batch
        ])
        
        payload = {
            "contents": [{
                "parts": [{"text": f"{SYSTEM_PROMPT}\n\nClassify these comments:\n\n{comment_text}"}]
            }],
            "generationConfig": {
                "temperature": 0.1,
                "maxOutputTokens": 4096,
                "responseMimeType": "application/json"
            }
        }
        
        url = f"{GEMINI_URL}?key={GEMINI_API_KEY}"
        req = Request(url, data=json.dumps(payload).encode(), headers={
            "Content-Type": "application/json"
        })
        
        try:
            with urlopen(req, timeout=30) as resp:
                data = json.loads(resp.read())
            
            text = data["candidates"][0]["content"]["parts"][0]["text"]
            batch_results = json.loads(text)
            
            if isinstance(batch_results, list):
                results.extend(batch_results)
            else:
                results.append(batch_results)
            
            print(f"  Classified batch {i//batch_size + 1}: {len(batch)} comments")
            
        except (HTTPError, json.JSONDecodeError, KeyError) as e:
            print(f"  Error on batch {i//batch_size + 1}: {e}", file=sys.stderr)
            # Mark as unclassified
            for c in batch:
                results.append({
                    "id": c["comment_id"],
                    "category": "error",
                    "confidence": 0,
                    "action": "flag_review",
                    "reason": f"Classification error: {str(e)[:100]}"
                })
        
        # Rate limit
        if i + batch_size < len(comments):
            time.sleep(1)
    
    return results


def merge_results(comments, classifications):
    """Merge classification results back into comment data."""
    class_map = {c["id"]: c for c in classifications}
    
    merged = []
    for comment in comments:
        cid = comment["comment_id"]
        cls = class_map.get(cid, {
            "category": "unclassified",
            "confidence": 0,
            "action": "flag_review",
            "reason": "Not classified"
        })
        
        merged.append({
            **comment,
            "classification": cls.get("category", "unclassified"),
            "confidence": cls.get("confidence", 0),
            "action": cls.get("action", "flag_review"),
            "reason": cls.get("reason", "")
        })
    
    return merged


def print_summary(classified):
    """Print classification summary."""
    counts = {}
    actions = {}
    for c in classified:
        cat = c["classification"]
        act = c["action"]
        counts[cat] = counts.get(cat, 0) + 1
        actions[act] = actions.get(act, 0) + 1
    
    print("\n=== CLASSIFICATION SUMMARY ===")
    print(f"Total comments: {len(classified)}")
    print("\nBy category:")
    for cat, count in sorted(counts.items(), key=lambda x: -x[1]):
        pct = count / len(classified) * 100
        print(f"  {cat:15s}: {count:4d} ({pct:.1f}%)")
    
    print("\nBy action:")
    for act, count in sorted(actions.items(), key=lambda x: -x[1]):
        print(f"  {act:15s}: {count:4d}")
    
    # Show some examples per category
    print("\n=== EXAMPLES ===")
    shown = {}
    for c in classified:
        cat = c["classification"]
        if cat not in shown:
            shown[cat] = 0
        if shown[cat] < 2:
            print(f"\n[{cat.upper()}] @{c['author']}: {c['text'][:120]}")
            print(f"  → {c['action']} (confidence: {c['confidence']}%) — {c['reason']}")
            shown[cat] += 1


def get_channel_owner_id(video_id):
    """Get the channel owner's author channel ID for a video."""
    [REDACTED_SECRET]"YOUTUBE_API_KEY")
    if not [REDACTED_SECRET] None
    from urllib.parse import urlencode as _urlencode
    url = f"https://www.googleapis.com/youtube/v3/videos?part=snippet&id={video_id}&key={api_key}"
    req = Request(url, headers={"User-Agent": "YT-Moderator/1.0"})
    try:
        with urlopen(req, timeout=15) as resp:
            data = json.loads(resp.read())
        if data.get("items"):
            return data["items"][0]["snippet"]["channelId"]
    except Exception:
        pass
    return None


def main():
    parser = argparse.ArgumentParser(description="Classify YouTube comments")
    parser.add_argument("--input", default=os.path.join(OUTPUT_DIR, "comments-raw.json"))
    parser.add_argument("--output", default=os.path.join(OUTPUT_DIR, "comments-classified.json"))
    parser.add_argument("--batch-size", type=int, default=20)
    parser.add_argument("--whitelist-owner", action="store_true", default=True,
                        help="Auto-whitelist channel owner comments (skip classification)")
    args = parser.parse_args()
    
    # Load comments
    with open(args.input) as f:
        data = json.load(f)
    
    comments = data["comments"]
    print(f"Loaded {len(comments)} comments from {args.input}")
    
    # Get channel owner IDs for whitelisting
    owner_ids = set()
    if args.whitelist_owner:
        video_ids = set(c["video_id"] for c in comments)
        for vid in video_ids:
            owner_id = get_channel_owner_id(vid)
            if owner_id:
                owner_ids.add(owner_id)
        if owner_ids:
            print(f"Whitelisted {len(owner_ids)} channel owner(s)")
    
    # Separate owner comments from others
    owner_comments = []
    classify_comments_list = []
    for c in comments:
        if c.get("author_channel_id") in owner_ids:
            owner_comments.append(c)
        else:
            classify_comments_list.append(c)
    
    if owner_comments:
        print(f"Skipping {len(owner_comments)} channel owner comments (whitelisted)")
    
    # Classify non-owner comments
    print(f"Classifying {len(classify_comments_list)} comments with Gemini Flash...")
    classifications = classify_batch(classify_comments_list, args.batch_size)
    
    # Merge — classify non-owner, mark owner as whitelisted
    classified = merge_results(classify_comments_list, classifications)
    
    # Add owner comments back as whitelisted
    for c in owner_comments:
        classified.append({
            **c,
            "classification": "owner",
            "confidence": 100,
            "action": "skip",
            "reason": "Channel owner (whitelisted)"
        })
    
    # Write output
    result = {
        "classified_at": datetime.now(timezone.utc).isoformat(),
        "source": args.input,
        "total": len(classified),
        "comments": classified
    }
    
    with open(args.output, "w") as f:
        json.dump(result, f, indent=2)
    
    print(f"\nClassified → {args.output}")
    print_summary(classified)


if __name__ == "__main__":
    main()


path: references/oauth-setup.md
content: # YouTube OAuth Setup Guide

This guide walks you through creating Google OAuth credentials so the moderator can reply to and delete comments on your YouTube channel.

**Time required:** ~10 minutes
**Cost:** Free

## Step 1: Create a Google Cloud Project

1. Go to [Google Cloud Console](https://console.cloud.google.com/)
2. Click the project dropdown (top left) → **New Project**
3. Name it something like `youtube-comment-moderator`
4. Click **Create**
5. Make sure the new project is selected in the dropdown

## Step 2: Enable the YouTube Data API

1. Go to [APIs & Services → Library](https://console.cloud.google.com/apis/library)
2. Search for **YouTube Data API v3**
3. Click it → click **Enable**

## Step 3: Configure the OAuth Consent Screen

1. Go to [APIs & Services → OAuth consent screen](https://console.cloud.google.com/apis/credentials/consent)
2. Choose **External** → click **Create**
3. Fill in:
   - **App name:** YouTube Moderator (or anything)
   - **User support email:** your email
   - **Developer contact email:** your email
4. Click **Save and Continue**
5. On the **Scopes** page, click **Add or Remove Scopes**
   - Search for `youtube.force-ssl`
   - Check the box for `YouTube Data API v3 — .../auth/youtube.force-ssl`
   - Click **Update** → **Save and Continue**
6. On the **Test users** page, click **Add Users**
   - Add the Google account email that owns your YouTube channel
   - Click **Save and Continue**
7. Click **Back to Dashboard**

> **Note:** The app stays in "Testing" mode, which is fine. Only your
> added test users can authorize. No Google review needed.

## Step 4: Create OAuth Credentials

1. Go to [APIs & Services → Credentials](https://console.cloud.google.com/apis/credentials)
2. Click **Create Credentials** → **OAuth client ID**
3. Application type: **Web application**
4. Name: `youtube-comment-moderator`
5. Under **Authorized redirect URIs**, add:
   ```
   http://127.0.0.1:8976/callback
   ```
6. Click **Create**
7. You'll see your **Client ID** and **Client Secret** — copy both

## Step 5: Get a YouTube API Key

1. Still on the [Credentials page](https://console.cloud.google.com/apis/credentials)
2. Click **Create Credentials** → **API key**
3. Copy the key
4. (Optional) Click **Restrict Key** → restrict to YouTube Data API v3

## Step 6: Set Environment Variables

Add to your `.env` file:

```bash
YOUTUBE_API_KEY=your_api_key_here
YT_MOD_CLIENT_ID=your_client_id_here
YT_MOD_CLIENT_SECRET=your_client_secret_here
```

## Step 7: Run Setup

```bash
python3 scripts/setup.py
```

The setup wizard will:
1. Detect your API key and OAuth credentials from environment
2. Ask for your YouTube channel URL or ID
3. Open a browser for OAuth authorization (or give you a URL to paste if headless)
4. Save tokens to `oauth.json`
5. Configure moderation mode, voice style, and FAQ

## Troubleshooting

**"Access blocked: This app's request is invalid" (redirect_uri_mismatch)**
→ Make sure `http://127.0.0.1:8976/callback` is in your Authorized redirect URIs (exact match, no trailing slash)

**"Access denied" or "This app is not verified"**
→ Click **Continue** (or **Advanced** → **Go to app**). This is expected for Testing mode apps.

**"The caller does not have permission" (403 on delete/reply)**
→ You must authorize with the Google account that owns the YouTube channel, not a different account.

**Token expired**
→ The moderator auto-refreshes tokens. If refresh fails, re-run `python3 scripts/setup.py` and re-authorize.


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