Skill: fredhjc/ai-twitter-digest (version 1.0.1)

--- name: ai-twitter-digest description: "Monitor a curated list of AI/tech Twitter accounts, summarize the day's key posts using an LLM, and deliver a formatted digest to a Discord channel. Use when: (1) setting up a daily automated AI news briefing from Twitter/X, (2) scheduling or running a one-shot Twitter digest to Discord, (3) managing or updating the monitored account list, summarization prompt, or delivery format." --- # AI Twitter Digest Fetches tweets from AI/tech influencers via the AISA API, summarizes in Chinese using any available LLM (Claude → OpenAI → Gemini, auto-detected), and delivers a two-part digest to Discord: - **Message 1**: Text summary with `[原文链接]` hyperlinks (no embed previews) - **Message 2**: Top 5 bare links rendered as Discord card previews ## Setup ### 1. Run the setup wizard (required before first use) ```bash python3 scripts/setup.py ``` The wizard will: - Auto-detect API keys from your environment and OpenClaw config - Prompt for any missing keys (AISA, LLM, Discord channel) - Test connectivity to AISA and your chosen LLM provider - Write a `.env` file — no manual editing needed > If you prefer manual setup, create `scripts/.env` with the following content: > > ```env > AISA_API_KEY=your_aisa_key_here > DELIVERY_CHANNEL=discord > DELIVERY_TARGET=channel:your_channel_id_here > SUMMARY_LANGUAGE=Chinese > ANTHROPIC_API_KEY= > OPENAI_API_KEY= > GEMINI_API_KEY= > # STATE_FILE=~/.ai-twitter-sent.json > # MAX_STORED_IDS=500 > # CARD_PREVIEWS=true > ``` **Required config:** | Variable | Description | |----------|-------------| | `AISA_API_KEY` | Twitter data — [aisa.one](https://aisa.one) | | `DELIVERY_CHANNEL` | `discord` / `whatsapp` / `telegram` / `slack` / `signal` | | `DELIVERY_TARGET` | Channel-specific target (see table below) | | One of: `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` / `GEMINI_API_KEY` | LLM for summarization | | `SUMMARY_LANGUAGE` | Digest language — `Chinese` (default), `English`, `Japanese`, `Korean`, `Spanish`, `French`, `German`, or any language name | **Delivery target format:** | Channel | Target format | Example | |---------|--------------|---------| | discord | `channel:<id>` | `channel:1234567890` | | whatsapp | E.164 phone or `group:<id>` | `+1234567890` | | telegram | `@username` or chat_id | `@mychannel` | | slack | `#channel-name` | `#ai-digest` | | signal | E.164 phone number | `+1234567890` | > Card link previews (`CARD_PREVIEWS`) are Discord-only and auto-disabled on other channels. ### 2. Run manually ```bash python3 scripts/monitor.py ``` ### 3. Schedule with OpenClaw cron ```bash # Daily at 3:30 PM Eastern openclaw cron add "AI Twitter Digest" "30 15 * * *" \ "python3 /path/to/ai-twitter-digest/scripts/monitor.py" \ --timezone "America/New_York" ``` ## Customizing Accounts Edit the `ACCOUNTS` list in `scripts/monitor.py`. See `references/accounts.md` for the default list and suggested additions. ## Output Format **Message 1 — Summary:** ``` 📊 **AI 每日简报** — 2026年02月26日 - Karpathy 发布了神经网络教程 | [原文链接](...) - Sam Altman 表示 GPT-5 今年发布 | [原文链接](...) ``` **Message 2 — Card previews:** ``` 🔗 **今日精选链接** https://x.com/karpathy/status/... https://x.com/sama/status/... ``` ## How It Works 1. Fetches up to 20 tweets/account (AI-keyword filtered, with all-tweets fallback) 2. Deduplicates against `STATE_FILE` (capped at `MAX_STORED_IDS`) 3. Summarizes with first available LLM provider 4. Posts two Discord messages: text digest + card preview links 5. Updates dedup state file

