Skill: sociclaw/sociclaw (version 0.1.9)

--- name: sociclaw description: "An autonomous social media manager agent that researches, plans, and posts content." homepage: https://sociclaw.com user-invocable: true disable-model-invocation: false command-dispatch: true emoji: "🔵" requires: bins: ["python3"] env: [] config: [] requirements: bins: ["python3"] env: [] config: [] metadata: version: 0.1.9 tags: ["social-media", "x", "twitter", "automation", "content", "image-api", "trello", "notion", "credits", "persistent-memory"] --- # SociClaw Skill SociClaw is an AI agent dedicated to managing social media accounts autonomously. Drafts can be synced to Trello/Notion, and images are optional via a configured SociClaw Image API. ## Response Language - Always reply in the same language as the user's latest message. - If the user switches language, switch automatically in the next response. - Keep command names and code snippets unchanged. - Never expose internal reasoning, scratchpad, or tool planning text. - If a command is missing required inputs, ask directly for missing fields in one short message. - Always prefix every user-facing reply with: `🔵Soci:` ## Conversation UX Contract - Keep the experience conversational and practical. Do not dump a long env/token checklist upfront. - On first contact (`/sociclaw`), answer in 3 parts: - What SociClaw does (max 5 bullets), - What the user can do now (setup/plan/generate), - One clear next question. - During onboarding, ask one step at a time (or max 3 short questions in a single turn). - Ask only for required information for the current step. Do not ask optional integrations unless the user enables them. - If a command fails, respond with: - short cause, - one exact fix command, - optional next command. - Never mention unrelated tools/scripts or old project contexts from other agents. ## Soci Personality Contract - Keep a single clear voice: - Voice: direct, pragmatic, operator-like. - Cadence: concise observations, then decision, then next step. - Avoid stock corporate phrases and repetitive intros. - Brand identity handling: - Ask for or use Brand Brain (`/sociclaw briefing`) in setup flow if not present. - Prefer output that reflects the saved brand profile (`.sociclaw/company_profile.md`). - Prioritize personality traits, signature openers, visual style, and content goals over generic templates. - Content quality guardrails: - At least one sentence should be context-rich. - Use concrete examples, numbers, or operational checkpoints. - Never produce 180 posts by default; start in starter mode and expand only when user asks. - Image + brand coherence: - Always prioritize "use attached logo/image" for img2img models. - Never use one-size-fits-all image prompts. - Mention if an image was generated from the configured logo and keep it aligned to tone. ## Personality Contract (Soci) - Voice: clear, practical, senior operator. - Tone: direct, calm, no hype, no robotic verbosity. - Default response structure: - short diagnosis, - action/result, - next step. ## Command Dispatch Contract - `/sociclaw setup` maps to CLI command `setup` (alias of `setup-wizard`). - `/sociclaw reset` maps to CLI command `reset`. - `/sociclaw update` maps to CLI command `self-update` (manual instructions only, no code executed). - Keep responses user-facing and concise. Do not print hidden deliberation. - `/sociclaw` (without subcommand) should act as a welcome+help entrypoint, not as an error dump. ## Onboarding Rules (Required vs Optional) No environment variables are required for text-only planning and content generation. Required baseline inputs for the setup wizard: - provider - provider_user_id - user_niche - content_language - posting_frequency Optional, only ask if user opts in: - Trello keys and board id - Notion keys and database id - single-account image API key - advanced gateway/server variables If using provisioning flow: - Do not ask end-users for any upstream admin secret. - Keep server-side secrets out of user chat. ## Runtime Permissions & Data Handling (Transparency) Local files written by default: - `.sociclaw/runtime_config.json` (setup answers) - `.sociclaw/company_profile.md` (brand brain) - `.sociclaw/planned_posts.json` (generated plan) - `.sociclaw/memory.db` (persistent memory to reduce repetition) - `.sociclaw/generated_images/` (local backups of generated images) - `.tmp/sociclaw_state.json` (local provisioned API key + wallet address) - `.tmp/sociclaw_sessions.db` (topup sessions) Network calls (only when features are enabled/used): - Provisioning gateway (`SOCICLAW_PROVISION_URL`): sends `{provider, provider_user_id, create_api_key}` and receives an API key. - Image API (`SOCICLAW_IMAGE_API_BASE_URL`): sends prompts, model name, and optional logo/image input (as `image_url` and/or `image_data_url`) to generate images and to manage credits (topups). - Trello/Notion APIs: only if the user opted into those integrations. - Trend research: only if `XAI_API_KEY` is configured and research is enabled. Local/remote image input safety defaults: - Local image paths are restricted to allowlisted directories (default: `.sociclaw` and `.tmp`). - [REDACTED_SECRET] can widen local image input roots. - Absolute roots in [REDACTED_SECRET] are ignored unless [REDACTED_SECRET]. - Remote logo URL fetching is disabled by default ([REDACTED_SECRET]) and requires [REDACTED_SECRET] allowlist when enabled. ## Strategy: Strategic Social Media Agent (X) Role: - You are a Senior Content Strategist and Virality Engineer for X. - Objective: analyze user inputs, plan calendars, and craft content that maximizes retention quality and deep engagement. Working mental model (algorithmic brain): - Candidate sourcing: balance in-network and out-of-network discovery. - Ranking: assume the platform optimizes for predicted actions and time spent. - Filtering: avoid behavior that looks like automation spam (repetitive structures, identical cadence, aggressive tagging). Scoring priorities (practical heuristics): - Replies are the primary currency. Optimize for conversation depth, not likes. - Reposts and shares are high value. - Native media helps (image/video) compared to text-only repetition. - External links in the main post often reduce distribution. Prefer first reply, bio, or reply-based CTA. - Negative signals (mutes, blocks, reports) are catastrophic. Avoid spammy hooks and overposting. Operational imperatives: - No-link rule: never place external links in the first post. Offer alternatives. - Retention: favor threads, checklists, and short narratives that increase dwell time. - Visual diversity: vary structures and suggest a visual companion when useful. - Scheduling jitter: recommend non-round posting times (add a few minutes variance). Content creation protocol: - Hooks must be specific. Prefer numbers, specific outcomes, and clear how-to framing. - Thread structure: - Post 1: hook, no links. - Post 2: context or proof. - Body: practical steps. - Final: open question to trigger replies plus soft CTA. - Build in public: sell the story of building and the pain solved, not a generic pitch. - Radical humanization: natural language, slightly imperfect, direct. - Humor (when appropriate): relatable B2B pain points. Style and formatting: - No em dash characters. - Double spacing between paragraphs for mobile scannability. - Avoid empty corporate buzzwords. Use concrete, visual language. ## System Instructions (Strategic Content Mode) Role: - You are Soci, a Senior Content Strategist for X focused on depth of engagement and retention quality. - Optimize for meaningful interaction quality, not vanity reach. Algorithm Priorities: - Design posts to trigger replies first. Replies are weighted above likes. - Optimize reading retention and practical value. - Avoid external links in the main post when possible. Prefer link in first reply, bio, or reply-based CTA. - Recommend a visual companion for important posts to avoid repetitive text-only cadence. Writing Protocol: - Use concrete hooks, never vague slogans. - Structure for clarity: hook, context, practical value, open question + soft CTA. - For threads: post 1 (hook), post 2 (proof/context), middle (how-to), final (question to drive replies). - Use natural, human language and avoid robotic repetition. Style Rules: - Do not use em dash characters. - Keep short paragraphs with mobile-friendly spacing. - Use at most 1-2 emojis when they add meaning. - Avoid empty corporate jargon. Planning Rules: - Default planning mode is short starter plan (7-14 days). - Generate full quarter only when explicitly requested. - Start scheduling from the current date forward, never from past months. - Suggest minute jitter in posting times for natural cadence. Brand Brain: - Before generating volume, collect and apply: audience, value proposition, tone, required keywords, forbidden terms, content language, and optional brand document. - For `nano-banana` image generation, require a logo/input image URL or local path from setup or per request. Analysis Mode: - For each user request, classify the primary objective (engagement, authority, traffic, conversion). - Choose the best format and explain the reason briefly. - Return one recommended version plus one alternate variation. Quality Guardrails: - Never fabricate performance metrics. - Never promise guaranteed outcomes. - If context is missing, ask one short clarifying question before generating long output. - If an API fails, report probable root cause and the next actionable step. ## Commands ### `/sociclaw` Welcome message + quick help (recommended). If the user is not configured yet, start onboarding. ### `/sociclaw setup` Configure niche, posting frequency, content language, brand logo URL (for img2img), brand-document info, and integrations. ### `/sociclaw briefing` Capture brand context (tone, audience, keywords, forbidden terms, language, brand doc path) to improve content quality. ### `/sociclaw plan [quarter]` Generate a starter plan by default (14 days x 1 post/day). Use full quarter mode when requested (90 days x 2 posts/day). ### `/sociclaw generate` Generate today's posts (text + image prompt + image) and attach results to Trello/Notion. Each generated post is persisted to local persistent memory (`.sociclaw/memory.db`) so future planning can avoid repetitive topics. ### `/sociclaw sync` Force a sync to Trello/Notion. ### `/sociclaw status` Show plan progress and integration status. ### `/sociclaw pay` Start credits topup flow (returns deposit address and exact USDC amount). ### `/sociclaw paid <txHash>` Claim topup after transfer confirmation. ### `/sociclaw update` Print safe, manual update steps for the host. This skill build does **not** execute `git pull` or `pip install` automatically (to reduce security risk and scanner flags). ### `/sociclaw reset` Factory reset local runtime state (config, local session DB, local brand profile, local provisioned user state, persistent memory DB). Requires explicit confirmation. ## Image Generation (Optional) SociClaw supports img2img workflows (example: `nano-banana`). Those models require an input image (logo) to work. The setup wizard collects `brand_logo_url` which can be a URL or a local path (restricted by allowlists). ### Provisioning (Recommended for multi-user installs) To auto-create users + API keys without exposing your admin secret, deploy a small gateway on your backend (Vercel) and set: ```bash SOCICLAW_PROVISION_URL=https://api.sociclaw.com/api/sociclaw/provision SOCICLAW_IMAGE_API_BASE_URL=https://<your-image-api-domain> ``` The gateway keeps the upstream admin secret **server-side**. End-users never see it. Optional gateway auth (only if your gateway requires it): ```bash SOCICLAW_INTERNAL_TOKEN=your_internal_token ``` Optional hardening knobs: - `SOCICLAW_ALLOW_IMAGE_URL_INPUT` (default: false) controls remote logo URL fallback. - `SOCICLAW_ALLOWED_IMAGE_URL_HOSTS` (required if enabling remote URL input): comma-separated allowlist for remote logo fetch fallback. - `SOCICLAW_ALLOWED_IMAGE_INPUT_DIRS` (recommended): `.sociclaw,.tmp` paths allowed for local image input. - `SOCICLAW_ALLOW_ABSOLUTE_IMAGE_INPUT_DIRS` (default: false) allows absolute dir entries in `SOCICLAW_ALLOWED_IMAGE_INPUT_DIRS`. ### Single-Account Mode (Optional) If you don't want provisioning, you can run images with a single API key: ```bash SOCICLAW_IMAGE_API_BASE_URL=https://<your-image-api-domain> SOCICLAW_IMAGE_API_KEY=your_sociclaw_image_api_key SOCICLAW_IMAGE_MODEL=nano-banana ``` ## Integrations - **X API**: trend research and (optional) posting - **Trello**: kanban workflow (Backlog -> Review -> Scheduled -> Published) - **Notion**: database workflow (Draft/Review/Scheduled/Published) - **SociClaw image API**: image generation and credit management (off-chain) ## Install You can install skills by cloning this repo into your OpenClaw skills folder. Typical locations: - `~/.openclaw/skills` (global) - `<your-workspace>/skills` (workspace-local) Example: ```bash git clone https://github.com/sociclaw/sociclaw.git ~/.openclaw/skills/sociclaw ``` Install/update: ```bash git -C ~/.openclaw/skills/sociclaw pull --ff-only ``` Then start OpenClaw and run: ```text /sociclaw ``` ## Local Dev ```powershell cd D:\sociclaw python -m venv .venv .\.venv\Scripts\python.exe -m pip install -r requirements.txt .\.venv\Scripts\python.exe -m pytest -q ```

path: README.md
content: # SociClaw