path: scripts/monitor.py
content: #!/usr/bin/env python3
"""
AI Twitter Digest — OpenClaw Skill
Fetches tweets from AI/tech influencers, summarizes with available LLM, posts to a chat channel.

Configuration (environment variables or .env file next to this script):
  Required:
    AISA_API_KEY          - AISA API key (aisa.one)
    DELIVERY_CHANNEL      - Channel to post to: discord | whatsapp | telegram | slack | ...
    DELIVERY_TARGET       - Channel-specific target (see below)

  DELIVERY_TARGET format per channel:
    discord               - channel:<channel_id>   e.g. channel:1234567890
    whatsapp              - phone number in E.164   e.g. +1234567890
                            or group:<group_id>
    telegram              - @username, chat_id, or group:<group_id>
    slack                 - #channel-name or channel:<channel_id>

  LLM (at least one required, tried in order):
    ANTHROPIC_API_KEY     - Claude API key
    OPENAI_API_KEY        - OpenAI API key
    GEMINI_API_KEY        - Google Gemini API key

  Optional:
    STATE_FILE            - Path to dedup state file (default: ~/.ai-twitter-sent.json)
    MAX_STORED_IDS        - Max tweet IDs to remember (default: 500)
    CARD_PREVIEWS         - Show link card previews (true/false, default: true)
                            Note: only supported on Discord; auto-disabled on other channels
    SUMMARY_LANGUAGE      - Language for the digest (default: Chinese)
                            Examples: Chinese, English, Japanese, Spanish, French, German, Korean
"""

import os
import json
import subprocess
import re
from datetime import datetime, timezone
from pathlib import Path

# ── Config ────────────────────────────────────────────────────────────────────

def load_env():
    """Load .env file from same directory as this script (if present)."""
    env_file = Path(__file__).parent / ".env"
    if env_file.exists():
        for line in env_file.read_text().splitlines():
            line = line.strip()
            if line and not line.startswith("#") and "=" in line:
                k, v = line.split("=", 1)
                os.environ.setdefault(k.strip(), v.strip())

load_env()

def require_env(key):
    val = os.environ.get(key, "").strip()
    if not val:
        raise SystemExit(f"❌ Missing required env var: {key}\n"
                         f"   Set it in your environment or create a .env file next to monitor.py")
    return val

AISA_API_KEY       = require_env("AISA_API_KEY")
DELIVERY_CHANNEL   = require_env("DELIVERY_CHANNEL")   # discord | whatsapp | telegram | slack | ...
DELIVERY_TARGET    = require_env("DELIVERY_TARGET")    # channel-specific target string
STATE_FILE         = os.environ.get("STATE_FILE",
                         str(Path.home() / ".ai-twitter-sent.json"))
MAX_STORED_IDS     = int(os.environ.get("MAX_STORED_IDS", "500"))
# Card previews only make sense on Discord (inline link embeds)
CARD_PREVIEWS      = os.environ.get("CARD_PREVIEWS", "true").lower() == "true" \
                     and DELIVERY_CHANNEL == "discord"
SUMMARY_LANGUAGE   = os.environ.get("SUMMARY_LANGUAGE", "Chinese").strip()

# ── Monitored accounts ────────────────────────────────────────────────────────

ACCOUNTS = [
    {"username": "karpathy",      "name": "Andrej Karpathy"},
    {"username": "ilyasut",       "name": "Ilya Sutskever"},
    {"username": "AndrewYNg",     "name": "Andrew Ng"},
    {"username": "lilianweng",    "name": "Lilian Weng"},
    {"username": "DrJimFan",      "name": "Jim Fan"},
    {"username": "jeremyphoward", "name": "Jeremy Howard"},
    {"username": "natolambert",   "name": "Nathan Lambert"},
    {"username": "hwchase17",     "name": "Harrison Chase"},
    {"username": "rauchg",        "name": "Guillermo Rauch"},
    {"username": "levelsio",      "name": "Pieter Levels"},
    {"username": "swyx",          "name": "swyx"},
    {"username": "sama",          "name": "Sam Altman"},
    {"username": "demishassabis", "name": "Demis Hassabis"},
    {"username": "elonmusk",      "name": "Elon Musk"},
    {"username": "jensenhuang",   "name": "Jensen Huang"},
    {"username": "JeffDean",      "name": "Jeff Dean"},
    {"username": "sundarpichai",  "name": "Sundar Pichai"},
    {"username": "satyanadella",  "name": "Satya Nadella"},
]

# ── Deduplication ─────────────────────────────────────────────────────────────

def load_sent_tweets():
    if os.path.exists(STATE_FILE):
        try:
            with open(STATE_FILE) as f:
                return set(json.load(f).get("tweet_ids", []))
        except Exception:
            pass
    return set()

def save_sent_tweets(ids):
    ids_list = sorted(ids)[-MAX_STORED_IDS:]
    with open(STATE_FILE, "w") as f:
        json.dump({"tweet_ids": ids_list,
                   "updated": datetime.now(timezone.utc).isoformat()}, f)

def filter_new(tweets, sent):
    new, new_ids = [], set()
    for t in tweets:
        tid = t.get("id", "")
        if tid and tid not in sent:
            new.append(t)
            new_ids.add(tid)
    return new, new_ids

# ── Twitter fetch ─────────────────────────────────────────────────────────────

def fetch_tweets(username, limit=20):
    import requests
    headers = {"Authorization": f"Bearer {AISA_API_KEY}"}
    base = "https://api.aisa.one/apis/v1/twitter/tweet/advanced_search"

    for query in [
        f"from:{username} AI OR LLM OR GPT OR model OR agent",
        f"from:{username}",
    ]:
        try:
            r = requests.get(base, params={"query": query, "queryType": "Latest"},
                             headers=headers, timeout=15)
            tweets = r.json().get("tweets", [])
            if tweets:
                return tweets[:limit]
        except Exception as e:
            print(f"  fetch error @{username}: {e}")
    return []

# ── LLM summarization (provider auto-detection) ───────────────────────────────

SUMMARIZE_PROMPT = """\
You are an AI industry analyst. Analyze the following tweets and summarize the most important AI/tech developments of the day.

Requirements:
1. Write the summary in {language}
2. Each item format: <content> | [Original Link](<url>)
   - Use the phrase for "Original Link" in {language} (e.g. "原文链接" in Chinese, "Source" in English, "元のリンク" in Japanese)
3. Leave one blank line between each item
4. Include AI/tech-related content: technical posts, product launches, industry opinions, company news, etc.
5. Sort by importance, max 12 items, cover at least 5 different authors
6. Output directly without any introduction or preamble

Example format:
- Karpathy shared new neural network training techniques | [Original Link](https://x.com/karpathy/status/123)

- Sam Altman says GPT-5 launching this year | [Original Link](https://x.com/sama/status/456)

Tweets:
{tweets_text}"""

def build_tweet_list(tweets):
    items = []
    for t in tweets:
        author = t.get("author", {}).get("userName", "?")
        text = t.get("text", "")
        tid = t.get("id", "")
        url = f"https://x.com/{author}/status/{tid}" if tid else ""
        if len(text) >= 20:
            items.append({"author": author, "content": text,
                          "url": url, "likes": t.get("likeCount", 0)})
    return items[:60]

def summarize(tweets):
    import requests

    items = build_tweet_list(tweets)
    if not items:
        return None

    tweets_text = "\n".join(
        f"@{t['author']}: {t['content'][:300]} | ❤️{t['likes']} | {t['url']}"
        for t in items
    )
    prompt = SUMMARIZE_PROMPT.format(tweets_text=tweets_text, language=SUMMARY_LANGUAGE)

    # Try providers in order of preference
    providers = [
        ("Anthropic Claude", _call_anthropic),
        ("OpenAI GPT-4o-mini", _call_openai),
        ("Google Gemini", _call_gemini),
    ]

    for name, fn in providers:
        result = fn(prompt)
        if result:
            print(f"  Summarized with {name}")
            return result

    print("  All LLM providers failed — using raw fallback")
    return "\n\n".join(
        f"- @{t['author']}: {t['content'][:200]} | 原文链接 {t['url']}"
        for t in items[:10]
    )