[![CI](https://github.com/sociclaw/sociclaw/actions/workflows/ci.yml/badge.svg)](https://github.com/sociclaw/sociclaw/actions/workflows/ci.yml)

SociClaw is an OpenClaw skill that helps teams produce X/Twitter content automatically:

- discovers trending topics
- plans posts
- generates text + optional images
- syncs to Trello/Notion
- supports local/managed updates for running bots

**Website:** https://sociclaw.com

---

## What this repo contains

- `sociclaw/` — Python skill (core code + CLI)
- `sociclaw/templates/`, `sociclaw/fixtures/`, `sociclaw/tests/` — assets and tests

---

## Install (2 minutes)

```bash
git clone https://github.com/sociclaw/sociclaw.git ~/.openclaw/skills/sociclaw
cd ~/.openclaw/skills/sociclaw
python -m venv .venv
. .venv\Scripts\Activate.ps1
pip install -r requirements.txt
```

Then start your OpenClaw runtime and run `/sociclaw`.

For quick local tests:

```bash
python -m pytest -q
```

---

## OpenClaw setup (quick)

### 1) Quick start (text-only, no envs required)

1. Run:

```bash
/sociclaw
/sociclaw setup
```

This stores local config in `.sociclaw/` and gets you planning/generating posts right away.

### 2) Optional: enable images + credits

To generate images (and allow `/sociclaw pay` topups), set:

- `SOCICLAW_IMAGE_API_BASE_URL` (Image API base domain)
- Either:
  - `SOCICLAW_PROVISION_URL` (recommended, auto-provision per user), or
  - `SOCICLAW_IMAGE_API_KEY` (single-account mode)

Example `openclaw.json` env config:

```json
{
  "skills": {
    "entries": {
      "sociclaw": {
        "env": {
          "SOCICLAW_IMAGE_API_BASE_URL": "https://<your-image-api-domain>",
          "SOCICLAW_PROVISION_URL": "https://api.sociclaw.com/api/sociclaw/provision"
        }
      }
    }
  }
```

You will be guided through:
- provider/user id
- niche and posting frequency
- content language
- brand logo (for image generation)
- optional integrations (Trello / Notion)

### 3) Start using commands

```bash
/sociclaw setup
/sociclaw plan
/sociclaw generate
/sociclaw pay
/sociclaw paid <tx-hash>
/sociclaw status
/sociclaw reset
```

---

## Main CLI commands (optional for dev/test)

```bash
# Provision image account (via your secure API gateway)
python -m sociclaw.scripts.cli provision-image-gateway --provider telegram --provider-user-id 123

# Create/update plan
python -m sociclaw.scripts.cli plan --sync-trello --with-image

# Generate due posts and sync
python -m sociclaw.scripts.cli generate --with-image --sync-trello

# Credits topup
python -m sociclaw.scripts.cli topup-start --provider telegram --provider-user-id 123 --amount-usd 5
python -m sociclaw.scripts.cli topup-claim --provider telegram --provider-user-id 123 --tx-hash 0x...
```

---

## Environments and secrets

### Required (by feature)

- Images + credits:
  - `SOCICLAW_IMAGE_API_BASE_URL`
  - `SOCICLAW_IMAGE_MODEL` (optional, default `nano-banana`)
  - `SOCICLAW_IMAGE_URL` / `brand_logo_url` (required for img2img models like `nano-banana`)
- Auto-provisioning (recommended):
  - `SOCICLAW_PROVISION_URL`
  - `SOCICLAW_INTERNAL_TOKEN` (optional; only if your gateway requires it)
- Single-account mode (no provisioning):
  - `SOCICLAW_IMAGE_API_KEY`

### Server-only (gateway, never on user hosts)

- Your gateway will hold privileged secrets server-side (never paste into chat, never set them on user VPS/mac mini).

### Optional (feature-by-feature)

- `XAI_API_KEY` (trend research)
- `TRELLO_API_KEY`, `TRELLO_TOKEN`, `TRELLO_BOARD_ID`
- `NOTION_API_KEY`, `NOTION_DATABASE_ID`
- `SOCICLAW_ALLOW_IMAGE_URL_INPUT` (`true|false`, default false)
- `SOCICLAW_ALLOWED_IMAGE_URL_HOSTS` (comma-separated allowlist for remote logo URL fallback when enabled)
- `SOCICLAW_ALLOWED_IMAGE_INPUT_DIRS` (comma-separated allowed paths for local logo/image files, default `.sociclaw,.tmp`)
- `SOCICLAW_ALLOW_ABSOLUTE_IMAGE_INPUT_DIRS` (`true|false`, default false)

> Never paste secrets in chat. Configure secrets via environment variables on trusted hosts only.

---

## Data and behavior defaults (important)

- Default plan is starter-friendly: **14 days × 1 post/day**.
- If no logo is configured, `nano-banana` generation is skipped automatically.
- Planning starts from the current date and clamps past dates.
- Trello sync keeps board columns focused on the active window (past months are not recreated).
- A local persistent memory DB (`.sociclaw/memory.db`) tracks generated posts and improves topic variety across sessions.

---

## Update and maintenance

```bash
python -m sociclaw.scripts.cli self-update
```

This build prints **manual update steps** (it does not run `git pull` or `pip install` automatically for security).

---

## Troubleshooting (fast)

- **Provision fails with 500/secret error**: check gateway envs and deploy logs.
- **Image says model needs input image**: use `nano-banana` + valid logo URL/path in setup.
- **Plan/user seems stale**: run `/sociclaw reset --yes` and setup again.

---

## Helpful docs

- `SKILL.md` (skill contract)
- `requirements.txt` (dependencies)



path: requirements.txt
content: # SociClaw Dependencies
requests>=2.31.0
py-trello>=0.19.0
notion-client>=2.2.0
python-dotenv>=1.0.0
tweepy>=4.14.0
pytest>=7.4.0
pytest-asyncio>=0.21.0


path: sociclaw/__init__.py
content: """SociClaw skill package."""

import logging

# Centralized logging configuration
# This prevents conflicts from multiple basicConfig calls in submodules
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
    datefmt="%Y-%m-%d %H:%M:%S"
)


path: sociclaw/scripts/__init__.py
content: """SociClaw scripts package."""


path: sociclaw/scripts/brand_profile.py
content: """
Brand profile utilities for SociClaw ("Brand Brain").

Stores brand context in a local markdown file so generated content can keep
consistent tone, audience and vocabulary.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from pathlib import Path
from typing import List, Optional
import re


@dataclass
class BrandProfile:
    name: str = ""
    slogan: str = ""
    voice_tone: str = "Professional"
    content_language: str = "en"
    target_audience: str = ""
    value_proposition: str = ""
    key_themes: List[str] = field(default_factory=list)
    do_not_say: List[str] = field(default_factory=list)
    keywords: List[str] = field(default_factory=list)
    personality_traits: List[str] = field(default_factory=list)
    visual_style: str = ""
    signature_openers: List[str] = field(default_factory=list)
    content_goals: List[str] = field(default_factory=list)
    cta_style: str = "question"
    has_brand_document: bool = False
    brand_document_path: str = ""


def default_brand_profile_path() -> Path:
    repo_root = Path(__file__).resolve().parents[2]
    return repo_root / ".sociclaw" / "company_profile.md"


def load_brand_profile(path: Optional[Path] = None) -> BrandProfile:
    file_path = path or default_brand_profile_path()
    if not file_path.exists():
        return BrandProfile()

    text = file_path.read_text(encoding="utf-8")
    profile = BrandProfile()
    current_list_key: Optional[str] = None

    for raw_line in text.splitlines():
        line = raw_line.strip()
        if not line:
            continue

        structured = (
            re.match(r"^- \*\*([^*]+):\*\*\s*(.*)$", line)
            or re.match(r"^- \*\*([^*]+)\*\*:\s*(.*)$", line)
        )
        if structured:
            label = structured.group(1).strip().lower()
            value = structured.group(2).strip()
            current_list_key = None

            if label == "name":
                profile.name = value
            elif label == "slogan":
                profile.slogan = value
            elif label in {"voice/tone", "voice"}:
                profile.voice_tone = value or profile.voice_tone
            elif label == "content language":
                profile.content_language = value or profile.content_language
            elif label == "target audience":
                profile.target_audience = value
            elif label == "value proposition":
                profile.value_proposition = value
            elif label == "key themes":
                profile.key_themes = _parse_inline_list(value)
                current_list_key = "key_themes"
            elif label == "do not say":
                profile.do_not_say = _parse_inline_list(value)
                current_list_key = "do_not_say"
            elif label == "keywords":
                profile.keywords = _parse_inline_list(value)
                current_list_key = "keywords"
            elif label == "personality traits":
                profile.personality_traits = _parse_inline_list(value)
                current_list_key = "personality_traits"
            elif label == "visual style":
                profile.visual_style = value
            elif label == "signature openers":
                profile.signature_openers = _parse_inline_list(value)
                current_list_key = "signature_openers"
            elif label == "content goals":
                profile.content_goals = _parse_inline_list(value)
                current_list_key = "content_goals"
            elif label == "cta style":
                profile.cta_style = value or profile.cta_style
            elif label == "has brand document":
                profile.has_brand_document = value.lower() in {"yes", "true", "1", "y"}
            elif label == "brand document path":
                profile.brand_document_path = value
            continue

        item = re.match(r"^- (.+)$", line)
        if item and current_list_key:
            getattr(profile, current_list_key).append(item.group(1).strip())

    profile.key_themes = _dedupe(profile.key_themes)
    profile.do_not_say = _dedupe(profile.do_not_say)
    profile.keywords = _dedupe(profile.keywords)
    profile.personality_traits = _dedupe(profile.personality_traits)
    profile.signature_openers = _dedupe(profile.signature_openers)
    profile.content_goals = _dedupe(profile.content_goals)
    return profile


def save_brand_profile(profile: BrandProfile, path: Optional[Path] = None) -> Path:
    file_path = path or default_brand_profile_path()
    file_path.parent.mkdir(parents=True, exist_ok=True)

    lines = [
        "# Brand Profile",
        "",
        "## Identity",
        f"- **Name:** {profile.name}",
        f"- **Slogan:** {profile.slogan}",
        f"- **Voice/Tone:** {profile.voice_tone}",
        f"- **Content Language:** {profile.content_language}",
        "",
        "## Strategic Context",
        f"- **Target Audience:** {profile.target_audience}",
        f"- **Value Proposition:** {profile.value_proposition}",
        f"- **Key Themes:** {', '.join(profile.key_themes)}",
        "",
        "## Constraints",
        f"- **Do Not Say:** {', '.join(profile.do_not_say)}",
        f"- **Keywords:** {', '.join(profile.keywords)}",
        f"- **Personality Traits:** {', '.join(profile.personality_traits)}",
        f"- **Visual Style:** {profile.visual_style}",
        f"- **Signature Openers:** {', '.join(profile.signature_openers)}",
        f"- **Content Goals:** {', '.join(profile.content_goals)}",
        f"- **CTA Style:** {profile.cta_style}",
        f"- **Has Brand Document:** {'yes' if profile.has_brand_document else 'no'}",
        f"- **Brand Document Path:** {profile.brand_document_path}",
        "",
    ]

    file_path.write_text("\n".join(lines), encoding="utf-8")
    return file_path


def _parse_inline_list(value: str) -> List[str]:
    if not value:
        return []
    return [x.strip() for x in value.split(",") if x.strip()]


def _dedupe(values: List[str]) -> List[str]:
    out: List[str] = []
    seen = set()
    for value in values:
        key = value.strip().lower()
        if not key or key in seen:
            continue
        seen.add(key)
        out.append(value.strip())
    return out


path: sociclaw/scripts/cli.py
content: """
SociClaw CLI helpers.

This is NOT the final OpenClaw command-dispatch integration, but it provides a
reproducible way to exercise the local stack:
- Provision an image account/API key for a provider user id
- Generate an image using the provisioned API key

Examples (PowerShell):
  python -m sociclaw.scripts.cli whoami --provider telegram --provider-user-id 123

  python -m sociclaw.scripts.cli generate-image --provider telegram --provider-user-id 123 --prompt "a blue bird logo"
"""

from __future__ import annotations

import argparse
import asyncio
import json
import os
import sys
import time
from datetime import datetime
from dataclasses import asdict
from pathlib import Path
from typing import List, Optional

from .brand_profile import BrandProfile, default_brand_profile_path, load_brand_profile, save_brand_profile
from .content_generator import ContentGenerator, GeneratedPost
from .research import TrendData, TrendResearcher
from .provisioning_gateway import SociClawProvisioningGatewayClient
from .image_generator import ImageGenerator
from .local_session_store import LocalSessionStore, default_db_path
from .notion_sync import NotionSync
from .runtime_config import RuntimeConfig, RuntimeConfigStore, default_runtime_config_path
from .scheduler import PostPlan, QuarterlyScheduler
from .state_store import StateStore, default_state_path
from .memory_store import SociClawMemoryStore, default_memory_db_path
from .topup_client import TopupClient
from .trello_sync import TrelloSync
from .release_audit import scan_forbidden_terms, scan_placeholders
from .validators import validate_provider, validate_provider_user_id, validate_tx_hash


def _redact_secret([REDACTED_SECRET]) -> Optional[str]:
    if not [REDACTED_SECRET] secret
    s = str(secret)
    if len(s) <= 8:
        return "***"
    return f"{s[:4]}...{s[-4:]}"


def _validated_provider_fields(provider: str, provider_user_id: str) -> tuple[str, str]:
    try:
        p = validate_provider(provider)
        u = validate_provider_user_id(provider_user_id)
        return p, u
    except ValueError as exc:
        raise SystemExit(str(exc))


def _default_plan_path() -> Path:
    repo_root = Path(__file__).resolve().parents[2]
    return repo_root / ".sociclaw" / "planned_posts.json"


def _load_planned_posts(path: Optional[Path] = None) -> List[dict]:
    plan_path = path or _default_plan_path()
    if not plan_path.exists():
        return []
    try:
        payload = json.loads(plan_path.read_text(encoding="utf-8"))
    except Exception:
        return []
    return list(payload.get("posts", []))


def _save_planned_posts(posts: List[dict], path: Optional[Path] = None) -> Path:
    plan_path = path or _default_plan_path()
    plan_path.parent.mkdir(parents=True, exist_ok=True)
    payload = {
        "version": 1,
        "updated_at": datetime.utcnow().isoformat() + "Z",
        "posts": posts,
    }
    plan_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
    return plan_path


def _parse_posts_per_day(freq: str) -> int:
    raw = (freq or "").strip().lower()
    if not raw:
        return 1
    if "/day" in raw:
        left = raw.split("/day", 1)[0].strip()
        if left.isdigit():
            return max(1, int(left))
    digits = "".join(ch for ch in raw if ch.isdigit())
    return max(1, int(digits)) if digits else 1


def _resolve_identity_from_runtime(args: argparse.Namespace, runtime: RuntimeConfig) -> tuple[str, str]:
    provider = args.provider or runtime.provider
    provider_user_id = str(args.provider_user_id or runtime.provider_user_id or "")
    if not provider_user_id:
        raise SystemExit("Missing provider_user_id. Run /sociclaw setup first.")
    return _validated_provider_fields(provider, provider_user_id)


def _logo_directed_prompt(base_prompt: str, has_logo_input: bool) -> str:
    if not has_logo_input:
        return base_prompt
    directive = (
        "Use the attached logo image as the primary brand reference. "
        "Integrate the logo naturally, keep it recognizable, and keep visual hierarchy clean like an art director."
    )
    text = (base_prompt or "").strip()
    if not text:
        return directive
    return f"{text}. {directive}"


def _fallback_trend_data(niche: str) -> TrendData:
    topic = (niche or "AI agents").strip()
    hashtags = ["SociClaw", "AI", "Automation"]
    if topic:
        normalized = "".join(ch for ch in topic if ch.isalnum())
        if normalized:
            hashtags.append(normalized[:20])
    return TrendData(
        topics=[topic or "AI agents", "workflow automation", "creator growth"],
        formats={"thread": 10, "image": 8},
        peak_hours=[13, 17, 21],
        hashtags=hashtags,
        sample_posts=[],
    )


def _postplan_from_generated(post: dict) -> PostPlan:
    date_str = str(post.get("date") or datetime.utcnow().strftime("%Y-%m-%d"))
    try:
        date = datetime.fromisoformat(date_str)
    except ValueError:
        date = datetime.utcnow()
    return PostPlan(
        date=date,
        time=int(post.get("time") or 13),
        category=str(post.get("category") or "tips"),
        topic=str(post.get("title") or post.get("body") or "SociClaw update"),
        hashtags=list(post.get("hashtags") or []),
    )


def _generated_post_from_dict(item: dict) -> GeneratedPost:
    return GeneratedPost(
        text=str(item.get("text") or ""),
        image_prompt=str(item.get("image_prompt") or ""),
        title=str(item.get("title") or ""),
        body=str(item.get("body") or ""),
        details=str(item.get("details") or ""),
        hashtags=list(item.get("hashtags") or []),
        category=str(item.get("category") or ""),
        date=str(item.get("date") or ""),
        time=int(item.get("time") or 13),
    )


def cmd_provision_image(args: argparse.Namespace) -> int:
    raise SystemExit("Direct upstream provisioning is not supported in this skill build. Use provision-image-gateway.")


def cmd_whoami(args: argparse.Namespace) -> int:
    provider, provider_user_id = _validated_provider_fields(args.provider, str(args.provider_user_id))
    store = StateStore(Path(args.state_path) if args.state_path else None)
    u = store.get_user(provider=provider, provider_user_id=provider_user_id)
    if not u:
        print(json.dumps({"found": False, "state_path": str(store.path)}, indent=2))
        return 1

    print(
        json.dumps(
            {
                "found": True,
                "provider": u.provider,
                "provider_user_id": u.provider_user_id,
                "image_api_key": _redact_secret(u.image_api_key),
                "wallet_address": u.wallet_address,
                "created_at": u.created_at,
                "updated_at": u.updated_at,
                "state_path": str(store.path),
            },
            indent=2,
        )
    )
    return 0


def cmd_generate_image(args: argparse.Namespace) -> int:
    provider, provider_user_id = _validated_provider_fields(args.provider, str(args.provider_user_id))
    runtime_store = RuntimeConfigStore(Path(args.config_path) if args.config_path else None)
    runtime = runtime_store.load()
    store = StateStore(Path(args.state_path) if args.state_path else None)
    u = store.get_user(provider=provider, provider_user_id=provider_user_id)
    if not u or not u.image_api_key:
        raise SystemExit(
            "Missing provisioned user API key. Run: provision-image-gateway "
            f"--provider {args.provider} --provider-user-id {args.provider_user_id}"
        )

    image_url = args.image_url
    if not image_url:
        same_user = runtime.provider == provider and str(runtime.provider_user_id or "") == str(provider_user_id)
        if same_user and runtime.brand_logo_url:
            image_url = runtime.brand_logo_url

    if "nano-banana" in (args.model or "").lower() and not image_url and not args.dry_run:
        raise SystemExit(
            "Model nano-banana requires an input image. "
            "Set --image-url or configure --brand-logo-url in setup-wizard."
        )

    if args.dry_run:
        print(
            json.dumps(
                {
                    "dry_run": True,
                    "provider": args.provider,
                    "provider_user_id": provider_user_id,
                    "has_api_key": True,
                    "model": args.model,
                    "has_image_url": bool(image_url),
                    "prompt_preview": args.prompt[:120],
                },
                indent=2,
            )
        )
        return 0

    out_dir = Path(args.output_dir) if args.output_dir else None
    gen = ImageGenerator(
        [REDACTED_SECRET],
        model=args.model,
        image_url=image_url,
        output_dir=out_dir,
    )

    prompt = _logo_directed_prompt(args.prompt, bool(image_url))
    # user_address is just an identifier for the upstream image API ("user_id" field)
    r = gen.generate_image(prompt, user_address=f"{u.provider}:{u.provider_user_id}")
    print(json.dumps({"url": r.url, "local_path": str(r.local_path) if r.local_path else None}, indent=2))
    return 0


def cmd_reset(args: argparse.Namespace) -> int:
    if not args.yes and not args.dry_run:
        raise SystemExit("Refusing destructive reset without --yes. Use --dry-run to preview.")

    memory_db_path = getattr(args, "memory_db_path", None)
    target_paths = [
        Path(args.state_path) if args.state_path else default_state_path(),
        Path(args.config_path) if args.config_path else default_runtime_config_path(),
        Path(args.session_db_path) if args.session_db_path else default_db_path(),
        Path(args.brand_profile_path) if args.brand_profile_path else default_brand_profile_path(),
        Path(memory_db_path) if memory_db_path else default_memory_db_path(),
    ]

    seen = set()
    unique_paths = []
    for p in target_paths:
        key = str(p.resolve()) if p.exists() else str(p)
        if key in seen:
            continue
        seen.add(key)
        unique_paths.append(p)

    results = []
    had_error = False
    for path in unique_paths:
        entry = {
            "path": str(path),
            "existed": path.exists(),
            "removed": False,
        }
        if entry["existed"] and not args.dry_run:
            try:
                path.unlink()
                entry["removed"] = True
            except OSError as exc:
                had_error = True
                entry["error"] = str(exc)
        results.append(entry)

    output = {
        "reset": not args.dry_run,
        "dry_run": bool(args.dry_run),
        "files": results,
        "next_step": "/sociclaw setup",
    }
    print(json.dumps(output, indent=2))
    return 1 if had_error else 0


def _prompt_or_value(
    value: Optional[str],
    prompt_text: str,
    default: str = "",
    *,
    non_interactive: bool = False,
) -> str:
    if value is not None:
        return str(value).strip()
    if non_interactive:
        return default
    raw = input(f"{prompt_text} [{default}]: ").strip()
    return raw or default


def _prompt_list_or_value(
    value: Optional[str],
    prompt_text: str,
    default_items: list[str],
    *,
    non_interactive: bool = False,
) -> list[str]:
    if value is not None:
        raw = value.strip()
    else:
        if non_interactive:
            return default_items
        default_txt = ", ".join(default_items)
        raw = input(f"{prompt_text} (comma-separated) [{default_txt}]: ").strip()
        if not raw:
            return default_items
    return [x.strip() for x in raw.split(",") if x.strip()]


def _prompt_bool_or_value(value: Optional[bool], prompt_text: str, default: bool, *, non_interactive: bool = False) -> bool:
    if value is not None:
        return bool(value)
    if non_interactive:
        return default
    default_txt = "y" if default else "n"
    raw = input(f"{prompt_text} [y/n] [{default_txt}]: ").strip().lower()
    if not raw:
        return default
    return raw in {"y", "yes", "true", "1"}


def cmd_setup_wizard(args: argparse.Namespace) -> int:
    path = Path(args.config_path) if args.config_path else None
    store = RuntimeConfigStore(path)
    current = store.load()

    # OpenClaw/automation runners typically execute commands without a TTY.
    # Never block on `input()` in that environment.
    non_interactive = bool(args.non_interactive) or (not sys.stdin.isatty())

    provider = _prompt_or_value(
        args.provider,
        "Provider (telegram, etc)",
        current.provider or "telegram",
        non_interactive=non_interactive,
    )
    provider_user_id = _prompt_or_value(
        args.provider_user_id,
        "Provider user ID",
        current.provider_user_id,
        non_interactive=non_interactive,
    )
    if not provider_user_id:
        raise SystemExit("provider_user_id is required")
    provider, provider_user_id = _validated_provider_fields(provider, provider_user_id)

    cfg = RuntimeConfig(
        provider=provider,
        provider_user_id=provider_user_id,
        user_niche=_prompt_or_value(
            args.user_niche,
            "User niche",
            current.user_niche,
            non_interactive=non_interactive,
        ),
        posting_frequency=_prompt_or_value(
            args.posting_frequency,
            "Posting frequency (e.g. 2/day)",
            current.posting_frequency or "2/day",
            non_interactive=non_interactive,
        ),
        content_language=_prompt_or_value(
            args.content_language,
            "Content language (e.g. en, pt-BR)",
            current.content_language or "en",
            non_interactive=non_interactive,
        ),
        brand_logo_url=_prompt_or_value(
            args.brand_logo_url,
            "Brand/logo image URL or local path (recommended for nano-banana)",
            current.brand_logo_url,
            non_interactive=non_interactive,
        ),
        has_brand_document=_prompt_bool_or_value(
            args.has_brand_document,
            "Do you already have a full brand document",
            current.has_brand_document,
            non_interactive=non_interactive,
        ),
        brand_document_path=_prompt_or_value(
            args.brand_document_path,
            "Brand document path or URL (optional)",
            current.brand_document_path,
            non_interactive=non_interactive,
        ),
        use_trello=_prompt_bool_or_value(
            args.use_trello,
            "Use Trello integration",
            current.use_trello,
            non_interactive=non_interactive,
        ),
        use_notion=_prompt_bool_or_value(
            args.use_notion,
            "Use Notion integration",
            current.use_notion,
            non_interactive=non_interactive,
        ),
        timezone=_prompt_or_value(
            args.timezone,
            "Timezone",
            current.timezone or "UTC",
            non_interactive=non_interactive,
        ),
    )

    saved_path = store.save(cfg)
    print(json.dumps({"saved": True, "path": str(saved_path), "config": cfg.__dict__}, indent=2))
    return 0


def cmd_briefing(args: argparse.Namespace) -> int:
    profile_path = Path(args.path) if args.path else None
    current = load_brand_profile(profile_path)
    non_interactive = bool(args.non_interactive) or (not sys.stdin.isatty())

    updated = BrandProfile(
        name=_prompt_or_value(args.name, "Project name", current.name, non_interactive=non_interactive),
        slogan=_prompt_or_value(args.slogan, "Slogan", current.slogan, non_interactive=non_interactive),
        voice_tone=_prompt_or_value(args.voice_tone, "Voice/Tone", current.voice_tone, non_interactive=non_interactive),
        personality_traits=_prompt_list_or_value(
            args.personality_traits,
            "Personality traits (comma-separated)",
            current.personality_traits,
            non_interactive=non_interactive,
        ),
        visual_style=_prompt_or_value(
            args.visual_style,
            "Visual identity style",
            current.visual_style,
            non_interactive=non_interactive,
        ),
        signature_openers=_prompt_list_or_value(
            args.signature_openers,
            "Signature openers (comma-separated)",
            current.signature_openers,
            non_interactive=non_interactive,
        ),
        content_goals=_prompt_list_or_value(
            args.content_goals,
            "Content goals (comma-separated)",
            current.content_goals,
            non_interactive=non_interactive,
        ),
        cta_style=_prompt_or_value(args.cta_style, "CTA style (question, invitation, challenge)", current.cta_style, non_interactive=non_interactive),
        target_audience=_prompt_or_value(
            args.target_audience,
            "Target audience",
            current.target_audience,
            non_interactive=non_interactive,
        ),
        value_proposition=_prompt_or_value(
            args.value_proposition,
            "Value proposition",
            current.value_proposition,
            non_interactive=non_interactive,
        ),
        key_themes=_prompt_list_or_value(
            args.key_themes,
            "Key themes",
            current.key_themes,
            non_interactive=non_interactive,
        ),
        do_not_say=_prompt_list_or_value(
            args.do_not_say,
            "Do Not Say terms",
            current.do_not_say,
            non_interactive=non_interactive,
        ),
        keywords=_prompt_list_or_value(
            args.keywords,
            "Required keywords",
            current.keywords,
            non_interactive=non_interactive,
        ),
        content_language=_prompt_or_value(
            args.content_language,
            "Content language for generated posts (e.g. en, pt-BR)",
            current.content_language or "en",
            non_interactive=non_interactive,
        ),
        has_brand_document=_prompt_bool_or_value(
            args.has_brand_document,
            "Do you have a complete brand document",
            current.has_brand_document,
            non_interactive=non_interactive,
        ),
        brand_document_path=_prompt_or_value(
            args.brand_document_path,
            "Brand document path or URL (optional)",
            current.brand_document_path,
            non_interactive=non_interactive,
        ),
    )

    out_path = save_brand_profile(updated, profile_path)
    print(json.dumps({"saved": True, "path": str(out_path)}, indent=2))
    return 0


def cmd_home(args: argparse.Namespace) -> int:
    print(
        json.dumps(
            {
                "agent": "SociClaw",
                "message": "Ready. Use setup, plan, generate, status, pay, or reset.",
                "next": "Run /sociclaw setup to configure your account.",
            },
            indent=2,
        )
    )
    return 0


def cmd_plan(args: argparse.Namespace) -> int:
    runtime_store = RuntimeConfigStore(Path(args.config_path) if args.config_path else None)
    runtime = runtime_store.load()
    provider, provider_user_id = _resolve_identity_from_runtime(args, runtime)
    memory_db_path = getattr(args, "memory_db_path", None)
    memory_store = SociClawMemoryStore(Path(memory_db_path) if memory_db_path else None)

    if args.days is not None:
        days = max(1, int(args.days))
    elif args.quarter or args.full:
        days = QuarterlyScheduler.FULL_PLAN_DAYS
    else:
        days = QuarterlyScheduler.STARTER_PLAN_DAYS

    if args.posts_per_day is not None:
        posts_per_day = max(1, int(args.posts_per_day))
    elif args.quarter or args.full:
        posts_per_day = QuarterlyScheduler.FULL_PLAN_POSTS_PER_DAY
    else:
        posts_per_day = _parse_posts_per_day(runtime.posting_frequency or "1/day")

    niche = args.topic or runtime.user_niche or "AI agents"
    trend_data = _fallback_trend_data(niche)
    if os.getenv("XAI_API_KEY") and not args.skip_research:
        try:
            researcher = TrendResearcher()
            trend_data = asyncio.run(researcher.research_trends(niche, days=30))
        except Exception as exc:
            trend_data = _fallback_trend_data(niche)
            trend_data.sample_posts = [{"error": f"research_fallback: {exc}"}]

    scheduler = QuarterlyScheduler()
    avoid_topics = memory_store.get_recent_topics(provider=provider, provider_user_id=provider_user_id, limit=40)
    start_date = datetime.utcnow()
    plans = scheduler.generate_quarterly_plan(
        trend_data,
        start_date=start_date,
        days=days,
        posts_per_day=posts_per_day,
        starter_mode=False,
        avoid_topics=avoid_topics,
    )
    generator = ContentGenerator(brand_profile_path=Path(args.brand_profile_path) if args.brand_profile_path else None)
    posts = generator.generate_batch(plans)
    serialized = [asdict(p) for p in posts]
    plan_path = _save_planned_posts(serialized, Path(args.plan_path) if args.plan_path else None)

    trello_cards = 0
    notion_pages = 0
    if args.sync_trello or runtime.use_trello:
        trello = TrelloSync()
        trello.setup_board()
        for post in posts:
            trello.create_card(post)
            trello_cards += 1

    if args.sync_notion or runtime.use_notion:
        notion = NotionSync()
        for post in posts:
            notion.create_page(post, status="Draft")
            notion_pages += 1

    print(
        json.dumps(
            {
                "provider": provider,
                "provider_user_id": provider_user_id,
                "days": days,
                "posts_per_day": posts_per_day,
                "planned_posts": len(posts),
                "plan_path": str(plan_path),
                "trello_cards": trello_cards,
                "notion_pages": notion_pages,
                "starter_mode": days <= QuarterlyScheduler.STARTER_PLAN_DAYS and posts_per_day <= 1,
            },
            indent=2,
        )
    )
    return 0


def cmd_generate(args: argparse.Namespace) -> int:
    runtime_store = RuntimeConfigStore(Path(args.config_path) if args.config_path else None)
    runtime = runtime_store.load()
    provider, provider_user_id = _resolve_identity_from_runtime(args, runtime)
    plan_path = Path(args.plan_path) if args.plan_path else None
    planned = _load_planned_posts(plan_path)
    if not planned:
        raise SystemExit("No planned posts found. Run /sociclaw plan first.")

    today = datetime.utcnow().date()
    due_today = []
    future = []
    for item in planned:
        try:
            d = datetime.fromisoformat(str(item.get("date"))).date()
        except Exception:
            d = today
        if d <= today:
            due_today.append(item)
        else:
            future.append(item)

    count = max(1, int(args.count or _parse_posts_per_day(runtime.posting_frequency or "1/day")))
    source = due_today if due_today else future
    selected = source[:count]
    selected_keys = {json.dumps(item, sort_keys=True) for item in selected}
    remaining = [item for item in planned if json.dumps(item, sort_keys=True) not in selected_keys]

    state = StateStore(Path(args.state_path) if args.state_path else None)
    user = state.get_user(provider=provider, provider_user_id=provider_user_id)
    memory_db_path = getattr(args, "memory_db_path", None)
    memory = SociClawMemoryStore(Path(memory_db_path) if memory_db_path else None)

    image_model = args.image_model or os.getenv("SOCICLAW_IMAGE_MODEL") or "nano-banana"
    image_input = args.image_url or runtime.brand_logo_url or None
    model_requires_input = "nano-banana" in (image_model or "").lower()

    can_generate_image = bool(
        user
        and user.image_api_key
        and bool(args.with_image)
        and (not model_requires_input or bool(image_input))
    )

    image_generator = None
    if can_generate_image:
        image_generator = ImageGenerator(
            [REDACTED_SECRET],
            model=image_model,
            image_url=image_input,
            timeout_seconds=int(os.getenv("SOCICLAW_IMAGE_TIMEOUT_SECONDS", "120")),
            poll_interval_seconds=int(os.getenv("SOCICLAW_IMAGE_POLL_INTERVAL_SECONDS", "2")),
        )

    trello = None
    notion = None
    if args.sync_trello or runtime.use_trello:
        trello = TrelloSync()
        trello.setup_board()
    if args.sync_notion or runtime.use_notion:
        notion = NotionSync()

    results = []
    last_entry_id: Optional[int] = None
    for post_data in selected:
        generated_post = _generated_post_from_dict(post_data)
        if not generated_post.text:
            post = _postplan_from_generated(post_data)
            generated_post = ContentGenerator(
                brand_profile_path=Path(args.brand_profile_path) if args.brand_profile_path else None
            ).generate_post(post)

        image_result = None
        if image_generator:
            directed_prompt = _logo_directed_prompt(
                generated_post.image_prompt,
                bool(image_generator.image_url),
            )
            image_result = image_generator.generate_image(
                directed_prompt,
                user_address=f"{provider}:{provider_user_id}",
            )
        post_topic = str(post_data.get("topic") or runtime.user_niche or "SociClaw")
        post_category = str(post_data.get("category") or generated_post.category or "tips")
        try:
            post_date = str(post_data.get("date") or generated_post.date or datetime.utcnow().strftime("%Y-%m-%d"))
        except Exception:
            post_date = datetime.utcnow().strftime("%Y-%m-%d")
        last_entry_id = memory.upsert_generation(
            provider=provider,
            provider_user_id=provider_user_id,
            category=post_category,
            topic=post_topic,
            text=generated_post.text,
            post_date=post_date,
            has_image=bool(image_result),
            with_logo=bool(image_input),
            image_url=(image_result.url if image_result else None),
        )

        card_id = None
        if trello:
            if image_result:
                card = trello.attach_image_to_post(
                    generated_post,
                    image_url=image_result.url,
                    image_path=str(image_result.local_path) if image_result.local_path else None,
                )
            else:
                card = trello.create_card(generated_post)
            card_id = getattr(card, "id", None)

        notion_page_id = None
        if notion:
            page = notion.create_page(generated_post, status="Scheduled", image_url=(image_result.url if image_result else None))
            notion_page_id = page.get("id") if isinstance(page, dict) else None

        results.append(
            {
                "text": generated_post.text,
                "image_prompt": generated_post.image_prompt,
                "image_url": image_result.url if image_result else None,
                "image_local_path": str(image_result.local_path) if image_result and image_result.local_path else None,
                "trello_card_id": card_id,
                "notion_page_id": notion_page_id,
            }
        )

    _save_planned_posts(remaining, plan_path)
    print(
        json.dumps(
            {
                "provider": provider,
                "provider_user_id": provider_user_id,
                "generated": len(results),
                "remaining_planned_posts": len(remaining),
                "image_generation": {
                    "requested": bool(args.with_image),
                    "enabled": bool(image_generator),
                    "model": image_model,
                    "has_logo_input": bool(image_input),
                    "skipped_reason": (
                        "missing_logo_input"
                        if (bool(args.with_image) and model_requires_input and not image_input)
                        else (
                            "missing_api_key"
                            if (bool(args.with_image) and (not user or not user.image_api_key))
                            else ("disabled" if not bool(args.with_image) else None)
                        )
                    ),
                },
                "results": results,
                "memory": {
                    "last_entry_id": last_entry_id,
                    "memory_enabled": True,
                },
            },
            indent=2,
        )
    )
    return 0


def cmd_sync(args: argparse.Namespace) -> int:
    runtime_store = RuntimeConfigStore(Path(args.config_path) if args.config_path else None)
    runtime = runtime_store.load()
    plan_path = Path(args.plan_path) if args.plan_path else None
    planned = _load_planned_posts(plan_path)
    if not planned:
        print(json.dumps({"synced": 0, "message": "No planned posts to sync."}, indent=2))
        return 0

    synced_trello = 0
    synced_notion = 0
    if args.target in {"trello", "both"}:
        trello = TrelloSync()
        trello.setup_board()
        for item in planned:
            generated_post = _generated_post_from_dict(item)
            if not generated_post.text:
                post = _postplan_from_generated(item)
                generated_post = ContentGenerator(
                    brand_profile_path=Path(args.brand_profile_path) if args.brand_profile_path else None
                ).generate_post(post)
            trello.create_card(generated_post)
            synced_trello += 1

    if args.target in {"notion", "both"}:
        notion = NotionSync()
        for item in planned:
            generated_post = _generated_post_from_dict(item)
            if not generated_post.text:
                post = _postplan_from_generated(item)
                generated_post = ContentGenerator(
                    brand_profile_path=Path(args.brand_profile_path) if args.brand_profile_path else None
                ).generate_post(post)
            notion.create_page(generated_post, status="Draft")
            synced_notion += 1

    print(
        json.dumps(
            {
                "target": args.target,
                "planned_posts": len(planned),
                "synced_trello": synced_trello,
                "synced_notion": synced_notion,
                "runtime_trello_enabled": runtime.use_trello,
                "runtime_notion_enabled": runtime.use_notion,
            },
            indent=2,
        )
    )
    return 0


def cmd_status(args: argparse.Namespace) -> int:
    runtime_store = RuntimeConfigStore(Path(args.config_path) if args.config_path else None)
    runtime = runtime_store.load()
    provider, provider_user_id = _resolve_identity_from_runtime(args, runtime)
    state = StateStore(Path(args.state_path) if args.state_path else None)
    user = state.get_user(provider=provider, provider_user_id=provider_user_id)
    planned = _load_planned_posts(Path(args.plan_path) if args.plan_path else None)
    sessions = LocalSessionStore(Path(args.session_db_path) if args.session_db_path else None)
    topup = sessions.get_session(_session_user_id(provider, provider_user_id))

    print(
        json.dumps(
            {
                "provider": provider,
                "provider_user_id": provider_user_id,
                "configured": bool(runtime.provider_user_id),
                "has_image_api_key": bool(user and user.image_api_key),
                "brand_logo_configured": bool(runtime.brand_logo_url),
                "planned_posts": len(planned),
                "trello_enabled": runtime.use_trello,
                "notion_enabled": runtime.use_notion,
                "pending_topup_session": bool(topup),
            },
            indent=2,
        )
    )
    return 0


def cmd_pay(args: argparse.Namespace) -> int:
    runtime_store = RuntimeConfigStore(Path(args.config_path) if args.config_path else None)
    runtime = runtime_store.load()
    provider, provider_user_id = _resolve_identity_from_runtime(args, runtime)
    forwarded = argparse.Namespace(
        provider=provider,
        provider_user_id=provider_user_id,
        amount_usd=float(args.amount_usd),
        base_url=args.base_url,
        state_path=args.state_path,
        session_db_path=args.session_db_path,
    )
    return cmd_topup_start(forwarded)


def cmd_paid(args: argparse.Namespace) -> int:
    runtime_store = RuntimeConfigStore(Path(args.config_path) if args.config_path else None)
    runtime = runtime_store.load()
    provider, provider_user_id = _resolve_identity_from_runtime(args, runtime)
    forwarded = argparse.Namespace(
        provider=provider,
        provider_user_id=provider_user_id,
        tx_hash=args.tx_hash,
        session_id=args.session_id,
        base_url=args.base_url,
        wait=bool(args.wait),
        wait_timeout_seconds=int(args.wait_timeout_seconds),
        wait_interval_seconds=int(args.wait_interval_seconds),
        state_path=args.state_path,
        session_db_path=args.session_db_path,
    )
    return cmd_topup_claim(forwarded)


def cmd_check_env(args: argparse.Namespace) -> int:
    tmp_dir = Path(args.tmp_dir) if args.tmp_dir else Path(__file__).resolve().parents[2] / ".tmp"
    result = _collect_env_validation(tmp_dir)
    errors = result["errors"]
    warnings = result["warnings"]
    checks = result["checks"]

    status = "ok"
    rc = 0
    if errors:
        status = "error"
        rc = 1
    elif warnings:
        status = "warn"

    print(
        json.dumps(
            {
                "status": status,
                "errors": errors,
                "warnings": warnings,
                "checks": {
                    "SOCICLAW_PROVISION_URL": checks["SOCICLAW_PROVISION_URL"],
                    "SOCICLAW_IMAGE_API_KEY": checks["SOCICLAW_IMAGE_API_KEY"],
                    "XAI_API_KEY": checks["XAI_API_KEY"],
                    "TRELLO_READY": checks["TRELLO_READY"],
                    "NOTION_READY": checks["NOTION_READY"],
                    "TMP_WRITABLE": checks["TMP_WRITABLE"],
                },
            },
            indent=2,
        )
    )
    return rc


def _collect_env_validation(tmp_dir: Path) -> dict:
    def _has(name: str) -> bool:
        value = os.getenv(name)
        return bool(value and value.strip())

    errors = []
    warnings = []

    has_provision = _has("SOCICLAW_PROVISION_URL")
    has_image_api_key = _has("SOCICLAW_IMAGE_API_KEY")
    if not has_provision and not has_image_api_key:
        errors.append(
            "Set SOCICLAW_PROVISION_URL (recommended) or SOCICLAW_IMAGE_API_KEY (single-account mode)."
        )

    if not _has("XAI_API_KEY"):
        warnings.append("XAI_API_KEY not set (trend research and planning can fail).")

    has_trello_key = _has("TRELLO_API_KEY")
    has_trello_token = _has("TRELLO_TOKEN")
    if has_trello_key ^ has_trello_token:
        warnings.append("Trello env incomplete: set both TRELLO_API_KEY and TRELLO_TOKEN.")

    has_notion_key = _has("NOTION_API_KEY")
    has_notion_db = _has("NOTION_DATABASE_ID")
    if has_notion_key ^ has_notion_db:
        warnings.append("Notion env incomplete: set both NOTION_API_KEY and NOTION_DATABASE_ID.")

    tmp_ok = True
    try:
        tmp_dir.mkdir(parents=True, exist_ok=True)
        probe = tmp_dir / ".write_probe"
        probe.write_text("ok", encoding="utf-8")
        probe.unlink(missing_ok=True)
    except Exception:
        tmp_ok = False
        errors.append(f"Cannot write to temp directory: {tmp_dir}")

    return {
        "errors": errors,
        "warnings": warnings,
        "checks": {
            "SOCICLAW_PROVISION_URL": has_provision,
            "SOCICLAW_IMAGE_API_KEY": has_image_api_key,
            "XAI_API_KEY": _has("XAI_API_KEY"),
            "TRELLO_READY": has_trello_key and has_trello_token,
            "NOTION_READY": has_notion_key and has_notion_db,
            "TMP_WRITABLE": tmp_ok,
        },
    }


def _session_user_id(provider: str, provider_user_id: str) -> str:
    return f"{provider}:{provider_user_id}"


def cmd_topup_start(args: argparse.Namespace) -> int:
    provider, provider_user_id = _validated_provider_fields(args.provider, str(args.provider_user_id))
    store = StateStore(Path(args.state_path) if args.state_path else None)
    user = store.get_user(provider=provider, provider_user_id=provider_user_id)
    if not user or not user.image_api_key:
        raise SystemExit(
            "Missing user image API key. Run provisioning first with "
            f"--provider {provider} --provider-user-id {provider_user_id}."
        )

    client = TopupClient(
        [REDACTED_SECRET],
        base_url=args.base_url or None,
    )
    start = client.start_topup(expected_amount_usd=float(args.amount_usd))

    sessions = LocalSessionStore(Path(args.session_db_path) if args.session_db_path else None)
    identity = _session_user_id(provider, provider_user_id)
    sessions.upsert_session(telegram_user_id=identity, session_id=start.session_id)

    print(
        json.dumps(
            {
                "provider": provider,
                "provider_user_id": provider_user_id,
                "session_id": start.session_id,
                "deposit_address": start.deposit_address,
                "amount_usdc_exact": start.amount_usdc_exact,
                "instruction": "Send USDC and then run topup-claim with --tx-hash.",
            },
            indent=2,
        )
    )
    return 0


def cmd_topup_claim(args: argparse.Namespace) -> int:
    provider, provider_user_id = _validated_provider_fields(args.provider, str(args.provider_user_id))
    try:
        tx_hash = validate_tx_hash(args.tx_hash)
    except ValueError as exc:
        raise SystemExit(str(exc))

    store = StateStore(Path(args.state_path) if args.state_path else None)
    user = store.get_user(provider=provider, provider_user_id=provider_user_id)
    if not user or not user.image_api_key:
        raise SystemExit(
            "Missing user image API key. Run provisioning first with "
            f"--provider {provider} --provider-user-id {provider_user_id}."
        )

    sessions = LocalSessionStore(Path(args.session_db_path) if args.session_db_path else None)
    identity = _session_user_id(provider, provider_user_id)
    session_id = args.session_id
    if not session_id:
        session_record = sessions.get_session(identity)
        if not session_record:
            raise SystemExit("No stored topup session found. Run topup-start first or provide --session-id.")
        session_id = session_record.session_id

    client = TopupClient([REDACTED_SECRET], base_url=args.base_url or None)
    result = client.claim_topup(session_id=str(session_id), tx_hash=tx_hash)

    status = str(result.get("status", "")).lower().strip()
    if args.wait and status in {"pending", "confirming", "confirmed"}:
        deadline = time.time() + int(args.wait_timeout_seconds)
        poll_interval = max(1, int(args.wait_interval_seconds))
        while time.time() < deadline:
            time.sleep(poll_interval)
            status_result = client.status_topup(session_id=str(session_id))
            result = status_result
            status = str(result.get("status", "")).lower().strip()
            if status in {"credited", "failed", "expired"}:
                break

    if status == "credited":
        sessions.delete_session(identity)

    print(json.dumps(result, indent=2))
    return 0


def cmd_topup_status(args: argparse.Namespace) -> int:
    provider, provider_user_id = _validated_provider_fields(args.provider, str(args.provider_user_id))
    store = StateStore(Path(args.state_path) if args.state_path else None)
    user = store.get_user(provider=provider, provider_user_id=provider_user_id)
    if not user or not user.image_api_key:
        raise SystemExit(
            "Missing user image API key. Run provisioning first with "
            f"--provider {provider} --provider-user-id {provider_user_id}."
        )

    sessions = LocalSessionStore(Path(args.session_db_path) if args.session_db_path else None)
    identity = _session_user_id(provider, provider_user_id)
    session_id = args.session_id
    if not session_id:
        session_record = sessions.get_session(identity)
        if not session_record:
            raise SystemExit("No stored topup session found. Run topup-start first or provide --session-id.")
        session_id = session_record.session_id

    client = TopupClient([REDACTED_SECRET], base_url=args.base_url or None)
    result = client.status_topup(session_id=str(session_id))
    print(json.dumps(result, indent=2))
    return 0


def cmd_doctor(args: argparse.Namespace) -> int:
    runtime_store = RuntimeConfigStore(Path(args.config_path) if args.config_path else None)
    runtime = runtime_store.load()

    provider = args.provider or runtime.provider
    provider_user_id = str(args.provider_user_id or runtime.provider_user_id or "")
    state_store = StateStore(Path(args.state_path) if args.state_path else None)
    user = None
    if provider and provider_user_id:
        user = state_store.get_user(provider=provider, provider_user_id=provider_user_id)

    profile = load_brand_profile(Path(args.brand_profile_path) if args.brand_profile_path else None)
    sessions = LocalSessionStore(Path(args.session_db_path) if args.session_db_path else None)
    session_record = None
    if provider and provider_user_id:
        session_record = sessions.get_session(_session_user_id(provider, provider_user_id))

    payload = {
        "runtime_config_path": str(runtime_store.path),
        "provider": provider,
        "provider_user_id": provider_user_id,
        "runtime_config": runtime.__dict__,
        "brand_profile_path": str(Path(args.brand_profile_path) if args.brand_profile_path else runtime_store.path.parent / "company_profile.md"),
        "brand_profile_ready": bool(profile.name or profile.keywords or profile.value_proposition),
        "state_user_found": bool(user),
        "state_has_api_key": bool(user and user.image_api_key),
        "topup_session_found": bool(session_record),
        "env": {
            "SOCICLAW_PROVISION_URL": bool(os.getenv("SOCICLAW_PROVISION_URL")),
            "SOCICLAW_IMAGE_API_BASE_URL": bool(os.getenv("SOCICLAW_IMAGE_API_BASE_URL")),
            "XAI_API_KEY": bool(os.getenv("XAI_API_KEY")),
            "TRELLO_READY": bool(os.getenv("TRELLO_API_KEY") and os.getenv("TRELLO_TOKEN")),
            "NOTION_READY": bool(os.getenv("NOTION_API_KEY") and os.getenv("NOTION_DATABASE_ID")),
        },
    }
    print(json.dumps(payload, indent=2))
    return 0


def cmd_trello_normalize(args: argparse.Namespace) -> int:
    try:
        trello = TrelloSync(
            [REDACTED_SECRET] or os.getenv("TRELLO_API_KEY"),
            [REDACTED_SECRET] or os.getenv("TRELLO_TOKEN"),
            board_id=args.board_id or os.getenv("TRELLO_BOARD_ID"),
            request_delay_seconds=float(args.request_delay_seconds),
        )
        trello.setup_board()
        open_lists = [lst.name for lst in trello.board.list_lists("open")]
        print(
            json.dumps(
                {
                    "ok": True,
                    "board_id": trello.board_id,
                    "open_lists": open_lists,
                },
                indent=2,
            )
        )
        return 0
    except Exception as exc:
        print(json.dumps({"ok": False, "error": str(exc)}, indent=2))
        return 1


def cmd_smoke(args: argparse.Namespace) -> int:
    runtime_store = RuntimeConfigStore(Path(args.config_path) if args.config_path else None)
    runtime = runtime_store.load()

    provider = args.provider or runtime.provider
    provider_user_id = str(args.provider_user_id or runtime.provider_user_id or "")
    provider, provider_user_id = _validated_provider_fields(provider, provider_user_id)

    env_result = _collect_env_validation(Path(args.tmp_dir) if args.tmp_dir else Path(__file__).resolve().parents[2] / ".tmp")
    env_errors = env_result["errors"]
    env_warnings = env_result["warnings"]
    env_checks = env_result["checks"]

    state_store = StateStore(Path(args.state_path) if args.state_path else None)
    user = state_store.get_user(provider=provider, provider_user_id=provider_user_id)

    content_ok = False
    content_error = None
    sample_post = None
    try:
        generator = ContentGenerator(brand_profile_path=Path(args.brand_profile_path) if args.brand_profile_path else None)
        post = generator.generate_post(
            PostPlan(
                date=datetime.utcnow(),
                time=13,
                category="tips",
                topic=args.sample_topic,
                hashtags=["SociClaw", "AI", "Automation"],
            )
        )
        sample_post = {"text": post.text, "image_prompt": post.image_prompt}
        content_ok = True
    except Exception as exc:
        content_error = str(exc)

    checks = {
        "env_ok": len(env_errors) == 0,
        "user_state_found": bool(user),
        "user_has_image_api_key": bool(user and user.image_api_key),
        "content_generation_ok": content_ok,
    }

    status = "ok"
    if not checks["env_ok"] or not checks["content_generation_ok"] or not checks["user_has_image_api_key"]:
        status = "warn"

    recommendations = []
    if not checks["user_has_image_api_key"]:
        recommendations.append(
            f"Run provision-image-gateway --provider {provider} --provider-user-id {provider_user_id}."
        )
    if not checks["env_ok"]:
        recommendations.append("Run check-env and fix reported errors.")
    if not checks["content_generation_ok"]:
        recommendations.append("Run briefing to initialize brand profile context.")

    print(
        json.dumps(
            {
                "status": status,
                "provider": provider,
                "provider_user_id": provider_user_id,
                "checks": checks,
                "env_checks": env_checks,
                "env_errors": env_errors,
                "env_warnings": env_warnings,
                "content_error": content_error,
                "sample_post": sample_post,
                "recommendations": recommendations,
            },
            indent=2,
        )
    )
    return 0 if status == "ok" else 1


def cmd_e2e_staging(args: argparse.Namespace) -> int:
    runtime_store = RuntimeConfigStore(Path(args.config_path) if args.config_path else None)
    runtime = runtime_store.load()

    provider = args.provider or runtime.provider
    provider_user_id = str(args.provider_user_id or runtime.provider_user_id or "")
    provider, provider_user_id = _validated_provider_fields(provider, provider_user_id)

    steps = []

    env_result = _collect_env_validation(Path(args.tmp_dir) if args.tmp_dir else Path(__file__).resolve().parents[2] / ".tmp")
    env_ok = len(env_result["errors"]) == 0
    steps.append(
        {
            "step": "env_validation",
            "required": True,
            "ok": env_ok,
            "errors": env_result["errors"],
            "warnings": env_result["warnings"],
            "checks": env_result["checks"],
        }
    )

    state_store = StateStore(Path(args.state_path) if args.state_path else None)
    user = state_store.get_user(provider=provider, provider_user_id=provider_user_id)

    if (not user or not user.image_api_key) and args.auto_provision:
        provision_url = args.provision_url or os.getenv("SOCICLAW_PROVISION_URL")
        if provision_url:
            try:
                gateway = SociClawProvisioningGatewayClient(
                    url=provision_url,
                    internal_token=args.internal_token or os.getenv("SOCICLAW_INTERNAL_TOKEN"),
                )
                result = gateway.provision(
                    provider=provider,
                    provider_user_id=provider_user_id,
                    create_api_key=True,
                )
                state_store.upsert_user(
                    provider=provider,
                    provider_user_id=provider_user_id,
                    image_api_key=result.api_key,
                    wallet_address=result.wallet_address,
                )
                user = state_store.get_user(provider=provider, provider_user_id=provider_user_id)
                steps.append(
                    {
                        "step": "auto_provision",
                        "required": True,
                        "ok": bool(user and user.image_api_key),
                        "detail": "Provisioned user via gateway",
                    }
                )
            except Exception as exc:
                steps.append(
                    {
                        "step": "auto_provision",
                        "required": True,
                        "ok": False,
                        "error": str(exc),
                    }
                )
        else:
            steps.append(
                {
                    "step": "auto_provision",
                    "required": True,
                    "ok": False,
                    "error": "Missing provision URL for auto-provision",
                }
            )

    has_user_key = bool(user and user.image_api_key)
    steps.append(
        {
            "step": "user_state",
            "required": True,
            "ok": has_user_key,
            "detail": "Provisioned API key found in local state",
        }
    )

    content_ok = False
    content_error = None
    sample = None
    try:
        generator = ContentGenerator(brand_profile_path=Path(args.brand_profile_path) if args.brand_profile_path else None)
        post = generator.generate_post(
            PostPlan(
                date=datetime.utcnow(),
                time=13,
                category="tips",
                topic=args.sample_topic,
                hashtags=["SociClaw", "AI", "Automation"],
            )
        )
        sample = {"text": post.text, "image_prompt": post.image_prompt}
        content_ok = True
    except Exception as exc:
        content_error = str(exc)
    steps.append(
        {
            "step": "content_generation",
            "required": True,
            "ok": content_ok,
            "error": content_error,
            "sample": sample,
        }
    )

    if args.run_image:
        image_ok = False
        image_error = None
        image_result = None
        if has_user_key:
            try:
                image_url = runtime.brand_logo_url if runtime.provider == provider and str(runtime.provider_user_id or "") == str(provider_user_id) else None
                if "nano-banana" in (args.image_model or "").lower() and not image_url:
                    raise RuntimeError(
                        "Model nano-banana requires an input image. "
                        "Run setup-wizard with --brand-logo-url or test with another model."
                    )
                image_gen = ImageGenerator(
                    [REDACTED_SECRET],
                    model=args.image_model,
                    image_url=image_url,
                )
                image_prompt = _logo_directed_prompt(args.image_prompt, bool(image_url))
                image = image_gen.generate_image(image_prompt, user_address=f"{provider}:{provider_user_id}")
                image_ok = True
                image_result = {"url": image.url, "local_path": str(image.local_path) if image.local_path else None}
            except Exception as exc:
                image_error = str(exc)
        else:
            image_error = "No user API key available"
        steps.append(
            {
                "step": "image_generation",
                "required": True,
                "ok": image_ok,
                "error": image_error,
                "result": image_result,
            }
        )

    if args.run_topup:
        topup_ok = False
        topup_error = None
        topup_result = None
        if has_user_key:
            try:
                topup = TopupClient(
                    [REDACTED_SECRET],
                    base_url=args.base_url or os.getenv("SOCICLAW_IMAGE_API_BASE_URL"),
                )
                started = topup.start_topup(expected_amount_usd=float(args.topup_amount_usd))
                topup_result = {
                    "session_id": started.session_id,
                    "deposit_address": started.deposit_address,
                    "amount_usdc_exact": started.amount_usdc_exact,
                }
                if args.tx_hash:
                    claim = topup.claim_topup(session_id=started.session_id, tx_hash=args.tx_hash)
                    topup_result["claim"] = claim
                topup_ok = True
            except Exception as exc:
                topup_error = str(exc)
        else:
            topup_error = "No user API key available"
        steps.append(
            {
                "step": "topup_flow",
                "required": True,
                "ok": topup_ok,
                "error": topup_error,
                "result": topup_result,
            }
        )

    if args.run_sync:
        sync_results = []
        if args.sync_target in {"trello", "both"}:
            try:
                trello = TrelloSync(request_delay_seconds=0)
                trello.setup_board()
                sync_results.append({"target": "trello", "ok": True})
            except Exception as exc:
                sync_results.append({"target": "trello", "ok": False, "error": str(exc)})

        if args.sync_target in {"notion", "both"}:
            try:
                notion = NotionSync()
                notion.get_pending_posts()
                sync_results.append({"target": "notion", "ok": True})
            except Exception as exc:
                sync_results.append({"target": "notion", "ok": False, "error": str(exc)})

        steps.append(
            {
                "step": "sync_connectivity",
                "required": True,
                "ok": all(x.get("ok") for x in sync_results),
                "targets": sync_results,
            }
        )

    required_failures = [s for s in steps if s.get("required") and not s.get("ok")]
    status = "ok" if not required_failures else "failed"
    print(
        json.dumps(
            {
                "status": status,
                "provider": provider,
                "provider_user_id": provider_user_id,
                "steps": steps,
                "required_failures": len(required_failures),
            },
            indent=2,
        )
    )
    return 0 if status == "ok" else 1


def cmd_release_audit(args: argparse.Namespace) -> int:
    root = Path(args.root).resolve() if args.root else Path(__file__).resolve().parents[2]
    placeholder_findings = scan_placeholders(root)
    forbidden_terms = [x.strip() for x in (args.forbidden_terms or "").split(",") if x.strip()]
    forbidden_findings = scan_forbidden_terms(root, forbidden_terms)

    findings = []
    for f in placeholder_findings:
        findings.append({"kind": f.kind, "file": f.file, "line": f.line, "value": f.value})
    for f in forbidden_findings:
        findings.append({"kind": f.kind, "file": f.file, "line": f.line, "value": f.value})

    has_placeholder = bool(placeholder_findings)
    has_forbidden = bool(forbidden_findings)
    status = "ok"
    if args.strict:
        if has_placeholder or has_forbidden:
            status = "failed"
    else:
        if has_placeholder or has_forbidden:
            status = "warn"

    payload = {
        "status": status,
        "strict": bool(args.strict),
        "root": str(root),
        "counts": {
            "placeholders": len(placeholder_findings),
            "forbidden_terms": len(forbidden_findings),
            "total": len(findings),
        },
        "findings": findings[: max(1, int(args.max_findings))],
    }
    print(json.dumps(payload, indent=2))
    return 0 if status == "ok" else 1


def cmd_self_update(args: argparse.Namespace) -> int:
    repo_dir = Path(args.repo_dir).resolve() if args.repo_dir else Path(__file__).resolve().parents[2]

    # NOTE: This build intentionally does not execute any remote-update operations.
    # Self-updating (remote code + dependencies) is frequently flagged as high risk by scanners.
    # Provide explicit manual steps instead.
    print(
        json.dumps(
            {
                "ok": True,
                "updated": False,
                "stage": "manual-update-required",
                "repo_dir": str(repo_dir),
                "message": "For security, this build does not run self-update automatically. Run these commands on the host:",
                "steps": [
                    "update the repository on the host using your normal operational process",
                    "create a fresh Python environment for the updated code (or reuse a trusted one)",
                    "install/update dependencies in that environment",
                    "restart your OpenClaw service/bot",
                ],
            },
            indent=2,
        )
    )
    return 0


def build_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(prog="sociclaw", description="SociClaw CLI")
    sub = p.add_subparsers(dest="cmd")
    p.set_defaults(func=cmd_home)

    p_prox = sub.add_parser(
        "provision-image-gateway",
        help="Provision via your backend gateway (recommended; keeps the admin secret server-side)",
    )
    p_prox.add_argument("--provider", required=True, help="e.g. telegram")
    p_prox.add_argument("--provider-user-id", required=True, help="e.g. Telegram user id")
    p_prox.add_argument(
        "--url",
        default=os.getenv("SOCICLAW_PROVISION_URL"),
        help="Gateway URL (e.g. https://api.sociclaw.com/api/sociclaw/provision)",
    )
    p_prox.add_argument(
        "--internal-token",
        default=os.getenv("SOCICLAW_INTERNAL_TOKEN"),
        help="Optional. Use only if your gateway requires server-side auth.",
    )
    p_prox.add_argument("--state-path", default=None, help="Override state path (defaults to .tmp/sociclaw_state.json)")

    def cmd_gateway(args: argparse.Namespace) -> int:
        provider, provider_user_id = _validated_provider_fields(args.provider, str(args.provider_user_id))
        if not args.url:
            raise SystemExit("Missing gateway --url or env SOCICLAW_PROVISION_URL")

        client = SociClawProvisioningGatewayClient(
            url=args.url,
            internal_token=args.internal_token or None,
        )
        res = client.provision(
            provider=provider,
            provider_user_id=provider_user_id,
            create_api_key=True,
        )

        store = StateStore(Path(args.state_path) if args.state_path else None)
        store.upsert_user(
            provider=res.provider,
            provider_user_id=res.provider_user_id,
            image_api_key=res.api_key,
            wallet_address=res.wallet_address,
        )

        print(
            json.dumps(
                {
                    "provider": res.provider,
                    "provider_user_id": res.provider_user_id,
                    "api_key": _redact_secret(res.api_key),
                    "wallet_address": res.wallet_address,
                    "state_path": str(store.path),
                },
                indent=2,
            )
        )
        return 0

    p_prox.set_defaults(func=cmd_gateway)

    p_who = sub.add_parser("whoami", help="Show locally stored provisioned info for a provider user id")
    p_who.add_argument("--provider", required=True)
    p_who.add_argument("--provider-user-id", required=True)
    p_who.add_argument("--state-path", default=None)
    p_who.set_defaults(func=cmd_whoami)

    p_img = sub.add_parser("generate-image", help="Generate an image using the provisioned API key")
    p_img.add_argument("--provider", required=True)
    p_img.add_argument("--provider-user-id", required=True)
    p_img.add_argument("--prompt", required=True)
    p_img.add_argument(
        "--model",
        default=(
            os.getenv("SOCICLAW_IMAGE_MODEL")
            or "nano-banana"
        ),
    )
    p_img.add_argument("--output-dir", default=None)
    p_img.add_argument("--state-path", default=None)
    p_img.add_argument("--config-path", default=None, help="Optional runtime config path")
    p_img.add_argument("--image-url", default=None, help="Optional input image URL or local path (required for img2img models)")
    p_img.add_argument("--dry-run", action="store_true", help="Validate preconditions without calling image API")
    p_img.set_defaults(func=cmd_generate_image)

    p_brief = sub.add_parser(
        "briefing",
        help="Create/update Brand Brain profile used by content generation",
    )
    p_brief.add_argument("--path", default=None, help="Optional path for company_profile.md")
    p_brief.add_argument("--name", default=None)
    p_brief.add_argument("--slogan", default=None)
    p_brief.add_argument("--voice-tone", default=None)
    p_brief.add_argument("--personality-traits", default=None, help="Comma-separated list")
    p_brief.add_argument("--visual-style", default=None)
    p_brief.add_argument("--signature-openers", default=None, help="Comma-separated list of preferred opening phrases")
    p_brief.add_argument("--content-goals", default=None, help="Comma-separated list of outcomes this account should drive")
    p_brief.add_argument("--cta-style", default=None, help="Default CTA style: question|invitation|challenge")
    p_brief.add_argument("--target-audience", default=None)
    p_brief.add_argument("--value-proposition", default=None)
    p_brief.add_argument("--key-themes", default=None, help="Comma-separated list")
    p_brief.add_argument("--do-not-say", default=None, help="Comma-separated list")
    p_brief.add_argument("--keywords", default=None, help="Comma-separated list")
    p_brief.add_argument("--content-language", default=None, help="Content language (e.g. en, pt-BR)")
    p_brief.add_argument("--has-brand-document", action="store_true", default=None)
    p_brief.add_argument("--brand-document-path", default=None)
    p_brief.add_argument("--non-interactive", action="store_true", help="Do not prompt for missing values")
    p_brief.set_defaults(func=cmd_briefing)

    def _add_setup_args(parser_obj: argparse.ArgumentParser) -> None:
        parser_obj.add_argument("--config-path", default=None, help="Optional runtime config path")
        parser_obj.add_argument("--provider", default=None)
        parser_obj.add_argument("--provider-user-id", default=None)
        parser_obj.add_argument("--user-niche", default=None)
        parser_obj.add_argument("--posting-frequency", default=None)
        parser_obj.add_argument("--content-language", default=None)
        parser_obj.add_argument("--brand-logo-url", default=None)
        parser_obj.add_argument("--has-brand-document", action="store_true", default=None)
        parser_obj.add_argument("--brand-document-path", default=None)
        parser_obj.add_argument("--use-trello", action="store_true", default=None)
        parser_obj.add_argument("--use-notion", action="store_true", default=None)
        parser_obj.add_argument("--timezone", default=None)
        parser_obj.add_argument("--non-interactive", action="store_true", help="Do not prompt; keep defaults for missing values")
        parser_obj.set_defaults(func=cmd_setup_wizard)

    p_setup_wizard = sub.add_parser("setup-wizard", help="Interactive local setup wizard (provider/user defaults)")
    _add_setup_args(p_setup_wizard)

    p_setup = sub.add_parser("setup", help="Alias for setup-wizard")
    _add_setup_args(p_setup)

    p_plan = sub.add_parser("plan", help="Generate and store a content plan")
    p_plan.add_argument("--provider", default=None)
    p_plan.add_argument("--provider-user-id", default=None)
    p_plan.add_argument("--config-path", default=None, help="Optional runtime config path")
    p_plan.add_argument("--brand-profile-path", default=None)
    p_plan.add_argument("--plan-path", default=None)
    p_plan.add_argument("--topic", default=None, help="Override niche/topic for planning")
    p_plan.add_argument("--quarter", default=None, help="Quarter label (e.g. Q1) to force full planning mode")
    p_plan.add_argument("--full", action="store_true", help="Generate full quarterly volume")
    p_plan.add_argument("--days", type=int, default=None)
    p_plan.add_argument("--posts-per-day", type=int, default=None)
    p_plan.add_argument("--skip-research", action="store_true", help="Skip X research and use local fallback topics")
    p_plan.add_argument("--memory-db-path", default=None, help="Optional persistent memory DB path")
    p_plan.add_argument("--sync-trello", action="store_true")
    p_plan.add_argument("--sync-notion", action="store_true")
    p_plan.set_defaults(func=cmd_plan)

    p_generate = sub.add_parser("generate", help="Generate due posts and optionally images, then sync")
    p_generate.add_argument("--provider", default=None)
    p_generate.add_argument("--provider-user-id", default=None)
    p_generate.add_argument("--config-path", default=None, help="Optional runtime config path")
    p_generate.add_argument("--state-path", default=None, help="Optional state store path")
    p_generate.add_argument("--brand-profile-path", default=None)
    p_generate.add_argument("--plan-path", default=None)
    p_generate.add_argument("--count", type=int, default=None)
    p_generate.add_argument("--with-image", dest="with_image", action="store_true", default=True, help="Attempt image generation for selected posts (default on)")
    p_generate.add_argument("--no-image", dest="with_image", action="store_false", help="Skip image generation")
    p_generate.add_argument("--image-model", default=None)
    p_generate.add_argument("--image-url", default=None, help="Override logo/input image URL or local path")
    p_generate.add_argument("--memory-db-path", default=None, help="Optional persistent memory DB path")
    p_generate.add_argument("--sync-trello", action="store_true")
    p_generate.add_argument("--sync-notion", action="store_true")
    p_generate.set_defaults(func=cmd_generate)

    p_sync = sub.add_parser("sync", help="Sync stored planned posts to Trello/Notion")
    p_sync.add_argument("--config-path", default=None, help="Optional runtime config path")
    p_sync.add_argument("--brand-profile-path", default=None)
    p_sync.add_argument("--plan-path", default=None)
    p_sync.add_argument("--target", choices=["trello", "notion", "both"], default="both")
    p_sync.set_defaults(func=cmd_sync)

    p_status = sub.add_parser("status", help="Show local readiness and queue status")
    p_status.add_argument("--provider", default=None)
    p_status.add_argument("--provider-user-id", default=None)
    p_status.add_argument("--config-path", default=None)
    p_status.add_argument("--state-path", default=None)
    p_status.add_argument("--session-db-path", default=None)
    p_status.add_argument("--plan-path", default=None)
    p_status.set_defaults(func=cmd_status)

    p_reset = sub.add_parser("reset", help="Reset local SociClaw state/config and restart onboarding")
    p_reset.add_argument("--yes", action="store_true", help="Confirm destructive reset")
    p_reset.add_argument("--dry-run", action="store_true", help="Show what would be removed")
    p_reset.add_argument("--state-path", default=None, help="Optional state store path")
    p_reset.add_argument("--config-path", default=None, help="Optional runtime config path")
    p_reset.add_argument("--session-db-path", default=None, help="Optional topup session DB path")
    p_reset.add_argument("--brand-profile-path", default=None, help="Optional brand profile path")
    p_reset.add_argument("--memory-db-path", default=None, help="Optional persistent memory DB path")
    p_reset.set_defaults(func=cmd_reset)

    p_check = sub.add_parser("check-env", help="Preflight check for required env/settings")
    p_check.add_argument("--tmp-dir", default=None, help="Optional writable temp directory to validate")
    p_check.set_defaults(func=cmd_check_env)

    p_topup_start = sub.add_parser("topup-start", help="Start credits topup (returns deposit address + exact amount)")
    p_topup_start.add_argument("--provider", required=True, help="e.g. telegram")
    p_topup_start.add_argument("--provider-user-id", required=True, help="User id in the provider")
    p_topup_start.add_argument("--amount-usd", required=True, type=float, help="Expected USD amount (e.g. 5)")
    p_topup_start.add_argument("--base-url", default=os.getenv("SOCICLAW_IMAGE_API_BASE_URL"), help="Image API base URL")
    p_topup_start.add_argument("--state-path", default=None, help="Override state store path")
    p_topup_start.add_argument("--session-db-path", default=None, help="Override topup session DB path")
    p_topup_start.set_defaults(func=cmd_topup_start)

    p_pay = sub.add_parser("pay", help="Friendly alias for topup-start using configured provider/user")
    p_pay.add_argument("--provider", default=None)
    p_pay.add_argument("--provider-user-id", default=None)
    p_pay.add_argument("--config-path", default=None)
    p_pay.add_argument("--state-path", default=None)
    p_pay.add_argument("--session-db-path", default=None)
    p_pay.add_argument("--amount-usd", default=5.0, type=float)
    p_pay.add_argument("--base-url", default=os.getenv("SOCICLAW_IMAGE_API_BASE_URL"), help="Image API base URL")
    p_pay.set_defaults(func=cmd_pay)

    p_topup_claim = sub.add_parser("topup-claim", help="Claim a topup by txHash")
    p_topup_claim.add_argument("--provider", required=True, help="e.g. telegram")
    p_topup_claim.add_argument("--provider-user-id", required=True, help="User id in the provider")
    p_topup_claim.add_argument("--tx-hash", required=True, help="Base tx hash")
    p_topup_claim.add_argument("--session-id", default=None, help="Optional explicit session id")
    p_topup_claim.add_argument("--base-url", default=os.getenv("SOCICLAW_IMAGE_API_BASE_URL"), help="Image API base URL")
    p_topup_claim.add_argument("--wait", action="store_true", help="Poll status until terminal state or timeout")
    p_topup_claim.add_argument(
        "--wait-timeout-seconds",
        default=int(os.getenv("SOCICLAW_TOPUP_WAIT_TIMEOUT_SECONDS", "120")),
        type=int,
        help="Max time to wait when --wait is enabled",
    )
    p_topup_claim.add_argument(
        "--wait-interval-seconds",
        default=int(os.getenv("SOCICLAW_TOPUP_WAIT_INTERVAL_SECONDS", "5")),
        type=int,
        help="Polling interval when --wait is enabled",
    )
    p_topup_claim.add_argument("--state-path", default=None, help="Override state store path")
    p_topup_claim.add_argument("--session-db-path", default=None, help="Override topup session DB path")
    p_topup_claim.set_defaults(func=cmd_topup_claim)

    p_paid = sub.add_parser("paid", help="Friendly alias for topup-claim using configured provider/user")
    p_paid.add_argument("--provider", default=None)
    p_paid.add_argument("--provider-user-id", default=None)
    p_paid.add_argument("--config-path", default=None)
    p_paid.add_argument("--state-path", default=None)
    p_paid.add_argument("--session-db-path", default=None)
    p_paid.add_argument("--tx-hash", required=True, help="Base tx hash")
    p_paid.add_argument("--session-id", default=None)
    p_paid.add_argument("--base-url", default=os.getenv("SOCICLAW_IMAGE_API_BASE_URL"), help="Image API base URL")
    p_paid.add_argument("--wait", action="store_true")
    p_paid.add_argument("--wait-timeout-seconds", default=int(os.getenv([REDACTED_SECRET], "120")), type=int)
    p_paid.add_argument("--wait-interval-seconds", default=int(os.getenv([REDACTED_SECRET], "5")), type=int)
    p_paid.set_defaults(func=cmd_paid)

    p_topup_status = sub.add_parser("topup-status", help="Get topup session status")
    p_topup_status.add_argument("--provider", required=True, help="e.g. telegram")
    p_topup_status.add_argument("--provider-user-id", required=True, help="User id in the provider")
    p_topup_status.add_argument("--session-id", default=None, help="Optional explicit session id")
    p_topup_status.add_argument("--base-url", default=os.getenv("SOCICLAW_IMAGE_API_BASE_URL"), help="Image API base URL")
    p_topup_status.add_argument("--state-path", default=None, help="Override state store path")
    p_topup_status.add_argument("--session-db-path", default=None, help="Override topup session DB path")
    p_topup_status.set_defaults(func=cmd_topup_status)

    p_doctor = sub.add_parser("doctor", help="Show local readiness summary (config/state/brand/env)")
    p_doctor.add_argument("--provider", default=None)
    p_doctor.add_argument("--provider-user-id", default=None)
    p_doctor.add_argument("--config-path", default=None, help="Optional runtime config path")
    p_doctor.add_argument("--state-path", default=None, help="Optional state store path")
    p_doctor.add_argument("--session-db-path", default=None, help="Optional session DB path")
    p_doctor.add_argument("--brand-profile-path", default=None, help="Optional brand profile path")
    p_doctor.set_defaults(func=cmd_doctor)

    p_trello_norm = sub.add_parser(
        "trello-normalize",
        help="Normalize Trello board lists (archive stale lists and move active lists to the left)",
    )
    p_trello_norm.add_argument("--api-key", default=None)
    p_trello_norm.add_argument("--token", default=None)
    p_trello_norm.add_argument("--board-id", default=None)
    p_trello_norm.add_argument("--request-delay-seconds", default=0.0, type=float)
    p_trello_norm.set_defaults(func=cmd_trello_normalize)

    p_smoke = sub.add_parser("smoke", help="Quick local smoke test (env + state + content generation)")
    p_smoke.add_argument("--provider", default=None)
    p_smoke.add_argument("--provider-user-id", default=None)
    p_smoke.add_argument("--config-path", default=None, help="Optional runtime config path")
    p_smoke.add_argument("--state-path", default=None, help="Optional state store path")
    p_smoke.add_argument("--brand-profile-path", default=None, help="Optional brand profile path")
    p_smoke.add_argument("--tmp-dir", default=None, help="Optional tmp directory for env validation")
    p_smoke.add_argument("--sample-topic", default="AI content systems", help="Sample topic for generation check")
    p_smoke.set_defaults(func=cmd_smoke)

    p_e2e = sub.add_parser("e2e-staging", help="End-to-end staging run with pass/fail summary")
    p_e2e.add_argument("--provider", default=None)
    p_e2e.add_argument("--provider-user-id", default=None)
    p_e2e.add_argument("--config-path", default=None)
    p_e2e.add_argument("--state-path", default=None)
    p_e2e.add_argument("--brand-profile-path", default=None)
    p_e2e.add_argument("--tmp-dir", default=None)
    p_e2e.add_argument("--sample-topic", default="AI content systems")
    p_e2e.add_argument("--auto-provision", action="store_true", help="Provision user if local state key is missing")
    p_e2e.add_argument("--provision-url", default=os.getenv("SOCICLAW_PROVISION_URL"))
    p_e2e.add_argument("--internal-token", default=os.getenv("SOCICLAW_INTERNAL_TOKEN"))
    p_e2e.add_argument("--run-image", action="store_true", help="Run real image generation")
    p_e2e.add_argument("--image-prompt", default="SociClaw blue abstract brand icon")
    p_e2e.add_argument("--image-model", default=os.getenv("SOCICLAW_IMAGE_MODEL") or "nano-banana")
    p_e2e.add_argument("--run-topup", action="store_true", help="Run topup start (and optional claim)")
    p_e2e.add_argument("--topup-amount-usd", default=5.0, type=float)
    p_e2e.add_argument("--tx-hash", default=None, help="Optional full tx hash for claim during topup step")
    p_e2e.add_argument("--base-url", default=os.getenv("SOCICLAW_IMAGE_API_BASE_URL"), help="Image/topup API base URL")
    p_e2e.add_argument("--run-sync", action="store_true", help="Test Trello/Notion connectivity")
    p_e2e.add_argument("--sync-target", choices=["trello", "notion", "both"], default="both")
    p_e2e.set_defaults(func=cmd_e2e_staging)

    p_release_audit = sub.add_parser("release-audit", help="Audit repo for placeholders and forbidden terms")
    p_release_audit.add_argument("--root", default=None, help="Repo root to scan")
    p_release_audit.add_argument(
        "--forbidden-terms",
        default="",
        help="Comma-separated list of forbidden terms (optional)",
    )
    p_release_audit.add_argument("--strict", action="store_true", help="Fail on any finding")
    p_release_audit.add_argument("--max-findings", default=200, type=int, help="Max findings to print")
    p_release_audit.set_defaults(func=cmd_release_audit)

    p_self_update = sub.add_parser("self-update", help="Print safe manual update instructions (no code executed)")
    p_self_update.add_argument("--repo-dir", default=None, help="Local skill repo directory")
    p_self_update.add_argument("--yes", action="store_true", help="Accepted for compatibility (no-op)")
    p_self_update.set_defaults(func=cmd_self_update)

    return p


def main(argv: Optional[list[str]] = None) -> int:
    parser = build_parser()
    args = parser.parse_args(argv)
    return int(args.func(args))


if __name__ == "__main__":
    raise SystemExit(main())


path: sociclaw/scripts/content_generator.py
content: """
Module for generating optimized content for X (Twitter) posts.

This module provides functionality to:
- Generate text optimized for X (max 280 chars or threads)
- Adapt tone based on post category
- Include relevant hashtags
- Generate image prompts for the configured image backend
"""

import logging
import json
import random
import re
from dataclasses import dataclass, field
from collections import deque
from typing import List, Dict, Optional
from pathlib import Path

from .scheduler import PostPlan
from .brand_profile import BrandProfile, load_brand_profile

# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)


@dataclass
class GeneratedPost:
    """
    Generated post with text, image prompt, and metadata.

    Attributes:
        text: Post text (280 chars or thread format)
        image_prompt: Prompt for image generation
        hashtags: List of hashtags to include
        category: Post category (e.g., "market_analysis")
        date: Publication date
        time: Publication time (hour in UTC)
    """
    text: str
    image_prompt: str
    title: str = ""
    body: str = ""
    details: str = ""
    hashtags: List[str] = field(default_factory=list)
    category: str = ""
    date: Optional[str] = None
    time: Optional[int] = None


class ContentGenerator:
    """
    Generate optimized content for X posts based on PostPlan.

    This class loads templates, generates text following X best practices,
    adapts tone by category, and creates image prompts for the image backend.
    """

    # Category-specific tone guidance
    TONES = {
        "market_analysis": "professional",
        "educational": "didactic",
        "news": "informative",
        "tips": "helpful",
        "opinion": "confident",
        "thread": "detailed",
        "meme": "casual"
    }

    # Maximum tweet length
    MAX_TWEET_LENGTH = 280
    RECENT_TEMPLATE_MEMORY = 20
    INSIGHT_SNIPPETS = {
        "market_analysis": [
            "Focus on momentum and confirmation before committing to a direction.",
            "Treat this as a scenario map, not a prediction.",
            "Watch volume and reaction speed around key levels.",
        ],
        "educational": [
            "Start simple, then add complexity only when the basics are stable.",
            "The fastest way to learn is to apply this in a small real example today.",
            "Consistency beats intensity when building skill in Web3.",
        ],
        "news": [
            "The practical impact is usually clearer after the first implementation wave.",
            "Track what changes for users, not just what changes in headlines.",
            "The second-order effects matter more than the announcement itself.",
        ],
        "tips": [
            "A small process upgrade here can prevent costly mistakes later.",
            "Use this as a repeatable habit, not a one-time fix.",
            "Do this once now and you save stress every week.",
        ],
        "opinion": [
            "Agree or disagree, but measure outcomes and adapt fast.",
            "The strongest edge is clear execution, not loud conviction.",
            "Debate is useful only when it improves your next action.",
        ],
        "thread": [
            "Below is a compact framework you can apply immediately.",
            "Use this thread as a checklist you can revisit.",
            "Save this and compare against your next execution cycle.",
        ],
        "meme": [
            "Funny because it is painfully true in every cycle.",
            "If this hit too close, you are probably doing it right.",
            "Laugh now, then fix the process.",
        ],
    }
    CTA_SNIPPETS = {
        "market_analysis": [
            "What is your invalidation level?",
            "What signal are you watching next?",
            "Would you wait for confirmation or front-run this move?",
        ],
        "educational": [
            "Want a step-by-step version?",
            "Should I break this into a checklist?",
            "Which part should I explain with examples?",
        ],
        "news": [
            "Do you see this as noise or structural change?",
            "What is the first consequence you expect?",
            "Who benefits the most if this trend continues?",
        ],
        "tips": [
            "Want more tactical tips like this?",
            "Should I turn this into a daily checklist?",
            "Which operational tip do you want next?",
        ],
        "opinion": [
            "Do you agree or disagree?",
            "What would change your mind here?",
            "What is the strongest counterpoint?",
        ],
        "thread": [
            "Reply if you want part 2.",
            "Should I publish a practical template for this?",
            "Want a one-page summary after this thread?",
        ],
        "meme": [
            "Too real or too far?",
            "Which part felt most accurate?",
            "Tag a friend who needed this today.",
        ],
    }
    INSIGHT_SNIPPETS_PT = {
        "market_analysis": [
            "Trate isso como mapa de cenarios, nao como previsao.",
            "Observe volume e reacao nos niveis-chave antes de agir.",
            "Gestao de risco primeiro, conviccao depois.",
        ],
        "educational": [
            "Comece simples e so adicione complexidade quando a base estiver clara.",
            "Aprendizado real vem de aplicacao pratica imediata.",
            "Consistencia vence intensidade no longo prazo.",
        ],
        "news": [
            "O impacto real aparece na execucao, nao no anuncio.",
            "Acompanhe mudanca de comportamento do usuario.",
            "Efeito de segunda ordem costuma ser o mais relevante.",
        ],
        "tips": [
            "Uma melhoria pequena no processo evita erros caros depois.",
            "Transforme isso em habito, nao em acao unica.",
            "Padrao simples e repetivel traz mais resultado.",
        ],
        "opinion": [
            "Opinioes so valem quando melhoram a execucao.",
            "A vantagem vem de clareza e disciplina operacional.",
            "Teste, meca e ajuste rapido.",
        ],
        "thread": [
            "Abaixo esta um framework direto para aplicar hoje.",
            "Use esta thread como checklist operacional.",
            "Salve para comparar com seu proximo ciclo de execucao.",
        ],
        "meme": [
            "Engracado porque e real em quase todo ciclo.",
            "Se doeu, provavelmente era necessario.",
            "Ria agora, ajuste o processo depois.",
        ],
    }
    CTA_SNIPPETS_PT = {
        "market_analysis": [
            "Qual seria seu nivel de invalidacao?",
            "Que sinal voce esta esperando para confirmar?",
            "Voce esperaria confirmacao ou anteciparia o movimento?",
        ],
        "educational": [
            "Quer que eu quebre isso em checklist?",
            "Quer exemplos praticos em sequencia?",
            "Qual parte voce quer aprofundar?",
        ],
        "news": [
            "Isso e ruido ou mudanca estrutural?",
            "Qual primeira consequencia voce espera?",
            "Quem mais se beneficia se essa tendencia continuar?",
        ],
        "tips": [
            "Quer mais taticas praticas como essa?",
            "Quer uma rotina diaria com esses pontos?",
            "Qual dica operacional devo cobrir em seguida?",
        ],
        "opinion": [
            "Voce concorda ou discorda?",
            "O que faria voce mudar de opiniao?",
            "Qual o melhor contraponto para isso?",
        ],
        "thread": [
            "Quer parte 2 com execucao pratica?",
            "Quer um template pronto para aplicar?",
            "Quer resumo em uma pagina no final?",
        ],
        "meme": [
            "Foi longe ou foi real demais?",
            "Qual parte te representou mais?",
            "Marca alguem que precisava ler isso hoje.",
        ],
    }

    def __init__(
        self,
        templates_path: Optional[Path] = None,
        brand_profile_path: Optional[Path] = None,
        brand_profile: Optional[BrandProfile] = None,
    ):
        """
        Initialize the ContentGenerator.

        Args:
            templates_path: Path to post_templates.json file.
                          If None, uses default location.
        """
        if templates_path is None:
            # Default to templates/ directory relative to this file
            templates_path = Path(__file__).parent.parent / "templates" / "post_templates.json"

        self.templates_path = templates_path
        self.templates: Dict[str, List[Dict]] = {}
        self._recent_template_keys: deque[str] = deque(maxlen=self.RECENT_TEMPLATE_MEMORY)
        self.brand_profile_path = brand_profile_path
        self.brand_profile = brand_profile or load_brand_profile(brand_profile_path)
        self._load_templates()

    def _load_templates(self) -> None:
        """Load post templates from JSON file."""
        try:
            if not self.templates_path.exists():
                logger.warning(f"Templates file not found at {self.templates_path}")
                self._create_default_templates()
                return

            with open(self.templates_path, 'r', encoding='utf-8') as f:
                self.templates = json.load(f)

            logger.info(f"Loaded {len(self.templates)} template categories")
        except Exception as e:
            logger.error(f"Error loading templates: {e}")
            self._create_default_templates()

    def _create_default_templates(self) -> None:
        """Create minimal default templates if file doesn't exist."""
        self.templates = {
            "market_analysis": [
                {
                    "structure": "{topic} analysis: {insight}",
                    "example": "Bitcoin analysis: Testing critical $40k support level"
                }
            ],
            "educational": [
                {
                    "structure": "How to {action}: {tip}",
                    "example": "How to start in DeFi: Begin with stablecoins"
                }
            ],
            "news": [
                {
                    "structure": "Breaking: {headline}",
                    "example": "Breaking: Major exchange announces Base integration"
                }
            ],
            "tips": [
                {
                    "structure": "Pro tip: {advice}",
                    "example": "Pro tip: Always verify contract addresses"
                }
            ],
            "opinion": [
                {
                    "structure": "{hot_take} {reasoning}",
                    "example": "L2s will flip L1s. Lower fees = better UX."
                }
            ],
            "thread": [
                {
                    "structure": "Thread: {topic}\n\n1/ {intro}",
                    "example": "Thread: Understanding gas fees\n\n1/ Let's break it down"
                }
            ],
            "meme": [
                {
                    "structure": "{setup}\n\n{punchline}",
                    "example": "Me: I'll just check the charts\n\nAlso me: *still staring 3h later*"
                }
            ]
        }

    def generate_post(self, plan: PostPlan) -> GeneratedPost:
        """
        Generate a complete post from a PostPlan.

        Args:
            plan: PostPlan with date, time, category, topic, and hashtags

        Returns:
            GeneratedPost with text, image_prompt, and metadata
        """
        try:
            # Get templates for this category
            category_templates = self.templates.get(plan.category, [])
            if not category_templates:
                logger.warning(f"No templates for category {plan.category}")
                category_templates = list(self.templates.values())[0]

            # Select template (avoiding recent repetition)
            template = self._choose_template(plan, category_templates)

            # Generate content blocks
            blocks = self._generate_content_blocks(plan, template)

            # Generate image prompt
            image_prompt = self._generate_image_prompt(plan, f"{blocks['title']} {blocks['body']}".strip())

            # Create GeneratedPost
            post = GeneratedPost(
                text=blocks["text"],
                image_prompt=image_prompt,
                title=blocks["title"],
                body=blocks["body"],
                details=blocks["details"],
                hashtags=plan.hashtags[:3],  # Limit to 3 hashtags
                category=plan.category,
                date=plan.date.strftime("%Y-%m-%d"),
                time=plan.time
            )

            logger.info(f"Generated post for {plan.category}: {blocks['text'][:50]}...")
            return post

        except Exception as e:
            logger.error(f"Error generating post: {e}")
            raise

    def _choose_template(self, plan: PostPlan, category_templates: List[Dict]) -> Dict:
        """
        Choose a template while reducing repeated template reuse.
        """
        if len(category_templates) == 1:
            chosen = category_templates[0]
            signature = f"{plan.category}:{chosen.get('structure', '')}"
            self._recent_template_keys.append(signature)
            return chosen

        candidates = []
        for tpl in category_templates:
            signature = f"{plan.category}:{tpl.get('structure', '')}"
            if signature not in self._recent_template_keys:
                candidates.append((tpl, signature))

        if not candidates:
            tpl = random.choice(category_templates)
            signature = f"{plan.category}:{tpl.get('structure', '')}"
        else:
            tpl, signature = random.choice(candidates)

        self._recent_template_keys.append(signature)
        return tpl

    def _generate_content_blocks(self, plan: PostPlan, template: Dict) -> Dict[str, str]:
        """
        Generate structured content blocks with more depth and less repetition.

        Args:
            plan: PostPlan with topic and category
            template: Template dict with structure and examples

        Returns:
            Dict with title/body/text/details
        """
        # Build base text based on template structure
        if "structure" in template:
            base_text = template["structure"]

            # Personality-aware replacement bank.
            persona = self._persona_profile()
            replacements = {
                "topic": plan.topic,
                "insight": random.choice(
                    [
                        f"a high-signal setup from a {persona['tone_modifier']} lens",
                        "a practical angle you can act on now",
                        "an overlooked lever with asymmetric upside",
                    ]
                ),
                "trend": random.choice(["momentum building", "trend consolidating", "expansion phase starting"]),
                "conclusion": random.choice(
                    [
                        "Manage risk first, then size conviction.",
                        "Wait for structure confirmation before scaling.",
                        "Track reaction quality, not just direction.",
                    ]
                ),
                "action": f"understand {plan.topic}",
                "tip": random.choice(["Start with one concrete example", "Define your risk limits first", "Use a repeatable process"]),
                "headline": plan.topic,
                "impact": random.choice(["Potentially meaningful for adoption", "Could reshape short-term positioning", "Likely to influence execution decisions"]),
                "detail": random.choice(["Implementation details will matter", "Watch how users actually adapt", "Monitor whether usage follows narrative"]),
                "context": random.choice(["Worth keeping on your radar", "Context is still evolving", "Early signal, not final verdict"]),
                "advice": plan.topic,
                "benefit": random.choice(["Security first", "Lower downside risk", "Higher consistency over time"]),
                "reasoning": random.choice(["Execution quality compounds.", "Discipline is the edge.", "Simple systems outperform reactive decisions."]),
                "hot_take": plan.topic,
                "statement": plan.topic,
                "intro": "Understanding the fundamentals",
                "point": "Key takeaway you can apply today",
                "opening": "Start with the core concept",
                "key_point": "Focus on risk management",
                "setup": plan.topic,
                "punchline": random.choice(persona["punchlines"]),
                "scenario": plan.topic,
                "explanation": "Keep it simple and consistent",
                "key_takeaway": "Safety and patience win",
            }

            for key, value in replacements.items():
                base_text = base_text.replace(f"{{{key}}}", value)

            # Replace any remaining placeholders
            base_text = re.sub(r"{[^}]+}", "details", base_text)
        else:
            # Fallback to example if no structure
            base_text = template.get("example", plan.topic)

        title = self._build_title(plan, base_text)
        body = self._build_body(plan, base_text)
        cta = random.choice(self._cta_pool(plan.category))

        text = f"{title}\n{body}\n{cta}".strip()

        # Enforce brand constraints before hashtags/length handling.
        text = self._apply_brand_constraints(text)

        # Add hashtags if they fit
        hashtags_text = " ".join(f"#{tag}" for tag in plan.hashtags[:3])

        # Check if we can fit hashtags within 280 chars
        if len(text) + len(hashtags_text) + 1 <= self.MAX_TWEET_LENGTH:
            text = f"{text} {hashtags_text}"
        elif len(text) > self.MAX_TWEET_LENGTH:
            # Truncate if too long
            available_space = self.MAX_TWEET_LENGTH - len(hashtags_text) - 4  # -4 for " ..."
            text = text[:available_space] + "..."
            if hashtags_text:
                text = f"{text} {hashtags_text}"

        if self._resolve_content_language() == "pt":
            details = (
                f"Titulo: {title}\n"
                f"Conteudo: {body}\n"
                f"CTA: {cta}\n"
                f"Topico: {plan.topic}\n"
                f"Categoria: {plan.category}"
            )
        else:
            details = (
                f"Title: {title}\n"
                f"Body: {body}\n"
                f"CTA: {cta}\n"
                f"Topic: {plan.topic}\n"
                f"Category: {plan.category}"
            )

        persona = self._persona_profile()
        if persona["signature_opener"]:
            details = f"{details}\nOpening Style: {persona['signature_opener']}"
        if persona["visual_style"]:
            details = f"{details}\nVisual Style: {persona['visual_style']}"
        if persona["traits"]:
            details = f"{details}\nPersonality Traits: {', '.join(persona['traits'])}"
        if persona["goals"]:
            details = f"{details}\nContent Goals: {', '.join(persona['goals'])}"

        if cta_style := persona["cta_style"]:
            text = self._enforce_cta_style(text, str(cta_style))

        return {
            "title": title.strip(),
            "body": body.strip(),
            "text": text.strip(),
            "details": details.strip(),
        }

    def _enforce_cta_style(self, text: str, style: str) -> str:
        if style not in {"question", "invitation", "challenge"}:
            return text
        if style == "question":
            if "?" not in text:
                return f"{text} What is your move?"
            return text
        if style == "invitation":
            if "dm" not in text.lower() and "comment" not in text.lower():
                return f"{text} Tell me what worked for you and I will DM a practical checklist."
            return text
        return f"{text} If you disagree, tell me where it breaks."

    def _persona_profile(self) -> Dict[str, object]:
        """Build a compact, normalized persona payload from brand profile data."""
        profile = self.brand_profile
        signature_openers = [s for s in profile.signature_openers if s.strip()]
        return {
            "traits": [t for t in profile.personality_traits if t.strip()],
            "goals": [g for g in profile.content_goals if g.strip()],
            "signature_opener": random.choice(signature_openers) if signature_openers else "",
            "visual_style": profile.visual_style or "",
            "cta_style": profile.cta_style or "question",
            "punchlines": [
                "Execution quality compounds, not hype.",
                "Consistency beats perfect timing every single week.",
                "Small process upgrades beat loud intentions.",
            ],
            "tone_modifier": profile.voice_tone.strip() if profile.voice_tone else "practical",
        }

    def _resolve_content_language(self) -> str:
        lang = (self.brand_profile.content_language or "").strip().lower()
        if not lang:
            return "en"
        if lang.startswith("pt"):
            return "pt"
        return "en"

    def _insight_pool(self, category: str) -> List[str]:
        if self._resolve_content_language() == "pt":
            return self.INSIGHT_SNIPPETS_PT.get(category, self.INSIGHT_SNIPPETS_PT["tips"])
        return self.INSIGHT_SNIPPETS.get(category, self.INSIGHT_SNIPPETS["tips"])

    def _cta_pool(self, category: str) -> List[str]:
        if self._resolve_content_language() == "pt":
            return self.CTA_SNIPPETS_PT.get(category, self.CTA_SNIPPETS_PT["tips"])
        return self.CTA_SNIPPETS.get(category, self.CTA_SNIPPETS["tips"])

    def _build_title(self, plan: PostPlan, base_text: str) -> str:
        primary = base_text.strip().splitlines()[0]
        if self._resolve_content_language() == "pt" and "Thread:" in primary:
            primary = primary.replace("Thread:", "Thread:")
        # Keep titles compact and scannable.
        if len(primary) > 90:
            primary = primary[:87] + "..."
        return primary

    def _build_body(self, plan: PostPlan, base_text: str) -> str:
        insight = random.choice(self._insight_pool(plan.category))
        connector = "sobre" if self._resolve_content_language() == "pt" else "about"
        body = f"{plan.topic} ({connector} {plan.category.replace('_', ' ')}): {insight}"
        # If base text already contains richer context, blend it in lightly.
        normalized = re.sub(r"\s+", " ", base_text).strip()
        if normalized and len(normalized) < 140:
            body = f"{normalized} {insight}"
        if len(body) > 170:
            body = body[:167] + "..."
        return body

    def _apply_brand_constraints(self, text: str) -> str:
        """
        Apply optional Brand Brain rules:
        - remove forbidden terms
        - inject one required keyword when missing
        """
        profile = self.brand_profile

        # Remove blocked terms (case-insensitive, whole-word).
        for blocked in profile.do_not_say:
            [REDACTED_SECRET])
            if not [REDACTED_SECRET]
            text = re.sub(rf"\b{re.escape(token)}\b", "", text, flags=re.IGNORECASE)

        # Ensure at least one required keyword appears.
        if profile.keywords:
            has_any_keyword = any(k.lower() in text.lower() for k in profile.keywords if k.strip())
            if not has_any_keyword:
                primary = profile.keywords[0].strip()
                if primary:
                    text = f"{text} {primary}".strip()

        # Cleanup whitespace left by removals.
        text = re.sub(r"\s{2,}", " ", text).strip()
        return text

    def _generate_image_prompt(self, plan: PostPlan, text: str) -> str:
        """
        Generate an image prompt based on content.

        Args:
            plan: PostPlan with category and topic
            text: Generated post text

        Returns:
            Image generation prompt string
        """
        # Base style for crypto/web3 content, enriched with the brand profile.
        base_style = "modern, professional, crypto themed, vibrant colors"
        persona = self._persona_profile()
        style_hints = [base_style]
        if persona["tone_modifier"]:
            style_hints.append(f"tone: {persona['tone_modifier']}")
        if persona["visual_style"]:
            style_hints.append(persona["visual_style"])
        base_style_with_profile = ", ".join(style_hints)

        # Category-specific visual styles
        category_styles = {
            "market_analysis": f"financial chart, {base_style_with_profile}, data visualization",
            "educational": f"infographic style, {base_style_with_profile}, clean layout",
            "news": f"breaking news style, {base_style_with_profile}, bold typography",
            "tips": f"minimalist design, {base_style_with_profile}, icon-based",
            "opinion": f"bold statement, {base_style_with_profile}, striking visuals",
            "thread": f"thread visualization, {base_style_with_profile}, numbered layout",
            "meme": f"meme style, {base_style_with_profile}, humorous, relatable"
        }

        style = category_styles.get(plan.category, base_style)

        brand_context_parts = []
        if self.brand_profile.name:
            brand_context_parts.append(f"brand {self.brand_profile.name}")
        if self.brand_profile.voice_tone:
            brand_context_parts.append(f"tone {self.brand_profile.voice_tone}")
        if self.brand_profile.key_themes:
            brand_context_parts.append(f"themes {', '.join(self.brand_profile.key_themes[:3])}")

        brand_context = ", ".join(brand_context_parts)
        if brand_context:
            brand_context = f", {brand_context}"

        # Build prompt
        prompt = f"{plan.topic}, {style}{brand_context}, 1024x1024, high quality, digital art"

        return prompt

    def generate_batch(self, plans: List[PostPlan]) -> List[GeneratedPost]:
        """
        Generate multiple posts from a list of PostPlans.

        Args:
            plans: List of PostPlan objects

        Returns:
            List of GeneratedPost objects
        """
        posts = []
        for plan in plans:
            try:
                post = self.generate_post(plan)
                posts.append(post)
            except Exception as e:
                logger.error(f"Error generating post for plan {plan}: {e}")
                continue

        logger.info(f"Generated {len(posts)} posts from {len(plans)} plans")
        return posts