def _call_anthropic(prompt):
    import requests
    key = os.environ.get("ANTHROPIC_API_KEY", "").strip()
    if not key:
        return None
    try:
        r = requests.post(
            "https://api.anthropic.com/v1/messages",
            headers={"x-api-key": key, "anthropic-version": "2023-06-01",
                     "content-type": "application/json"},
            json={"model": "claude-haiku-4-5", "max_tokens": 1500,
                  "messages": [{"role": "user", "content": prompt}]},
            timeout=60,
        )
        if r.status_code == 200:
            return r.json()["content"][0]["text"].strip()
        print(f"  Anthropic error {r.status_code}")
    except Exception as e:
        print(f"  Anthropic exception: {e}")
    return None

def _call_openai(prompt):
    import requests
    key = os.environ.get("OPENAI_API_KEY", "").strip()
    if not key:
        return None
    try:
        r = requests.post(
            "https://api.openai.com/v1/chat/completions",
            headers={"Authorization": f"Bearer {key}",
                     "Content-Type": "application/json"},
            json={"model": "gpt-4o-mini", "max_tokens": 1500,
                  "messages": [{"role": "user", "content": prompt}]},
            timeout=60,
        )
        if r.status_code == 200:
            return r.json()["choices"][0]["message"]["content"].strip()
        print(f"  OpenAI error {r.status_code}")
    except Exception as e:
        print(f"  OpenAI exception: {e}")
    return None

def _call_gemini(prompt):
    import requests
    key = os.environ.get("GEMINI_API_KEY", "").strip()
    if not key:
        return None
    try:
        r = requests.post(
            f"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={key}",
            headers={"Content-Type": "application/json"},
            json={"contents": [{"parts": [{"text": prompt}]}]},
            timeout=60,
        )
        if r.status_code == 200:
            return r.json()["candidates"][0]["content"]["parts"][0]["text"].strip()
        print(f"  Gemini error {r.status_code}")
    except Exception as e:
        print(f"  Gemini exception: {e}")
    return None

# ── Channel delivery ──────────────────────────────────────────────────────────

def send_message(message):
    """Send a message via OpenClaw to the configured channel and target."""
    subprocess.run([
        "openclaw", "message", "send",
        "--channel", DELIVERY_CHANNEL,
        "--target", DELIVERY_TARGET,
        "--message", message,
    ])

# ── Format output ─────────────────────────────────────────────────────────────

def format_messages(summary, date_str, failed_accounts):
    """Returns (text_msg, cards_msg) tuple."""

    # Extract bare links before transformation (for card message)
    raw_links = re.findall(r'https://\S+', summary)
    raw_links = [re.sub(r'[)\].,>]+$', '', l) for l in raw_links]
    seen, top_links = set(), []
    for l in raw_links:
        if l not in seen:
            seen.add(l)
            top_links.append(l)
        if len(top_links) == 5:
            break

    # Convert "原文链接 https://..." → "[原文链接](<url>)"
    formatted = re.sub(
        r'原文链接\s+(https://\S+)',
        lambda m: f"[原文链接](<{re.sub(r'[)\\].,>]+$', '', m.group(1))}>)",
        summary,
    )
    # Suppress remaining bare links
    formatted = re.sub(r'(?<!<)(https://\S+)(?!>)', r'<\1>', formatted)

    if len(formatted) > 1800:
        formatted = formatted[:1800] + "\n\n...(截断)"

    text_msg = f"📊 **AI 每日简报** — {date_str}\n\n{formatted}"
    if failed_accounts:
        text_msg += f"\n\n⚠️ 未获取到数据: {', '.join('@' + a for a in failed_accounts)}"

    # Card preview message only for Discord
    cards_msg = None
    if CARD_PREVIEWS and top_links:
        cards_msg = "🔗 **今日精选链接**\n\n" + "\n".join(top_links)

    return text_msg, cards_msg

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