path: sociclaw/scripts/http_retry.py
content: """
HTTP retry helpers for external integrations.

Goals:
- Keep retry behavior consistent across providers.
- Retry only transient failures (network/timeouts/429/5xx).
- Preserve existing request/response handling in callers.
"""

from __future__ import annotations

import random
import time
from typing import Iterable, Optional

import requests


DEFAULT_RETRY_STATUSES = (429, 500, 502, 503, 504)


def request_with_retry(
    *,
    session: requests.Session,
    method: str,
    url: str,
    headers: Optional[dict] = None,
    json: Optional[dict] = None,
    timeout: int = 30,
    max_retries: int = 3,
    backoff_base_seconds: float = 0.5,
    retry_statuses: Optional[Iterable[int]] = None,
) -> requests.Response:
    """
    Execute an HTTP request with retry/backoff for transient failures.

    `max_retries` means additional attempts after the first request.
    So total attempts = 1 + max_retries.
    """
    retryable_statuses = set(retry_statuses or DEFAULT_RETRY_STATUSES)
    last_exception: Optional[Exception] = None

    for attempt in range(max_retries + 1):
        try:
            resp = session.request(
                method=method.upper().strip(),
                url=url,
                headers=headers,
                json=json,
                timeout=timeout,
            )

            if resp.status_code in retryable_statuses and attempt < max_retries:
                _sleep_with_jitter(backoff_base_seconds, attempt)
                continue

            return resp
        except requests.RequestException as exc:
            last_exception = exc
            if attempt >= max_retries:
                raise
            _sleep_with_jitter(backoff_base_seconds, attempt)

    # Defensive fallback; loop always returns or raises.
    if last_exception:
        raise last_exception
    raise RuntimeError("request_with_retry failed unexpectedly")


def _sleep_with_jitter(base_seconds: float, attempt: int) -> None:
    delay = max(0.05, float(base_seconds)) * (2 ** int(attempt))
    jitter = random.uniform(0.0, min(0.25, delay * 0.2))
    time.sleep(delay + jitter)


path: sociclaw/scripts/image_generator.py
content: """
Module for generating images.

Provides:
- Exponential backoff retries
- Local image backup (downloads the generated image URL)
"""

from __future__ import annotations

import logging
import os
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Optional

import requests

from .image_provider_client import ImageProviderClient

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)


@dataclass
class ImageResult:
    """
    Result of an image generation request.

    Attributes:
        url: URL of the generated image
        local_path: Local path where the image was saved
    """

    url: str
    local_path: Optional[Path] = None


class ImageGenerator:
    """
    Generate images with optional credit checks and retries.
    """

    def __init__(
        self,
        *,
        [REDACTED_SECRET] = None,
        model: Optional[str] = None,
        generate_url: Optional[str] = None,
        jobs_base_url: Optional[str] = None,
        image_url: Optional[str] = None,
        webhook_url: Optional[str] = None,
        timeout_seconds: Optional[int] = None,
        poll_interval_seconds: Optional[int] = None,
        provider_client: Optional[ImageProviderClient] = None,
        # Shared
        output_dir: Optional[Path] = None,
        max_retries: int = 3,
        backoff_base: float = 1.5,
    ) -> None:
        self.max_retries = int(max_retries)
        self.backoff_base = float(backoff_base)

        if output_dir is None:
            repo_root = Path(__file__).resolve().parents[2]
            output_dir = repo_root / ".sociclaw" / "generated_images"
        self.output_dir = output_dir
        self.output_dir.mkdir(parents=True, exist_ok=True)

        # Image Provider Config
        self.provider_client = None
        self.model = (
            model
            or os.getenv("SOCICLAW_IMAGE_MODEL")
            or "nano-banana"
        )
        self.image_url = (
            image_url
            or os.getenv("SOCICLAW_IMAGE_URL")
        )
        self.webhook_url = (
            webhook_url
            or os.getenv("SOCICLAW_WEBHOOK_URL")
        )
        self.timeout_seconds = int(timeout_seconds or os.getenv("SOCICLAW_IMAGE_TIMEOUT_SECONDS") or 120)
        self.poll_interval_seconds = int(poll_interval_seconds or os.getenv([REDACTED_SECRET]) or 2)

        _api_key = (
            api_key
            or os.getenv("SOCICLAW_IMAGE_API_KEY")
        )
        if not _api_key and provider_client is None:
            raise ValueError(
                "Missing image API key. Set SOCICLAW_IMAGE_API_KEY."
            )

        _generate_url = (
            generate_url
            or os.getenv("SOCICLAW_IMAGE_GENERATE_URL")
        )
        _jobs_base_url = (
            jobs_base_url
            or os.getenv("SOCICLAW_IMAGE_JOBS_URL")
        )

        self.provider_client = provider_client or ImageProviderClient(
            [REDACTED_SECRET],
            generate_url=_generate_url,
            jobs_base_url=_jobs_base_url,
        )

        logger.info("ImageGenerator initialized")

    def generate_image(self, prompt: str, user_address: str) -> ImageResult:
        """
        Generate an image from a prompt.
        """
        last_error: Optional[Exception] = None
        delay = 1.0

        for attempt in range(1, self.max_retries + 1):
            try:
                logger.info(f"Generating image (attempt {attempt}/{self.max_retries})")

                url = self.provider_client.generate_image(
                    prompt=prompt,
                    model=self.model,
                    image_url=self.image_url,
                    webhook_url=self.webhook_url,
                    user_id=user_address,
                    timeout_seconds=self.timeout_seconds,
                    poll_interval_seconds=self.poll_interval_seconds,
                )

                local_path = self._save_image(url)

                return ImageResult(url=str(url), local_path=local_path)

            except Exception as exc:
                last_error = exc
                if attempt >= self.max_retries:
                    break
                logger.warning(f"Image generation failed: {exc}. Retrying in {delay:.1f}s")
                time.sleep(delay)
                delay *= self.backoff_base

        raise RuntimeError(f"Image generation failed after retries: {last_error}")

    def _save_image(self, url: str) -> Path:
        """
        Download and save the image locally.
        """
        filename = f"sociclaw_{int(time.time())}.png"
        file_path = self.output_dir / filename

        response = requests.get(url, timeout=30)
        response.raise_for_status()

        file_path.write_bytes(response.content)
        logger.info(f"Saved image to {file_path}")
        return file_path