def main():
    print(f"🤖 AI Twitter Digest — {datetime.now().strftime('%Y-%m-%d %H:%M')}")

    sent_ids = load_sent_tweets()
    print(f"Loaded {len(sent_ids)} previously sent tweet IDs")

    all_tweets, failed = [], []
    for acc in ACCOUNTS:
        username = acc["username"].strip()
        print(f"Fetching @{username}...")
        tweets = fetch_tweets(username)
        if tweets:
            all_tweets.extend(tweets)
            print(f"  -> {len(tweets)} tweets")
        else:
            failed.append(username)
            print(f"  -> no tweets (skipped)")

    print(f"\nTotal: {len(all_tweets)} tweets | Failed: {len(failed)}")

    new_tweets, new_ids = filter_new(all_tweets, sent_ids)
    print(f"New after dedup: {len(new_tweets)}")

    if not new_tweets:
        send_to_discord("📊 **AI Twitter Update** — No new tweets today.")
        return

    print("\nSummarizing...")
    summary = summarize(new_tweets)

    if not summary:
        summary = "今日暂无重要AI动态。"

    lang = SUMMARY_LANGUAGE.lower()
    if "chinese" in lang or "中文" in lang:
        date_str = datetime.now().strftime('%Y年%m月%d日')
    elif "japanese" in lang or "日本語" in lang:
        date_str = datetime.now().strftime('%Y年%m月%d日')
    else:
        date_str = datetime.now().strftime('%B %d, %Y')
    text_msg, cards_msg = format_messages(summary, date_str, failed)

    send_message(text_msg)

    if cards_msg:
        import time
        time.sleep(1)
        send_message(cards_msg)

    save_sent_tweets(sent_ids | new_ids)
    print(f"✅ Done. Tracked IDs: {min(len(sent_ids | new_ids), MAX_STORED_IDS)}")

if __name__ == "__main__":
    main()


path: scripts/setup.py
content: #!/usr/bin/env python3
"""
AI Twitter Digest — Setup Wizard
Run this once after installing the skill to configure your local environment.

Usage:
    python3 scripts/setup.py
"""

import os
import json
import sys
import subprocess
from pathlib import Path

SKILL_DIR = Path(__file__).parent.parent
ENV_FILE = Path(__file__).parent / ".env"

def banner(text):
    print(f"\n{'─' * 50}")
    print(f"  {text}")
    print('─' * 50)

def ok(text):   print(f"  ✅ {text}")
def warn(text): print(f"  ⚠️  {text}")
def info(text): print(f"  ℹ️  {text}")
def ask(prompt, default=None):
    suffix = f" [{default}]" if default else ""
    val = input(f"  → {prompt}{suffix}: ").strip()
    return val or default or ""

# ── 1. Detect OpenClaw installation ──────────────────────────────────────────

def find_openclaw_auth():
    """Try to read API keys from OpenClaw's auth.json (if installed)."""
    found = {}

    # Common OpenClaw auth file locations
    candidates = [
        Path.home() / ".openclaw" / "agents" / "main" / "agent" / "auth.json",
        Path(os.environ.get("OPENCLAW_STATE_DIR", "~/.openclaw")).expanduser()
            / "agents" / "main" / "agent" / "auth.json",
    ]

    for path in candidates:
        if path.exists():
            try:
                data = json.loads(path.read_text())
                # Google/Gemini
                g = data.get("google", {})
                if g.get("key"):
                    found["GEMINI_API_KEY"] = g["key"]
                    ok(f"Found Google/Gemini key in OpenClaw config ({path})")
                # Minimax (uses Anthropic-compatible API)
                mm = data.get("minimax", {})
                if mm.get("key"):
                    found["_MINIMAX_KEY"] = mm["key"]
                # Note: Anthropic token in OpenClaw is OAuth and can't be used directly
            except Exception as e:
                warn(f"Could not read {path}: {e}")
            break

    return found

def find_env_keys():
    """Collect API keys already set in the environment."""
    found = {}
    for key in ("ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY", "AISA_API_KEY"):
        val = os.environ.get(key, "").strip()
        if val:
            found[key] = val
            ok(f"Found {key} in environment")
    return found

# ── 2. Detect available channels ─────────────────────────────────────────────