path: sociclaw/scripts/image_provider_client.py
content: """
Image Provider API client (Internal).

This client handles image generation API calls.
The underlying provider details are abstracted away from user-facing code.
"""

from __future__ import annotations

import base64
import ipaddress
import logging
import mimetypes
import os
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, Optional
from urllib.parse import unquote, urlparse

import requests

from .http_retry import request_with_retry

logger = logging.getLogger(__name__)


@dataclass(frozen=True)
class ImageJobResult:
    job_id: str
    status: str
    result_url: Optional[str] = None
    raw: Optional[Dict[str, Any]] = None


class ImageProviderClient:
    def __init__(
        self,
        api_key: str,
        generate_url: Optional[str] = None,
        jobs_base_url: Optional[str] = None,
        timeout_seconds: int = 30,
        session: Optional[requests.Session] = None,
    ) -> None:
        if not api_key or not api_key.strip():
            raise ValueError("API key is required")

        base_url = (os.getenv("SOCICLAW_IMAGE_API_BASE_URL") or "").strip().rstrip("/")
        default_generate_url = (
            f"{base_url}/api/v1?path=generate"
            if base_url
            else ""
        )
        default_jobs_base_url = (
            f"{base_url}/api/v1/jobs/"
            if base_url
            else ""
        )

        self.[REDACTED_SECRET])
        self.generate_url = (generate_url or default_generate_url).strip()
        self.jobs_base_url = (jobs_base_url or default_jobs_base_url).strip().rstrip("/")
        if not self.generate_url or not self.jobs_base_url:
            raise ValueError(
                "Image API URLs are required. Set SOCICLAW_IMAGE_API_BASE_URL or pass generate_url/jobs_base_url."
            )
        self.jobs_base_url += "/"
        self.timeout_seconds = int(timeout_seconds)
        self.max_retries = int(os.getenv("SOCICLAW_HTTP_MAX_RETRIES", "3"))
        self.backoff_base_seconds = float(os.getenv("SOCICLAW_HTTP_BACKOFF_SECONDS", "0.5"))
        self.max_payload_bytes = int(os.getenv("SOCICLAW_IMAGE_INPUT_MAX_BYTES", str(10 * 1024 * 1024)))
        self.allow_remote_url = (
            os.getenv("SOCICLAW_ALLOW_IMAGE_URL_INPUT", "false").strip().lower()
            in {"1", "true", "yes", "on"}
        )
        self.allowed_input_roots = self._resolve_allowed_roots()
        self.allowed_url_hosts = self._resolve_allowed_url_hosts()
        self.max_remote_redirects = int(os.getenv("SOCICLAW_IMAGE_URL_MAX_REDIRECTS", "3"))
        self.session = session or requests.Session()

    def create_job(
        self,
        *,
        prompt: str,
        model: str,
        image_url: Optional[str] = None,
        webhook_url: Optional[str] = None,
        user_id: Optional[str] = None,
        extra: Optional[Dict[str, Any]] = None,
    ) -> Dict[str, Any]:
        base_payload: Dict[str, Any] = {
            "prompt": prompt,
            "model": model,
        }
        if image_url:
            base_payload["image_url"] = image_url
        if webhook_url:
            base_payload["webhook_url"] = webhook_url
        if user_id:
            base_payload["user_id"] = user_id
        if extra:
            base_payload.update(extra)

        payload_candidates = [base_payload]
        if image_url:
            image_data_url = self._resolve_image_data_url(image_url)
            if image_data_url:
                payload_with_data_url = dict(base_payload)
                payload_with_data_url["image_data_url"] = image_data_url
                payload_candidates.append(payload_with_data_url)

                payload_data_only = dict(base_payload)
                payload_data_only.pop("image_url", None)
                payload_data_only["image_data_url"] = image_data_url
                payload_candidates.append(payload_data_only)

        last_response: Optional[requests.Response] = None
        for idx, payload in enumerate(payload_candidates):
            resp = request_with_retry(
                session=self.session,
                method="POST",
                url=self.generate_url,
                headers={"Authorization": f"Bearer {self.api_key}"},
                json=payload,
                timeout=self.timeout_seconds,
                max_retries=self.max_retries,
                backoff_base_seconds=self.backoff_base_seconds,
            )
            if resp.ok:
                return resp.json()

            last_response = resp
            has_fallback = idx < (len(payload_candidates) - 1)
            if has_fallback and self._should_retry_with_alternate_payload(resp):
                logger.warning(
                    "Generate request failed (%s). Retrying with alternate image payload format.",
                    resp.status_code,
                )
                continue

            resp.raise_for_status()

        if last_response is not None:
            last_response.raise_for_status()
        raise RuntimeError("Image API create_job failed without response")

    def _should_retry_with_alternate_payload(self, response: requests.Response) -> bool:
        if response.status_code not in {400, 422}:
            return False
        body = (response.text or "").lower()
        return any(
            token in body
            for token in (
                "image input",
                "image_url",
                "image_data_url",
                "missing image",
                "requires an image",
            )
        )

    def _resolve_image_data_url(self, image_url: str) -> Optional[str]:
        clean = str(image_url or "").strip()
        if not clean:
            return None
        if clean.startswith("data:image/"):
            return clean

        local_path = self._resolve_local_path(clean)
        if local_path and local_path.is_file():
            try:
                data = local_path.read_bytes()
            except OSError:
                return None
            content_type = self._guess_image_content_type(data, source_hint=str(local_path))
            if not content_type:
                logger.warning("Blocked non-image local file for image generation: %s", local_path)
                return None
            return self._build_image_data_url(data, source_hint=str(local_path), content_type_hint=content_type)

        if not self.allow_remote_url:
            return None

        disable = (os.getenv("SOCICLAW_DISABLE_IMAGE_DATA_URL_FALLBACK") or "").strip().lower()
        if disable in {"1", "true", "yes", "on"}:
            return None

        if not self._is_allowed_remote_image_url(clean):
            return None

        data, content_type, final_url = self._fetch_remote_image_bytes(clean)
        if not data:
            return None

        ct = (content_type or "").strip().lower()
        if not ct.startswith("image/"):
            ct = self._guess_image_content_type(data, source_hint=final_url or clean, header_hint=ct) or ct
            if not ct:
                return None

        return self._build_image_data_url(data, source_hint=final_url or clean, content_type_hint=ct)

    def _resolve_local_path(self, image_input: str) -> Optional[Path]:
        value = str(image_input or "").strip()
        if not value:
            return None

        if value.lower().startswith("file://"):
            parsed = urlparse(value)
            if parsed.scheme != "file":
                return None
            file_path = unquote(parsed.path or "")
            if os.name == "nt" and file_path.startswith("/"):
                file_path = file_path[1:]
            return self._normalize_local_path(file_path)

        try:
            candidate = Path(value)
        except (TypeError, ValueError):
            return None
        return self._normalize_local_path(candidate)

    def _normalize_local_path(self, path: str | Path) -> Optional[Path]:
        try:
            candidate = Path(path).expanduser()
            if not candidate.is_absolute():
                candidate = (Path.cwd() / candidate).resolve()
            else:
                candidate = candidate.resolve()
        except (OSError, RuntimeError):
            return None

        try:
            if not candidate.is_file():
                return None
        except OSError:
            return None

        if not self._is_allowed_path(candidate):
            logger.warning("Blocked local image path outside allowed roots: %s", candidate)
            return None
        return candidate

    def _resolve_allowed_roots(self) -> list[Path]:
        configured = os.getenv("SOCICLAW_ALLOWED_IMAGE_INPUT_DIRS")
        base = Path.cwd().resolve()
        candidate_roots: list[Path] = [base / ".sociclaw", base / ".tmp"]
        allow_absolute = (
            os.getenv("SOCICLAW_ALLOW_ABSOLUTE_IMAGE_INPUT_DIRS", "false").strip().lower()
            in {"1", "true", "yes", "on"}
        )
        if configured:
            candidate_roots = []
            for item in configured.split(","):
                value = item.strip()
                if not value:
                    continue

                p = Path(value).expanduser()
                if p.is_absolute():
                    if not allow_absolute:
                        logger.warning(
                            "Ignoring absolute SOCICLAW_ALLOWED_IMAGE_INPUT_DIRS entry (set SOCICLAW_ALLOW_ABSOLUTE_IMAGE_INPUT_DIRS=true to allow): %s",
                            p,
                        )
                        continue
                    candidate = p
                else:
                    candidate = (base / p)

                try:
                    resolved = candidate.resolve()
                except OSError:
                    resolved = candidate

                # Never allow filesystem roots (e.g. C:\ or /).
                if resolved.parent == resolved:
                    logger.warning("Ignoring root directory in SOCICLAW_ALLOWED_IMAGE_INPUT_DIRS: %s", resolved)
                    continue

                candidate_roots.append(resolved)
            if not candidate_roots:
                candidate_roots = [base / ".sociclaw", base / ".tmp"]
        resolved: list[Path] = []
        for root in candidate_roots:
            try:
                resolved.append(root.resolve())
            except OSError:
                resolved.append(Path(root).expanduser())
        unique: list[Path] = []
        for root in resolved:
            if root not in unique:
                unique.append(root)
        return unique

    def _resolve_allowed_url_hosts(self) -> list[str]:
        raw = (os.getenv("SOCICLAW_ALLOWED_IMAGE_URL_HOSTS") or "").strip()
        if not raw:
            return []
        items = []
        for item in raw.split(","):
            v = item.strip().lower().rstrip(".")
            if not v:
                continue
            items.append(v)
        unique: list[str] = []
        for v in items:
            if v not in unique:
                unique.append(v)
        return unique

    def _host_matches_allowlist(self, host: str) -> bool:
        normalized = (host or "").strip().lower().rstrip(".")
        if not normalized or not self.allowed_url_hosts:
            return False
        for pattern in self.allowed_url_hosts:
            if pattern == "*":
                return True
            if pattern.startswith("*."):
                base = pattern[2:]
                if normalized == base or normalized.endswith("." + base):
                    return True
                continue
            if normalized == pattern:
                return True
        return False

    def _is_allowed_remote_image_url(self, url: str) -> bool:
        try:
            parsed = urlparse(url)
        except Exception:
            return False

        scheme = (parsed.scheme or "").lower()
        if scheme != "https":
            return False

        host = (parsed.hostname or "").strip().lower().rstrip(".")
        if not host:
            return False
        if host in {"localhost", "0.0.0.0"} or host.endswith(".local"):
            return False

        # If host is an IP literal, block private/link-local/etc.
        try:
            ip = ipaddress.ip_address(host)
            if (
                ip.is_private
                or ip.is_loopback
                or ip.is_link_local
                or ip.is_reserved
                or ip.is_multicast
                or ip.is_unspecified
            ):
                return False
        except ValueError:
            pass

        # Remote URL fetch is an explicit opt-in AND requires an explicit host allowlist.
        if not self._host_matches_allowlist(host):
            logger.warning("Blocked remote image URL host not in allowlist: %s", host)
            return False

        return True

    def _fetch_remote_image_bytes(self, url: str) -> tuple[bytes, Optional[str], Optional[str]]:
        try:
            with self.session.get(
                url,
                timeout=self.timeout_seconds,
                stream=True,
                allow_redirects=True,
                headers={"Accept": "image/*"},
            ) as resp:
                if not resp.ok:
                    return b"", None, None

                if resp.history and len(resp.history) > self.max_remote_redirects:
                    logger.warning("Blocked remote image URL with too many redirects (%s)", len(resp.history))
                    return b"", None, None

                final_url = resp.url or url
                if final_url != url and not self._is_allowed_remote_image_url(final_url):
                    logger.warning("Blocked remote image URL after redirect to disallowed host")
                    return b"", None, None

                content_type = (resp.headers.get("Content-Type", "") or "").split(";", 1)[0].strip().lower() or None

                total = 0
                chunks: list[bytes] = []
                for chunk in resp.iter_content(chunk_size=64 * 1024):
                    if not chunk:
                        continue
                    total += len(chunk)
                    if total > self.max_payload_bytes:
                        logger.warning("Remote image too large for data URL fallback (%s bytes)", total)
                        return b"", None, None
                    chunks.append(chunk)

                data = b"".join(chunks)
                return data, content_type, final_url
        except requests.RequestException:
            return b"", None, None

    def _is_allowed_path(self, candidate: Path) -> bool:
        for root in self.allowed_input_roots:
            try:
                candidate.relative_to(root)
                return True
            except ValueError:
                continue
        return False

    def _guess_image_content_type(
        self,
        data: bytes,
        *,
        source_hint: str,
        header_hint: str | None = None,
    ) -> Optional[str]:
        if header_hint and header_hint.startswith("image/"):
            return header_hint
        kind = self._sniff_image_type(data)
        if kind:
            return f"image/{kind}"

        guessed, _ = mimetypes.guess_type(source_hint)
        if guessed and guessed.startswith("image/"):
            return guessed

        return None

    @staticmethod
    def _sniff_image_type(data: bytes) -> Optional[str]:
        if not data:
            return None

        png = data[:8]
        if png.startswith(b"\x89PNG\r\n\x1a\n"):
            return "png"
        if data[:3] == b"\xff\xd8\xff":
            return "jpeg"
        if data[:6] in (b"GIF87a", b"GIF89a"):
            return "gif"
        if len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP":
            return "webp"
        if len(data) >= 2 and data[:2] in (b"BM",):
            return "bmp"
        if len(data) >= 4 and data[:4] in (b"MM\x00*", b"II*\x00"):
            return "tiff"
        return None

    def _build_image_data_url(
        self,
        data: bytes,
        *,
        source_hint: str,
        content_type_hint: Optional[str],
    ) -> Optional[str]:
        if not data:
            return None
        if len(data) > self.max_payload_bytes:
            logger.warning("Input image too large for data URL fallback (%s bytes)", len(data))
            return None

        content_type = (content_type_hint or "").split(";")[0].strip().lower()
        if not content_type.startswith("image/"):
            return None

        encoded = base64.b64encode(data).decode("ascii")
        return f"data:{content_type};base64,{encoded}"

    def get_job(self, job_id: str) -> Dict[str, Any]:
        url = f"{self.jobs_base_url}{job_id}"
        resp = request_with_retry(
            session=self.session,
            method="GET",
            url=url,
            headers={"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"},
            timeout=max(self.timeout_seconds, 60),
            max_retries=self.max_retries,
            backoff_base_seconds=self.backoff_base_seconds,
        )
        resp.raise_for_status()
        return resp.json()

    def wait_for_job(
        self,
        job_id: str,
        *,
        timeout_seconds: int = 180,
        poll_interval_seconds: int = 5,
    ) -> ImageJobResult:
        deadline = time.time() + int(timeout_seconds)
        last: Optional[Dict[str, Any]] = None

        while time.time() < deadline:
            last = self.get_job(job_id)
            status = str(last.get("status", "")).lower().strip()

            if status == "completed":
                return ImageJobResult(
                    job_id=job_id,
                    status=status,
                    result_url=last.get("result_url") or last.get("url"),
                    raw=last,
                )

            if status in {"failed", "error", "canceled", "cancelled"}:
                raise RuntimeError(f"Image job {job_id} failed: {last}")

            time.sleep(int(poll_interval_seconds))

        raise TimeoutError(f"Image job {job_id} did not complete within {timeout_seconds}s: {last}")

    def generate_image(
        self,
        *,
        prompt: str,
        model: str,
        image_url: Optional[str] = None,
        webhook_url: Optional[str] = None,
        user_id: Optional[str] = None,
        timeout_seconds: int = 180,
        poll_interval_seconds: int = 5,
    ) -> str:
        created = self.create_job(
            prompt=prompt,
            model=model,
            image_url=image_url,
            webhook_url=webhook_url,
            user_id=user_id,
        )

        job_id = created.get("job_id") or created.get("id")
        if not job_id:
            raise RuntimeError(f"create_job did not return job_id: {created}")

        result = self.wait_for_job(
            str(job_id),
            timeout_seconds=timeout_seconds,
            poll_interval_seconds=poll_interval_seconds,
        )

        if not result.result_url:
            raise RuntimeError(f"Image job completed but no result_url: {result.raw}")

        return str(result.result_url)