SUPPORTED_CHANNELS = {
    "discord":  {
        "label": "Discord",
        "target_hint": "channel:<channel_id>  e.g. channel:1234567890",
        "cards": True,
    },
    "whatsapp": {
        "label": "WhatsApp",
        "target_hint": "E.164 phone number  e.g. +1234567890  (or group:<group_id>)",
        "cards": False,
    },
    "telegram": {
        "label": "Telegram",
        "target_hint": "@username or chat_id  (or group:<group_id>)",
        "cards": False,
    },
    "slack": {
        "label": "Slack",
        "target_hint": "#channel-name or channel:<channel_id>",
        "cards": False,
    },
    "signal": {
        "label": "Signal",
        "target_hint": "E.164 phone number  e.g. +1234567890",
        "cards": False,
    },
}

def detect_configured_channels():
    """Ask openclaw which channels are configured."""
    configured = []
    try:
        result = subprocess.run(
            ["openclaw", "channels", "list"],
            capture_output=True, text=True, timeout=10
        )
        if result.returncode == 0:
            output = result.stdout.lower()
            for ch in SUPPORTED_CHANNELS:
                if ch in output:
                    configured.append(ch)
    except Exception:
        pass
    return configured

def list_channel_targets(channel):
    """Try to list available targets (e.g. Discord channels) via openclaw directory."""
    try:
        result = subprocess.run(
            ["openclaw", "directory", "list", "--channel", channel],
            capture_output=True, text=True, timeout=10
        )
        if result.returncode == 0 and result.stdout.strip():
            info(f"Available {channel} targets:")
            print(result.stdout[:800])
    except Exception:
        pass

# ── 3. Test LLM connectivity ──────────────────────────────────────────────────

def test_llm(config):
    """Test which LLM provider works."""
    import requests

    if config.get("ANTHROPIC_API_KEY"):
        try:
            r = requests.post(
                "https://api.anthropic.com/v1/messages",
                headers={"x-api-key": config["ANTHROPIC_API_KEY"],
                         "anthropic-version": "2023-06-01",
                         "content-type": "application/json"},
                json={"model": "claude-haiku-4-5", "max_tokens": 10,
                      "messages": [{"role": "user", "content": "hi"}]},
                timeout=10,
            )
            if r.status_code == 200:
                ok("Anthropic Claude ✓ (will be used for summarization)")
                return "anthropic"
            else:
                warn(f"Anthropic key invalid ({r.status_code})")
        except Exception as e:
            warn(f"Anthropic unreachable: {e}")

    if config.get("OPENAI_API_KEY"):
        try:
            r = requests.post(
                "https://api.openai.com/v1/chat/completions",
                headers={"Authorization": f"Bearer {config['OPENAI_API_KEY']}",
                         "Content-Type": "application/json"},
                json={"model": "gpt-4o-mini", "max_tokens": 10,
                      "messages": [{"role": "user", "content": "hi"}]},
                timeout=10,
            )
            if r.status_code == 200:
                ok("OpenAI GPT-4o-mini ✓ (will be used for summarization)")
                return "openai"
            else:
                warn(f"OpenAI key invalid ({r.status_code})")
        except Exception as e:
            warn(f"OpenAI unreachable: {e}")

    if config.get("GEMINI_API_KEY"):
        try:
            r = requests.post(
                f"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={config['GEMINI_API_KEY']}",
                headers={"Content-Type": "application/json"},
                json={"contents": [{"parts": [{"text": "hi"}]}]},
                timeout=10,
            )
            if r.status_code == 200:
                ok("Google Gemini 2.0 Flash ✓ (will be used for summarization)")
                return "gemini"
            else:
                warn(f"Gemini key invalid ({r.status_code})")
        except Exception as e:
            warn(f"Gemini unreachable: {e}")

    return None

# ── 4. Test AISA connectivity ─────────────────────────────────────────────────

def test_aisa(key):
    import requests
    try:
        r = requests.get(
            "https://api.aisa.one/apis/v1/twitter/tweet/advanced_search",
            params={"query": "from:sama AI", "queryType": "Latest"},
            headers={"Authorization": f"Bearer {key}"},
            timeout=10,
        )
        if r.status_code == 200 and r.json().get("tweets") is not None:
            ok("AISA API ✓")
            return True
        else:
            warn(f"AISA API responded with {r.status_code}")
    except Exception as e:
        warn(f"AISA unreachable: {e}")
    return False

# ── 5. Write .env ─────────────────────────────────────────────────────────────

def write_env(config):
    ch = config.get("DELIVERY_CHANNEL", "discord")
    cards_default = "true" if ch == "discord" else "false"
    lines = [
        "# AI Twitter Digest — auto-generated by setup.py",
        "# Edit manually if needed.\n",
        f"AISA_API_KEY={config.get('AISA_API_KEY', '')}",
        "",
        "# Delivery (channel + target)",
        f"DELIVERY_CHANNEL={ch}",
        f"DELIVERY_TARGET={config.get('DELIVERY_TARGET', '')}",
        f"# CARD_PREVIEWS={cards_default}  # Discord only — set false to disable link embeds",
        "",
        f"# Digest language",
        f"SUMMARY_LANGUAGE={config.get('SUMMARY_LANGUAGE', 'Chinese')}",
        "",
        "# LLM keys (script tries Anthropic → OpenAI → Gemini in order)",
        f"ANTHROPIC_API_KEY={config.get('ANTHROPIC_API_KEY', '')}",
        f"OPENAI_API_KEY={config.get('OPENAI_API_KEY', '')}",
        f"GEMINI_API_KEY={config.get('GEMINI_API_KEY', '')}",
        "",
        "# Optional",
        f"STATE_FILE={config.get('STATE_FILE', str(Path.home() / '.ai-twitter-sent.json'))}",
        "MAX_STORED_IDS=500",
    ]
    ENV_FILE.write_text("\n".join(lines))
    ok(f".env written to {ENV_FILE}")

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