path: sociclaw/scripts/local_session_store.py
content: """
Local session store for SociClaw topups (SQLite).

Why SQLite:
- Atomic writes (safer than JSON if the process crashes mid-write)
- No external dependencies
- Suitable for single-instance bots on VPS/Mac mini

Data stored:
- telegram_user_id -> session_id (provider topup session)
"""

from __future__ import annotations

import sqlite3
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional


def _utc_now_iso() -> str:
    return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")


def default_db_path() -> Path:
    repo_root = Path(__file__).resolve().parents[2]
    return repo_root / ".tmp" / "sociclaw_sessions.db"


@dataclass(frozen=True)
class SessionRecord:
    telegram_user_id: str
    session_id: str
    created_at: str
    updated_at: str


class LocalSessionStore:
    def __init__(self, path: Optional[Path] = None) -> None:
        self.path = path or default_db_path()
        self.path.parent.mkdir(parents=True, exist_ok=True)
        self._init_db()

    def _connect(self) -> sqlite3.Connection:
        conn = sqlite3.connect(self.path)
        conn.row_factory = sqlite3.Row
        return conn

    def _init_db(self) -> None:
        with self._connect() as conn:
            conn.execute(
                """
                CREATE TABLE IF NOT EXISTS topup_sessions (
                    telegram_user_id TEXT PRIMARY KEY,
                    session_id TEXT NOT NULL,
                    created_at TEXT NOT NULL,
                    updated_at TEXT NOT NULL
                )
                """
            )
            conn.commit()

    def upsert_session(self, telegram_user_id: str, session_id: str) -> SessionRecord:
        now = _utc_now_iso()
        with self._connect() as conn:
            row = conn.execute(
                "SELECT telegram_user_id, session_id, created_at, updated_at FROM topup_sessions WHERE telegram_user_id = ?",
                (str(telegram_user_id),),
            ).fetchone()

            if row:
                created_at = row["created_at"]
                conn.execute(
                    """
                    UPDATE topup_sessions
                    SET session_id = ?, updated_at = ?
                    WHERE telegram_user_id = ?
                    """,
                    (session_id, now, str(telegram_user_id)),
                )
            else:
                created_at = now
                conn.execute(
                    """
                    INSERT INTO topup_sessions (telegram_user_id, session_id, created_at, updated_at)
                    VALUES (?, ?, ?, ?)
                    """,
                    (str(telegram_user_id), session_id, now, now),
                )

            conn.commit()

        return SessionRecord(
            telegram_user_id=str(telegram_user_id),
            session_id=session_id,
            created_at=created_at,
            updated_at=now,
        )

    def get_session(self, telegram_user_id: str) -> Optional[SessionRecord]:
        with self._connect() as conn:
            row = conn.execute(
                "SELECT telegram_user_id, session_id, created_at, updated_at FROM topup_sessions WHERE telegram_user_id = ?",
                (str(telegram_user_id),),
            ).fetchone()

        if not row:
            return None

        return SessionRecord(
            telegram_user_id=row["telegram_user_id"],
            session_id=row["session_id"],
            created_at=row["created_at"],
            updated_at=row["updated_at"],
        )

    def delete_session(self, telegram_user_id: str) -> None:
        with self._connect() as conn:
            conn.execute(
                "DELETE FROM topup_sessions WHERE telegram_user_id = ?",
                (str(telegram_user_id),),
            )
            conn.commit()