def main():
    print("\n🤖 AI Twitter Digest — Setup Wizard")
    print("This will configure your local environment.\n")

    config = {}

    # Step 1: Auto-detect keys
    banner("Step 1: Detecting existing configuration")
    config.update(find_env_keys())
    config.update(find_openclaw_auth())  # OpenClaw config (non-destructive)

    # Step 2: AISA API key
    banner("Step 2: AISA API key (Twitter data)")
    if not config.get("AISA_API_KEY"):
        info("Get your key at https://aisa.one")
        config["AISA_API_KEY"] = ask("Paste your AISA_API_KEY")
    else:
        info(f"Using detected AISA key: {config['AISA_API_KEY'][:12]}...")
        override = ask("Press Enter to keep, or paste a different key")
        if override:
            config["AISA_API_KEY"] = override

    if config.get("AISA_API_KEY"):
        info("Testing AISA connectivity...")
        test_aisa(config["AISA_API_KEY"])
    else:
        warn("No AISA key — twitter fetching will not work")

    # Step 3: LLM key
    banner("Step 3: LLM API key (for summarization)")
    info("The script supports Anthropic, OpenAI, and Google Gemini.")
    info("It will use whichever key you provide (in that priority order).")

    for label, env_key, url in [
        ("Anthropic Claude", "ANTHROPIC_API_KEY", "console.anthropic.com"),
        ("OpenAI", "OPENAI_API_KEY", "platform.openai.com"),
        ("Google Gemini", "GEMINI_API_KEY", "aistudio.google.com"),
    ]:
        if not config.get(env_key):
            val = ask(f"{label} key (Enter to skip, get at {url})")
            if val:
                config[env_key] = val

    info("Testing LLM connectivity...")
    provider = test_llm(config)
    if not provider:
        warn("No working LLM found — summarization will fall back to raw tweet list")

    # Step 4: Delivery channel
    banner("Step 4: Delivery channel")
    configured = detect_configured_channels()
    if configured:
        ok(f"OpenClaw has configured channels: {', '.join(configured)}")
    else:
        info("Could not auto-detect configured channels.")

    print()
    print("  Supported channels:")
    for key, meta in SUPPORTED_CHANNELS.items():
        marker = "✅" if key in configured else "  "
        cards_note = " (supports card previews)" if meta["cards"] else ""
        print(f"  {marker} {key:10} — {meta['label']}{cards_note}")
    print()

    default_channel = configured[0] if configured else "discord"
    channel = ask("Which channel to deliver to?", default=default_channel).lower().strip()
    if channel not in SUPPORTED_CHANNELS:
        warn(f"'{channel}' is not in the supported list but will be used as-is.")
    config["DELIVERY_CHANNEL"] = channel

    ch_meta = SUPPORTED_CHANNELS.get(channel, {})
    hint = ch_meta.get("target_hint", "target identifier")
    info(f"Target format for {channel}: {hint}")
    list_channel_targets(channel)
    config["DELIVERY_TARGET"] = ask(f"Delivery target")

    # Step 5: Summary language
    banner("Step 5: Digest language")
    print("  The digest summary will be written in your chosen language.")
    print("  Examples: Chinese, English, Japanese, Korean, Spanish, French, German\n")
    lang = ask("Summary language", default="Chinese")
    config["SUMMARY_LANGUAGE"] = lang or "Chinese"
    ok(f"Digest will be delivered in: {config['SUMMARY_LANGUAGE']}")

    # Step 6: Optional config
    banner("Step 6: Optional settings")
    default_state = str(Path.home() / ".ai-twitter-sent.json")
    state = ask(f"State file path (for dedup tracking)", default=default_state)
    config["STATE_FILE"] = state or default_state

    # Step 7: Write .env
    banner("Step 7: Writing configuration")
    write_env(config)

    # Step 8: Summary
    banner("Setup complete!")
    print(f"""
  Run manually:
    python3 {SKILL_DIR}/scripts/monitor.py

  Schedule daily (OpenClaw cron):
    openclaw cron add "AI Twitter Digest" "30 15 * * *" \\
      "python3 {SKILL_DIR}/scripts/monitor.py" \\
      --timezone "America/New_York"

  Edit monitored accounts:
    {SKILL_DIR}/scripts/monitor.py  (ACCOUNTS list)
    See also: {SKILL_DIR}/references/accounts.md
""")

if __name__ == "__main__":
    main()


path: references/accounts.md
content: # Default Monitored Accounts

The default curated list in `scripts/monitor.py` covers major AI researchers, lab leaders, and tech founders.

## Current List

| Username | Name | Category |
|----------|------|----------|
| karpathy | Andrej Karpathy | AI Research |
| ilyasut | Ilya Sutskever | AI Research |
| AndrewYNg | Andrew Ng | AI Education |
| lilianweng | Lilian Weng | AI Research (OpenAI) |
| DrJimFan | Jim Fan | AI Research (NVIDIA) |
| jeremyphoward | Jeremy Howard | Fast.ai / Practical AI |
| natolambert | Nathan Lambert | RLHF / Alignment |
| hwchase17 | Harrison Chase | LangChain |
| rauchg | Guillermo Rauch | Vercel / AI Dev Tools |
| levelsio | Pieter Levels | Indie AI builders |
| swyx | swyx | AI Dev Community |
| sama | Sam Altman | OpenAI CEO |
| demishassabis | Demis Hassabis | Google DeepMind CEO |
| elonmusk | Elon Musk | xAI / Grok |
| jensenhuang | Jensen Huang | NVIDIA CEO |
| JeffDean | Jeff Dean | Google AI |
| sundarpichai | Sundar Pichai | Google CEO |
| satyanadella | Satya Nadella | Microsoft CEO |

## Suggested Additions

| Username | Name | Why |
|----------|------|-----|
| ylecun | Yann LeCun | Meta AI Chief Scientist |
| emostaque | Emad Mostaque | Stability AI founder |
| ID_AA_Carmack | John Carmack | AGI / systems thinking |
| gdb | Greg Brockman | OpenAI President |
| miramurati | Mira Murati | ex-OpenAI CTO |
| aidan_gomez | Aidan Gomez | Cohere CEO |


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