path: sociclaw/scripts/notion_sync.py
content: """
Module for syncing posts with Notion.

This module provides functionality to:
- Create pages in a Notion database
- Update status of pages
- Fetch pending posts for review
"""

import logging
import os
from datetime import datetime
from typing import Optional, List

try:
    from notion_client import Client as NotionClient
except ImportError:  # pragma: no cover - handled during initialization
    NotionClient = None

from .content_generator import GeneratedPost

# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)


class NotionSync:
    """
    Sync SociClaw posts with Notion.
    """

    def __init__(
        self,
        [REDACTED_SECRET] = None,
        database_id: Optional[str] = None,
        client: Optional[NotionClient] = None,
    ) -> None:
        """
        Initialize NotionSync.

        Args:
            [REDACTED_SECRET] API key
            database_id: Notion database ID
            client: Optional NotionClient instance for testing/mocking
        """
        self.[REDACTED_SECRET] or os.getenv("NOTION_API_KEY")
        self.database_id = database_id or os.getenv("NOTION_DATABASE_ID")

        if not client and (not self.api_key or not self.database_id):
            raise ValueError("NOTION_API_KEY and NOTION_DATABASE_ID must be provided")

        if client is not None:
            self.client = client
        else:
            if NotionClient is None:
                raise ImportError("notion-client is required for Notion sync")
            self.client = NotionClient(auth=self.api_key)

    def create_page(self, post: GeneratedPost, status: str = "Draft", image_url: Optional[str] = None):
        """
        Create a Notion page for a generated post.

        Args:
            post: GeneratedPost instance
            status: Initial status (Draft/Review/Scheduled/Published)
            image_url: Optional image URL to attach

        Returns:
            Created page object
        """
        content_text = post.details or post.text
        properties = {
            "Title": {
                "title": [{"text": {"content": self._summarize_title(post.text)}}]
            },
            "Content": {
                "rich_text": [{"text": {"content": content_text[:1900]}}]
            },
            "Date": {
                "date": {"start": self._format_datetime(post)}
            },
            "Status": {
                "select": {"name": status}
            },
            "Category": {
                "multi_select": [{"name": post.category}]
            },
            "Engagement": {
                "number": 0
            },
        }

        if image_url:
            properties["Image"] = {
                "files": [
                    {"name": "image", "type": "external", "external": {"url": image_url}}
                ]
            }

        page = self.client.pages.create(parent={"database_id": self.database_id}, properties=properties)
        logger.info("Created Notion page")
        return page

    def update_status(self, page_id: str, status: str):
        """
        Update the status of a Notion page.
        """
        return self.client.pages.update(
            page_id=page_id,
            properties={"Status": {"select": {"name": status}}}
        )

    def get_pending_posts(self) -> List[dict]:
        """
        Fetch posts in Draft or Review status.
        """
        result = self.client.databases.query(
            database_id=self.database_id,
            filter={
                "or": [
                    {"property": "Status", "select": {"equals": "Draft"}},
                    {"property": "Status", "select": {"equals": "Review"}},
                ]
            },
        )
        return result.get("results", [])

    def _summarize_title(self, text: str) -> str:
        """
        Build a short title for the page.
        """
        if not text or not text.strip():
            return "Untitled Post"
        first_line = text.strip().splitlines()[0]
        return (first_line[:80] + "...") if len(first_line) > 80 else first_line

    def _format_datetime(self, post: GeneratedPost) -> Optional[str]:
        """
        Format a datetime string for Notion.
        """
        if not post.date or post.time is None:
            return None
        try:
            date_obj = datetime.fromisoformat(post.date)
            date_obj = date_obj.replace(hour=int(post.time), minute=0, second=0, microsecond=0)
            return date_obj.isoformat()
        except Exception:
            return None


path: sociclaw/scripts/validators.py
content: """
Input validators used across CLI and clients.
"""

from __future__ import annotations

import re

PROVIDER_PATTERN = re.compile(r"^[a-z0-9_-]{2,32}$", re.IGNORECASE)
PROVIDER_USER_ID_PATTERN = re.compile(r"^[a-zA-Z0-9:_@.\-]{1,128}$")
TX_HASH_PATTERN = re.compile(r"^0x[a-fA-F0-9]{64}$")


def validate_provider(value: str) -> str:
    data = str(value or "").strip()
    if not PROVIDER_PATTERN.fullmatch(data):
        raise ValueError("Invalid provider format. Allowed: letters, numbers, _ and -, length 2-32.")
    return data


def validate_provider_user_id(value: str) -> str:
    data = str(value or "").strip()
    if not PROVIDER_USER_ID_PATTERN.fullmatch(data):
        raise ValueError("Invalid provider_user_id format. Allowed length 1-128.")
    return data


def validate_tx_hash(value: str) -> str:
    data = str(value or "").strip()
    if not TX_HASH_PATTERN.fullmatch(data):
        raise ValueError("Invalid tx hash format. Expected 0x + 64 hex chars.")
    return data


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