Skill: chaimengphp/openclaw-aisa-twitter (version 1.0.6)

--- name: Twitter Command Center (Search + Post + Interact) description: "Searches and reads X (Twitter): profiles, timelines, mentions, followers, tweet search, trends, lists, communities, and Spaces. Publishes posts, likes/unlikes tweets, and follows/unfollows users after the user completes OAuth in the browser. Use when the user asks about Twitter/X data, social listening, posting, or interacting with tweets/users without sharing account passwords." homepage: https://openclaw.ai metadata: {"openclaw":{"emoji":"🐦","requires":{"bins":["curl","python3"],"env":["AISA_API_KEY"]},"primaryEnv":"AISA_API_KEY"}} --- # OpenClaw Twitter 🐦 **Twitter/X data access and automation for autonomous agents. Powered by AIsa.** One API key. Full Twitter intelligence. ## What Can You Do? ### Monitor Influencers ```text "Get Elon Musk's latest tweets and notify me of any AI-related posts" ``` ### Track Trends ```text "What's trending on Twitter worldwide right now?" ``` ### Social Listening ```text "Search for tweets mentioning our product and analyze sentiment" ``` ### Competitor Intel ```text "Monitor @anthropic and @GoogleAI - alert me on new announcements" ``` ## Action Workflows This file does not define like / unlike / follow / unfollow logic directly. If the user asks to like, unlike, follow, or unfollow on X/Twitter, handle that workflow with `./references/engage_twitter.md`. **OAuth Authorization is required and must be obtained from `./references/post_twitter.md` before executing.** ## Posting Workflows This file does not define publishing logic. If the user asks to send, publish, or reply, or quote on X/Twitter, handle that workflow with `./references/post_twitter.md`. ## Quick Start ```bash export AISA_API_KEY="your-key" ``` ## Core Capabilities ### Read Operations (No Login Required) #### User Endpoints ```bash # Get user info curl "https://api.aisa.one/apis/v1/twitter/user/info?userName=elonmusk" \ -H "Authorization: Bearer $AISA_API_KEY" # Get user profile about (account country, verification, username changes) curl "https://api.aisa.one/apis/v1/twitter/user_about?userName=elonmusk" \ -H "Authorization: Bearer $AISA_API_KEY" # Batch get user info by IDs curl "https://api.aisa.one/apis/v1/twitter/user/batch_info_by_ids?userIds=44196397,123456" \ -H "Authorization: Bearer $AISA_API_KEY" # Get user's latest tweets curl "https://api.aisa.one/apis/v1/twitter/user/last_tweets?userName=elonmusk" \ -H "Authorization: Bearer $AISA_API_KEY" # Get user mentions curl "https://api.aisa.one/apis/v1/twitter/user/mentions?userName=elonmusk" \ -H "Authorization: Bearer $AISA_API_KEY" # Get user followers curl "https://api.aisa.one/apis/v1/twitter/user/followers?userName=elonmusk" \ -H "Authorization: Bearer $AISA_API_KEY" # Get user followings curl "https://api.aisa.one/apis/v1/twitter/user/followings?userName=elonmusk" \ -H "Authorization: Bearer $AISA_API_KEY" # Get user verified followers (requires user_id, not userName) curl "https://api.aisa.one/apis/v1/twitter/user/verifiedFollowers?user_id=44196397" \ -H "Authorization: Bearer $AISA_API_KEY" # Check follow relationship between two users curl "https://api.aisa.one/apis/v1/twitter/user/check_follow_relationship?source_user_name=elonmusk&target_user_name=BillGates" \ -H "Authorization: Bearer $AISA_API_KEY" # Search users by keyword curl "https://api.aisa.one/apis/v1/twitter/user/search?query=AI+researcher" \ -H "Authorization: Bearer $AISA_API_KEY" ``` #### Tweet Endpoints ```bash # Advanced tweet search (queryType is required: Latest or Top) curl "https://api.aisa.one/apis/v1/twitter/tweet/advanced_search?query=AI+agents&queryType=Latest" \ -H "Authorization: Bearer $AISA_API_KEY" # Search top tweets curl "https://api.aisa.one/apis/v1/twitter/tweet/advanced_search?query=AI+agents&queryType=Top" \ -H "Authorization: Bearer $AISA_API_KEY" # Get tweets by IDs (comma-separated) curl "https://api.aisa.one/apis/v1/twitter/tweets?tweet_ids=1895096451033985024" \ -H "Authorization: Bearer $AISA_API_KEY" # Get tweet replies curl "https://api.aisa.one/apis/v1/twitter/tweet/replies?tweetId=1895096451033985024" \ -H "Authorization: Bearer $AISA_API_KEY" # Get tweet quotes curl "https://api.aisa.one/apis/v1/twitter/tweet/quotes?tweetId=1895096451033985024" \ -H "Authorization: Bearer $AISA_API_KEY" # Get tweet retweeters curl "https://api.aisa.one/apis/v1/twitter/tweet/retweeters?tweetId=1895096451033985024" \ -H "Authorization: Bearer $AISA_API_KEY" # Get tweet thread context (full conversation thread) curl "https://api.aisa.one/apis/v1/twitter/tweet/thread_context?tweetId=1895096451033985024" \ -H "Authorization: Bearer $AISA_API_KEY" # Get article by tweet ID curl "https://api.aisa.one/apis/v1/twitter/article?tweet_id=1895096451033985024" \ -H "Authorization: Bearer $AISA_API_KEY" ``` #### Trends, Lists, Communities & Spaces ```bash # Get trending topics (worldwide) curl "https://api.aisa.one/apis/v1/twitter/trends?woeid=1" \ -H "Authorization: Bearer $AISA_API_KEY" # Get list members curl "https://api.aisa.one/apis/v1/twitter/list/members?list_id=1585430245762441216" \ -H "Authorization: Bearer $AISA_API_KEY" # Get list followers curl "https://api.aisa.one/apis/v1/twitter/list/followers?list_id=1585430245762441216" \ -H "Authorization: Bearer $AISA_API_KEY" # Get community info curl "https://api.aisa.one/apis/v1/twitter/community/info?community_id=1708485837274263614" \ -H "Authorization: Bearer $AISA_API_KEY" # Get community members curl "https://api.aisa.one/apis/v1/twitter/community/members?community_id=1708485837274263614" \ -H "Authorization: Bearer $AISA_API_KEY" # Get community moderators curl "https://api.aisa.one/apis/v1/twitter/community/moderators?community_id=1708485837274263614" \ -H "Authorization: Bearer $AISA_API_KEY" # Get community tweets curl "https://api.aisa.one/apis/v1/twitter/community/tweets?community_id=1708485837274263614" \ -H "Authorization: Bearer $AISA_API_KEY" # Search tweets from all communities curl "https://api.aisa.one/apis/v1/twitter/community/get_tweets_from_all_community?query=AI" \ -H "Authorization: Bearer $AISA_API_KEY" # Get Space detail curl "https://api.aisa.one/apis/v1/twitter/spaces/detail?space_id=1dRJZlbLkjexB" \ -H "Authorization: Bearer $AISA_API_KEY" ``` ## Python Client ```bash # User operations python3 {baseDir}/scripts/twitter_client.py user-info --username elonmusk python3 {baseDir}/scripts/twitter_client.py user-about --username elonmusk python3 {baseDir}/scripts/twitter_client.py tweets --username elonmusk python3 {baseDir}/scripts/twitter_client.py mentions --username elonmusk python3 {baseDir}/scripts/twitter_client.py followers --username elonmusk python3 {baseDir}/scripts/twitter_client.py followings --username elonmusk python3 {baseDir}/scripts/twitter_client.py verified-followers --user-id 44196397 python3 {baseDir}/scripts/twitter_client.py check-follow --source elonmusk --target BillGates # Search & discovery python3 {baseDir}/scripts/twitter_client.py search --query "AI agents" python3 {baseDir}/scripts/twitter_client.py search --query "AI agents" --type Top python3 {baseDir}/scripts/twitter_client.py user-search --query "AI researcher" python3 {baseDir}/scripts/twitter_client.py trends --woeid 1 # Tweet operations python3 {baseDir}/scripts/twitter_client.py detail --tweet-ids 1895096451033985024 python3 {baseDir}/scripts/twitter_client.py replies --tweet-id 1895096451033985024 python3 {baseDir}/scripts/twitter_client.py quotes --tweet-id 1895096451033985024 python3 {baseDir}/scripts/twitter_client.py retweeters --tweet-id 1895096451033985024 python3 {baseDir}/scripts/twitter_client.py thread --tweet-id 1895096451033985024 # List operations python3 {baseDir}/scripts/twitter_client.py list-members --list-id 1585430245762441216 python3 {baseDir}/scripts/twitter_client.py list-followers --list-id 1585430245762441216 # Community operations python3 {baseDir}/scripts/twitter_client.py community-info --community-id 1708485837274263614 python3 {baseDir}/scripts/twitter_client.py community-members --community-id 1708485837274263614 python3 {baseDir}/scripts/twitter_client.py community-tweets --community-id 1708485837274263614 python3 {baseDir}/scripts/twitter_client.py community-search --query "AI" # Engagement operations through the local relay python3 {baseDir}/scripts/twitter_engagement_client.py list-tweets --user "@elonmusk" --limit 10 python3 {baseDir}/scripts/twitter_engagement_client.py like-latest --user "@elonmusk" python3 {baseDir}/scripts/twitter_engagement_client.py unlike-latest --user "@elonmusk" python3 {baseDir}/scripts/twitter_engagement_client.py follow-user --user "@elonmusk" python3 {baseDir}/scripts/twitter_engagement_client.py unfollow-user --user "@elonmusk" ``` ## API Endpoints Reference ### Read Endpoints (GET) | Endpoint | Description | Key Params | |----------|-------------|------------| | `/twitter/user/info` | Get user profile | `userName` | | `/twitter/user_about` | Get user profile about | `userName` | | `/twitter/user/batch_info_by_ids` | Batch get users by IDs | `userIds` | | `/twitter/user/last_tweets` | Get user's recent tweets | `userName`, `cursor` | | `/twitter/user/mentions` | Get user mentions | `userName`, `cursor` | | `/twitter/user/followers` | Get user followers | `userName`, `cursor` | | `/twitter/user/followings` | Get user followings | `userName`, `cursor` | | `/twitter/user/verifiedFollowers` | Get verified followers | `user_id`, `cursor` | | [REDACTED_SECRET] | Check follow relationship | `source_user_name`, `target_user_name` | | `/twitter/user/search` | Search users by keyword | `query`, `cursor` | | `/twitter/tweet/advanced_search` | Advanced tweet search | `query`, `queryType` (Latest/Top), `cursor` | | `/twitter/tweets` | Get tweets by IDs | `tweet_ids` (comma-separated) | | `/twitter/tweet/replies` | Get tweet replies | `tweetId`, `cursor` | | `/twitter/tweet/quotes` | Get tweet quotes | `tweetId`, `cursor` | | `/twitter/tweet/retweeters` | Get tweet retweeters | `tweetId`, `cursor` | | `/twitter/tweet/thread_context` | Get tweet thread context | `tweetId`, `cursor` | | `/twitter/article` | Get article by tweet | `tweet_id` | | `/twitter/trends` | Get trending topics | `woeid` (1=worldwide) | | `/twitter/list/members` | Get list members | `list_id`, `cursor` | | `/twitter/list/followers` | Get list followers | `list_id`, `cursor` | | `/twitter/community/info` | Get community info | `community_id` | | `/twitter/community/members` | Get community members | `community_id`, `cursor` | | `/twitter/community/moderators` | Get community moderators | `community_id`, `cursor` | | `/twitter/community/tweets` | Get community tweets | `community_id`, `cursor` | | [REDACTED_SECRET] | Search all community tweets | `query`, `cursor` | | `/twitter/spaces/detail` | Get Space detail | `space_id` | ## Pricing | API | Cost | |-----|------| | Twitter read query | ~$0.0004 | Every response includes `usage.cost` and `usage.credits_remaining`. ## Get Started 1. Sign up at [aisa.one](https://aisa.one) 2. Get your API key 3. Add credits (pay-as-you-go) 4. Set environment variable: `export AISA_API_KEY="your-key"` ## Full API Reference See [API Reference](https://docs.aisa.one/reference/) for complete endpoint documentation.

path: README.md
content: # OpenClaw Twitter 🐦

Twitter/X intelligence and automation for autonomous agents. Powered by AIsa.

This skill provides comprehensive capabilities to **read, search, engage, write, and post (text & media)** on Twitter/X.

## Features

- **Read & Search**: Access user info, tweets, advanced search, trends, followers, lists, communities, and Spaces without requiring user login.
- **Engagement via Relay**: Like/unlike tweets and follow/unfollow users through the local OAuth relay service.
- **Write & Post (OAuth)**: Publish text, images, and videos, create threads, and quote/reply to tweets securely via user OAuth authorization.

## Installation

```bash
export AISA_API_KEY="your-key"
```

## Quick Start

### Read & Search
```bash
# Get user info and search tweets
python scripts/twitter_client.py user-info --username elonmusk
python scripts/twitter_client.py search --query "AI agents"
python scripts/twitter_client.py trends
```

### Post & Write (Requires OAuth)
```bash
# Publish a text post
python scripts/twitter_oauth_client.py post --text "Hello from OpenClaw!"

# Publish a post with media
python scripts/twitter_oauth_client.py post --text "Check out this image" --media-file ./photo.png
```

### Engagement (Requires OAuth Relay)
```bash
# Like the latest tweet from a user
python scripts/twitter_engagement_client.py like-latest --user "@elonmusk"

# Query recent tweets for indexed follow-up actions
python scripts/twitter_engagement_client.py list-tweets --user "@elonmusk" --limit 10

# Follow a user
python scripts/twitter_engagement_client.py follow-user --user "@elonmusk"
```

> **Note**: For detailed engagement workflows, please see [`./references/engage_twitter.md`](./references/engage_twitter.md). For publishing, authorization, and multi-chunk threading, see [`./references/post_twitter.md`](./references/post_twitter.md).

## Get API Key

Sign up at [aisa.one](https://aisa.one)

## Links

- [ClawHub](https://www.clawhub.com/aisa-one/openclaw-twitter)
- [API Reference](https://docs.aisa.one/reference/)


path: references/engage_twitter.md
content: # OpenClaw Twitter Engagement

**Like, unlike, follow, and unfollow X/Twitter content through the local relay service.**

This workflow is for engagement actions, not publishing.

If the user asks to send, post, reply, or quote on X/Twitter, use `./references/post_twitter.md`.

## What This Workflow Supports

### Like or Unlike the Latest Tweet
```text
Like Elon Musk's latest tweet
Remove the like from @AIsa's latest tweet
```

### Query Recent Tweets Then Operate by Index
```text
Show Elon Musk's latest 10 tweets
Like the second one
Unlike the fifth one
```

### Follow or Unfollow a User
```text
Follow Elon Musk
Unfollow @AIsa
Follow the author of the tweet we just looked at
```

## Quick Start

```bash
export AISA_API_KEY="your-key"
```

## Python Client

```bash
# List tweets for follow-up actions
python3 {baseDir}/scripts/twitter_engagement_client.py list-tweets --user "@elonmusk" --limit 10

# Like or unlike the latest tweet
python3 {baseDir}/scripts/twitter_engagement_client.py like-latest --user "@elonmusk"
python3 {baseDir}/scripts/twitter_engagement_client.py unlike-latest --user "@elonmusk"

# Like or unlike a tweet ID already resolved by OpenClaw context
python3 {baseDir}/scripts/twitter_engagement_client.py like-tweet --tweet-id "2040901249466593766" --label "Tweet #2" --username "elonmusk"
python3 {baseDir}/scripts/twitter_engagement_client.py unlike-tweet --tweet-id "2040901249466593766" --label "Tweet #5"

# Follow or unfollow a user
python3 {baseDir}/scripts/twitter_engagement_client.py follow-user --user "@elonmusk"
python3 {baseDir}/scripts/twitter_engagement_client.py unfollow-user --user "@elonmusk"

# Follow or unfollow a user ID already resolved by OpenClaw context
python3 {baseDir}/scripts/twitter_engagement_client.py follow-user-id --target-user-id "44196397" --username "elonmusk"
python3 {baseDir}/scripts/twitter_engagement_client.py unfollow-user-id --target-user-id "44196397" --username "elonmusk"
```

## Agent Instructions

When the user asks to like, unlike, follow, or unfollow on X/Twitter:

1. Do not ask the user for tweet IDs, tweet links, or user IDs.
2. If the user specifies a direct account such as `@elonmusk`, call the `--user` based command.
3. If the user asks for the latest tweet, use `like-latest` or `unlike-latest`.
4. If the user first asked to query tweets, keep the returned `tweets[]` structure in OpenClaw context.
5. For follow-up instructions like `Like the second one`, map the ordinal to the remembered `tweets[].index`, then call `like-tweet` or `unlike-tweet` with the resolved `tweet_id`.
6. For follow-up instructions like `Follow this author`, use the remembered tweet author's `author_id`, then call `follow-user-id` or `unfollow-user-id`.
7. If multiple user candidates match a natural-language name, stop and ask the user to confirm the account.
8. If the current conversation does not contain the required tweet or author context, tell the user you need a prior tweet query or a clearer target first.
9. Convert script results into natural conversational English for the final user response.

## Guardrails

- Do not ask the user to manually paste tweet links or IDs.
- Do not guess a target account when multiple candidates are returned.
- Do not fabricate tweet IDs or user IDs from natural language.
- Do not use local file-based conversation memory for this workflow; rely on OpenClaw context only.
- Do not claim the action succeeded unless the relay request actually returns success.
- Relay endpoints for this workflow are `/like_twitter`, `/unlike_twitter`, `/follow_twitter`, and `/unfollow_twitter`.


path: references/post_twitter.md
content: # OpenClaw Twitter OAuth

**OAuth-based X/Twitter posting for autonomous agents. Powered by AIsa.**

One API key. Simple authorization. Direct posting.

## What Can You Do?

### Publish Posts
```text
Help me post this to Twitter: Today we released a new version.
```

### Publish Image / Video Posts
```text
Post this image to X with the caption: Shipping day.
```

```text
Post this video to Twitter.
```

### Return Authorization Links When Needed
```text
Post this to X, and if authorization is needed, give me the approval link.
```

### Continue After Approval
```text
I already authorized Twitter. Post this update now.
```

## Quick Start

```bash
export AISA_API_KEY="your-key"
```

## Python Client

```bash
# Show current client configuration
python3 {baseDir}/scripts/twitter_oauth_client.py status

# Request an authorization link
python3 {baseDir}/scripts/twitter_oauth_client.py authorize

# Request an authorization link and open it in the browser
python3 {baseDir}/scripts/twitter_oauth_client.py authorize --open-browser

# Publish a text post
python3 {baseDir}/scripts/twitter_oauth_client.py post --text "Hello from Twitter OAuth"

# Publish an image-only post
python3 {baseDir}/scripts/twitter_oauth_client.py post --media-file ./workspace/photo.png

# Publish a video-only post
python3 {baseDir}/scripts/twitter_oauth_client.py post --media-file ./workspace/demo.mp4

# Publish an image with text
python3 {baseDir}/scripts/twitter_oauth_client.py post --text "Shipping day." --media-file ./workspace/photo.png

# Publish a video with text
python3 {baseDir}/scripts/twitter_oauth_client.py post --text "Demo clip" --media-file ./workspace/demo.mp4

# Publish a threaded post using reply relationships between chunks
python3 {baseDir}/scripts/twitter_oauth_client.py post --text "Hello from Twitter OAuth" --type reply

# Quote another tweet and include its link in the new post
python3 {baseDir}/scripts/twitter_oauth_client.py post --text "My take on this:" --type quote --quote-tweet-url "https://x.com/example/status/1888888888888888888"

# Start the thread from a specific external tweet
python3 {baseDir}/scripts/twitter_oauth_client.py post --text "Reply content" --type reply --in-reply-to-tweet-id "1888888888888888888"
```

## Core Behavior

This skill is designed around the user intent of posting to X/Twitter.

Recommended flow:

1. Try to publish the requested content first.
2. If posting fails because access has not been authorized yet, return an authorization link.
3. After the user completes authorization, publish using the authorized account.

### OpenClaw Attachment Flow

When the user drops image/video files into OpenClaw:

1. OpenClaw stores the attachment in the local workspace and provides the workspace file path to the skill.
2. The skill passes that local path through `--media-file <workspace_path>`.
3. The Python client reads the local file and sends it to the relay backend as `multipart/form-data`.
4. The relay backend uploads the media to Twitter/X and then publishes the tweet.
5. The skill returns the final publish result, including the tweet link or tweet ID when available.
6. If the user provides only one image, publish with only that single image. Do not duplicate it, infer extra images, or expand it into a multi-image post.
7. If the user provides media without any text, send a media-only post request. Do not synthesize, inject, or infer caption text.
8. For a normal single post with no threading context, do not send thread/relationship fields such as `type`.
9. When the user explicitly wants to quote another tweet, require the original tweet URL, append that URL to the published content, and use it as the quoted target.

## Commands

### `status`

Show the current local client configuration.

### `authorize`

Request an authorization link for the current user context.

### `post`

Publish a post.

#### Character Limit & Thread Splitting Rules:
1. Maximum 280 characters per tweet (Chinese/full-width characters/Emojis count as 1 character each);
2. If content exceeds 280 characters:
   - The Python client automatically splits content into chunks before publishing;
3. If any chunk fails to post, the multi-chunk publishing process stops and returns an error.
4. Media files are attached only to the first chunk of a multi-chunk post.

## Agent Instructions

When the user asks to publish content to X/Twitter:

1. Check whether `AISA_API_KEY` is configured.
2. Try `post` first when the user intent is to publish content.
3. If the user attached workspace files, pass each image/video path with `--media-file`.
4. If the user provided only one image attachment, pass only that one image once.
5. Support these combinations directly:
   - image only
   - video only
   - image + text
   - video + text
6. Default to `--type quote` for publishing. Only pass `--type reply` when the user explicitly says they want to use reply relationships for a threaded post.
7. In this skill, `--type reply` does not mean replying to a target tweet. It only controls how multi-chunk content is threaded.
8. If the user says things like `use reply mode to post: ...`, `post this in reply mode: ...`, or `reply-post this: ...`, run the `post` command directly with `--type reply`.
9. If the user explicitly wants to quote another tweet, require the tweet URL. Pass it with `--quote-tweet-url <url>`, and make sure that URL remains in the new post content.
10. If the user wants to reply to a specific external tweet, use `--type reply --in-reply-to-tweet-id <tweet_id>`.
11. Do not ask for a tweet link or tweet ID just because the user requested `reply`; only use `--in-reply-to-tweet-id` when the user explicitly wants to target a specific tweet.
12. If the user asks for a quote tweet but does not provide the quoted tweet URL, stop and ask for that URL. Do not silently fall back to an incomplete quote flow.
13. If the quoted tweet URL is missing, explain that the quote content will be incomplete and can still be constrained by mutual-follow visibility limits.
14. If posting indicates that authorization is required, run `authorize` and return the approval link.
15. Do not claim the post succeeded until the publish step actually succeeds.

## Guardrails

- Do not ask the user for their Twitter password.
- Do not use cookie-based login or proxy-based login unless the user explicitly asks for legacy behavior.
- Do not claim authorization succeeded just because an authorization URL was generated.
- Do not ask for a tweet link or tweet ID just because the user requested `reply`; use `--type reply` directly.
- If the user explicitly identifies a target tweet, use `--in-reply-to-tweet-id` to attach the new thread to that tweet.
- If the user explicitly wants to quote another tweet, require the tweet URL and pass `--quote-tweet-url`; do not substitute `--in-reply-to-tweet-id` for that flow.
- If the quote tweet URL is missing, do not proceed with a quote tweet. Explain that the quote would be incomplete and still affected by mutual-follow restrictions.
- Do not invent remote URLs for attachments; always use the provided local workspace file path with `--media-file`.
- If the user provides a single image attachment, do not duplicate it or turn it into a multi-image post.
- If the user did not provide tweet text, do not generate or attach any caption text.
- For a normal standalone image/video post, do not send quote/reply relationship fields.


path: scripts/twitter_client.py
content: #!/usr/bin/env python3
"""
OpenClaw Twitter - AIsa API Client
Twitter/X read APIs for autonomous agents.

Read operations use GET with [REDACTED_SECRET]

Usage (read):
    python twitter_client.py user-info --username <username>
    python twitter_client.py search --query <query> [--type Latest|Top]
    ...
"""

import argparse
import json
import os
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
import unicodedata
import webbrowser
from typing import Any, Dict, List, Optional


DEFAULT_RELAY_TIMEOUT = 30
TWITTER_MAX_WEIGHT = 280
TWITTER_URL_WEIGHT = 23
URL_PATTERN = re.compile(r"https?://\S+", re.IGNORECASE)


class TwitterClient:
    """OpenClaw Twitter - Twitter/X API Client."""

    BASE_URL = "https://api.aisa.one/apis/v1"

    def __init__(self, [REDACTED_SECRET] = None):
        """Initialize the client with an API key."""
        self.[REDACTED_SECRET] or os.environ.get("AISA_API_KEY")
        if not self.api_key:
            raise ValueError(
                "AISA_API_KEY is required. Set it via environment variable or pass to constructor."
            )

    def _request(
        self,
        method: str,
        endpoint: str,
        params: Optional[Dict[str, Any]] = None,
        data: Optional[Dict[str, Any]] = None,
    ) -> Dict[str, Any]:
        """Make an HTTP request to the AIsa API."""
        url = f"{self.BASE_URL}{endpoint}"

        if params:
            query_string = urllib.parse.urlencode(
                {k: v for k, v in params.items() if v is not None}
            )
            url = f"{url}?{query_string}"

        headers = {
            "Authorization": f"Bearer {self.api_key}",
            "Content-Type": "application/json",
            "User-Agent": "OpenClaw-Twitter/1.0",
        }

        request_data = None
        if method == "POST":
            body = data.copy() if data else {}
            body.setdefault("aisa_api_key", self.api_key)
            request_data = json.dumps(body).encode("utf-8")

        req = urllib.request.Request(url, data=request_data, headers=headers, method=method)

        try:
            with urllib.request.urlopen(req, timeout=60) as response:
                return json.loads(response.read().decode("utf-8"))
        except urllib.error.HTTPError as e:
            error_body = e.read().decode("utf-8")
            try:
                return json.loads(error_body)
            except json.JSONDecodeError:
                return {"success": False, "error": {"code": str(e.code), "message": error_body}}
        except urllib.error.URLError as e:
            return {"success": False, "error": {"code": "NETWORK_ERROR", "message": str(e.reason)}}

    # ==================== User Read APIs ====================

    def user_info(self, username: str) -> Dict[str, Any]:
        """Get Twitter user information by username."""
        return self._request("GET", "/twitter/user/info", params={"userName": username})

    def user_about(self, username: str) -> Dict[str, Any]:
        """Get user profile about page (account country, verification, etc.)."""
        return self._request("GET", "/twitter/user_about", params={"userName": username})

    def batch_user_info(self, user_ids: str) -> Dict[str, Any]:
        """Batch get user info by comma-separated user IDs."""
        return self._request("GET", "/twitter/user/batch_info_by_ids", params={"userIds": user_ids})

    def user_tweets(self, username: str, cursor: str = None) -> Dict[str, Any]:
        """Get latest tweets from a specific user."""
        return self._request("GET", "/twitter/user/last_tweets", params={"userName": username, "cursor": cursor})

    def user_mentions(self, username: str, cursor: str = None) -> Dict[str, Any]:
        """Get mentions of a user."""
        return self._request("GET", "/twitter/user/mentions", params={"userName": username, "cursor": cursor})

    def followers(self, username: str, cursor: str = None) -> Dict[str, Any]:
        """Get user followers."""
        return self._request("GET", "/twitter/user/followers", params={"userName": username, "cursor": cursor})

    def followings(self, username: str, cursor: str = None) -> Dict[str, Any]:
        """Get user followings."""
        return self._request("GET", "/twitter/user/followings", params={"userName": username, "cursor": cursor})

    def verified_followers(self, user_id: str, cursor: str = None) -> Dict[str, Any]:
        """Get verified followers of a user (requires user_id, not username)."""
        return self._request("GET", "/twitter/user/verifiedFollowers", params={"user_id": user_id, "cursor": cursor})

    def check_follow_relationship(self, source: str, target: str) -> Dict[str, Any]:
        """Check follow relationship between two users."""
        return self._request(
            "GET",
            "/twitter/user/check_follow_relationship",
            params={"source_user_name": source, "target_user_name": target},
        )

    def user_search(self, query: str, cursor: str = None) -> Dict[str, Any]:
        """Search for Twitter users by keyword."""
        return self._request("GET", "/twitter/user/search", params={"query": query, "cursor": cursor})

    # ==================== Tweet Read APIs ====================

    def search(self, query: str, query_type: str = "Latest", cursor: str = None) -> Dict[str, Any]:
        """Search for tweets matching a query."""
        return self._request(
            "GET",
            "/twitter/tweet/advanced_search",
            params={"query": query, "queryType": query_type, "cursor": cursor},
        )

    def tweet_detail(self, tweet_ids: str) -> Dict[str, Any]:
        """Get detailed information about tweets by IDs (comma-separated)."""
        return self._request("GET", "/twitter/tweets", params={"tweet_ids": tweet_ids})

    def tweet_replies(self, tweet_id: str, cursor: str = None) -> Dict[str, Any]:
        """Get replies to a tweet."""
        return self._request("GET", "/twitter/tweet/replies", params={"tweetId": tweet_id, "cursor": cursor})

    def tweet_quotes(self, tweet_id: str, cursor: str = None) -> Dict[str, Any]:
        """Get quotes of a tweet."""
        return self._request("GET", "/twitter/tweet/quotes", params={"tweetId": tweet_id, "cursor": cursor})

    def tweet_retweeters(self, tweet_id: str, cursor: str = None) -> Dict[str, Any]:
        """Get retweeters of a tweet."""
        return self._request("GET", "/twitter/tweet/retweeters", params={"tweetId": tweet_id, "cursor": cursor})

    def tweet_thread(self, tweet_id: str, cursor: str = None) -> Dict[str, Any]:
        """Get the full thread context of a tweet."""
        return self._request("GET", "/twitter/tweet/thread_context", params={"tweetId": tweet_id, "cursor": cursor})

    def article(self, tweet_id: str) -> Dict[str, Any]:
        """Get article content by tweet ID."""
        return self._request("GET", "/twitter/article", params={"tweet_id": tweet_id})

    # ==================== Trends, Lists, Communities, Spaces ====================

    def trends(self, woeid: int = 1) -> Dict[str, Any]:
        """Get current Twitter trending topics by WOEID (1 = worldwide)."""
        return self._request("GET", "/twitter/trends", params={"woeid": woeid})

    def list_members(self, list_id: str, cursor: str = None) -> Dict[str, Any]:
        """Get members of a Twitter list."""
        return self._request("GET", "/twitter/list/members", params={"list_id": list_id, "cursor": cursor})

    def list_followers(self, list_id: str, cursor: str = None) -> Dict[str, Any]:
        """Get followers of a Twitter list."""
        return self._request("GET", "/twitter/list/followers", params={"list_id": list_id, "cursor": cursor})

    def community_info(self, community_id: str) -> Dict[str, Any]:
        """Get community info by ID."""
        return self._request("GET", "/twitter/community/info", params={"community_id": community_id})

    def community_members(self, community_id: str, cursor: str = None) -> Dict[str, Any]:
        """Get community members."""
        return self._request(
            "GET", "/twitter/community/members", params={"community_id": community_id, "cursor": cursor}
        )

    def community_moderators(self, community_id: str, cursor: str = None) -> Dict[str, Any]:
        """Get community moderators."""
        return self._request(
            "GET", "/twitter/community/moderators", params={"community_id": community_id, "cursor": cursor}
        )

    def community_tweets(self, community_id: str, cursor: str = None) -> Dict[str, Any]:
        """Get community tweets."""
        return self._request(
            "GET", "/twitter/community/tweets", params={"community_id": community_id, "cursor": cursor}
        )

    def community_search(self, query: str, cursor: str = None) -> Dict[str, Any]:
        """Search tweets from all communities."""
        return self._request(
            "GET",
            "/twitter/community/get_tweets_from_all_community",
            params={"query": query, "cursor": cursor},
        )

    def space_detail(self, space_id: str) -> Dict[str, Any]:
        """Get Space detail by ID."""
        return self._request("GET", "/twitter/spaces/detail", params={"space_id": space_id})



def main():
    """Main CLI entry point."""
    parser = argparse.ArgumentParser(
        description="OpenClaw Twitter - Twitter/X read APIs",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )

    subparsers = parser.add_subparsers(dest="command", help="Command")

    # ---- User Read Commands ----

    p = subparsers.add_parser("user-info", help="Get user information")
    p.add_argument("--username", "-u", required=True)

    p = subparsers.add_parser("user-about", help="Get user profile about")
    p.add_argument("--username", "-u", required=True)

    p = subparsers.add_parser("batch-users", help="Batch get users by IDs")
    p.add_argument("--user-ids", required=True, help="Comma-separated user IDs")

    p = subparsers.add_parser("tweets", help="Get user's latest tweets")
    p.add_argument("--username", "-u", required=True)
    p.add_argument("--cursor", help="Pagination cursor")

    p = subparsers.add_parser("mentions", help="Get user mentions")
    p.add_argument("--username", "-u", required=True)
    p.add_argument("--cursor", help="Pagination cursor")

    p = subparsers.add_parser("followers", help="Get user followers")
    p.add_argument("--username", "-u", required=True)
    p.add_argument("--cursor", help="Pagination cursor")

    p = subparsers.add_parser("followings", help="Get user followings")
    p.add_argument("--username", "-u", required=True)
    p.add_argument("--cursor", help="Pagination cursor")

    p = subparsers.add_parser("verified-followers", help="Get verified followers")
    p.add_argument("--user-id", required=True, help="User ID (not username)")
    p.add_argument("--cursor", help="Pagination cursor")

    p = subparsers.add_parser("check-follow", help="Check follow relationship")
    p.add_argument("--source", required=True, help="Source username")
    p.add_argument("--target", required=True, help="Target username")

    # ---- Search & Discovery ----

    p = subparsers.add_parser("search", help="Search tweets")
    p.add_argument("--query", "-q", required=True)
    p.add_argument("--type", "-t", choices=["Latest", "Top"], default="Latest")
    p.add_argument("--cursor", help="Pagination cursor")

    p = subparsers.add_parser("user-search", help="Search users")
    p.add_argument("--query", "-q", required=True)
    p.add_argument("--cursor", help="Pagination cursor")

    p = subparsers.add_parser("trends", help="Get trending topics")
    p.add_argument("--woeid", "-w", type=int, default=1)

    # ---- Tweet Detail Commands ----

    p = subparsers.add_parser("detail", help="Get tweets by IDs")
    p.add_argument("--tweet-ids", required=True, help="Comma-separated tweet IDs")

    p = subparsers.add_parser("replies", help="Get tweet replies")
    p.add_argument("--tweet-id", required=True)
    p.add_argument("--cursor", help="Pagination cursor")

    p = subparsers.add_parser("quotes", help="Get tweet quotes")
    p.add_argument("--tweet-id", required=True)
    p.add_argument("--cursor", help="Pagination cursor")

    p = subparsers.add_parser("retweeters", help="Get tweet retweeters")
    p.add_argument("--tweet-id", required=True)
    p.add_argument("--cursor", help="Pagination cursor")

    p = subparsers.add_parser("thread", help="Get tweet thread context")
    p.add_argument("--tweet-id", required=True)
    p.add_argument("--cursor", help="Pagination cursor")

    p = subparsers.add_parser("article", help="Get article by tweet ID")
    p.add_argument("--tweet-id", required=True)

    # ---- List Commands ----

    p = subparsers.add_parser("list-members", help="Get list members")
    p.add_argument("--list-id", required=True)
    p.add_argument("--cursor", help="Pagination cursor")

    p = subparsers.add_parser("list-followers", help="Get list followers")
    p.add_argument("--list-id", required=True)
    p.add_argument("--cursor", help="Pagination cursor")

    # ---- Community Commands ----

    p = subparsers.add_parser("community-info", help="Get community info")
    p.add_argument("--community-id", required=True)

    p = subparsers.add_parser("community-members", help="Get community members")
    p.add_argument("--community-id", required=True)
    p.add_argument("--cursor", help="Pagination cursor")

    p = subparsers.add_parser("community-moderators", help="Get community moderators")
    p.add_argument("--community-id", required=True)
    p.add_argument("--cursor", help="Pagination cursor")

    p = subparsers.add_parser("community-tweets", help="Get community tweets")
    p.add_argument("--community-id", required=True)
    p.add_argument("--cursor", help="Pagination cursor")

    p = subparsers.add_parser("community-search", help="Search all community tweets")
    p.add_argument("--query", "-q", required=True)
    p.add_argument("--cursor", help="Pagination cursor")

    # ---- Spaces ----

    p = subparsers.add_parser("space-detail", help="Get Space detail")
    p.add_argument("--space-id", required=True)

    args = parser.parse_args()

    if not args.command:
        parser.print_help()
        sys.exit(1)

    try:
        client = TwitterClient()
    except ValueError as e:
        print(json.dumps({"success": False, "error": {"code": "AUTH_ERROR", "message": str(e)}}))
        sys.exit(1)

    result = None
    cmd = args.command

    if cmd == "user-info":
        result = client.user_info(args.username)
    elif cmd == "user-about":
        result = client.user_about(args.username)
    elif cmd == "batch-users":
        result = client.batch_user_info(args.user_ids)
    elif cmd == "tweets":
        result = client.user_tweets(args.username, getattr(args, "cursor", None))
    elif cmd == "mentions":
        result = client.user_mentions(args.username, getattr(args, "cursor", None))
    elif cmd == "followers":
        result = client.followers(args.username, getattr(args, "cursor", None))
    elif cmd == "followings":
        result = client.followings(args.username, getattr(args, "cursor", None))
    elif cmd == "verified-followers":
        result = client.verified_followers(args.user_id, getattr(args, "cursor", None))
    elif cmd == "check-follow":
        result = client.check_follow_relationship(args.source, args.target)
    elif cmd == "search":
        result = client.search(args.query, args.type, getattr(args, "cursor", None))
    elif cmd == "user-search":
        result = client.user_search(args.query, getattr(args, "cursor", None))
    elif cmd == "trends":
        result = client.trends(args.woeid)
    elif cmd == "detail":
        result = client.tweet_detail(args.tweet_ids)
    elif cmd == "replies":
        result = client.tweet_replies(args.tweet_id, getattr(args, "cursor", None))
    elif cmd == "quotes":
        result = client.tweet_quotes(args.tweet_id, getattr(args, "cursor", None))
    elif cmd == "retweeters":
        result = client.tweet_retweeters(args.tweet_id, getattr(args, "cursor", None))
    elif cmd == "thread":
        result = client.tweet_thread(args.tweet_id, getattr(args, "cursor", None))
    elif cmd == "article":
        result = client.article(args.tweet_id)
    elif cmd == "list-members":
        result = client.list_members(args.list_id, getattr(args, "cursor", None))
    elif cmd == "list-followers":
        result = client.list_followers(args.list_id, getattr(args, "cursor", None))
    elif cmd == "community-info":
        result = client.community_info(args.community_id)
    elif cmd == "community-members":
        result = client.community_members(args.community_id, getattr(args, "cursor", None))
    elif cmd == "community-moderators":
        result = client.community_moderators(args.community_id, getattr(args, "cursor", None))
    elif cmd == "community-tweets":
        result = client.community_tweets(args.community_id, getattr(args, "cursor", None))
    elif cmd == "community-search":
        result = client.community_search(args.query, getattr(args, "cursor", None))
    elif cmd == "space-detail":
        result = client.space_detail(args.space_id)

    if result:
        output = json.dumps(result, indent=2, ensure_ascii=False)
        try:
            print(output)
        except UnicodeEncodeError:
            print(json.dumps(result, indent=2, ensure_ascii=True))
        sys.exit(0 if result.get("success", True) else 1)


if __name__ == "__main__":
    main()


path: scripts/twitter_engagement_client.py
content: #!/usr/bin/env python3
"""
Twitter engagement client for local like/unlike/follow/unfollow relay actions.

Commands:
    python twitter_engagement_client.py list-tweets --user "@elonmusk" --limit 10
    python twitter_engagement_client.py like-latest --user "@elonmusk"
    python twitter_engagement_client.py unlike-latest --user "@elonmusk"
    python twitter_engagement_client.py like-tweet --tweet-id 123 --label "Tweet #2" --username elonmusk
    python twitter_engagement_client.py unlike-tweet --tweet-id 123 --label "Tweet #2" --username elonmusk
    python twitter_engagement_client.py follow-user --user "@elonmusk"
    python twitter_engagement_client.py unfollow-user --user "@elonmusk"
    python twitter_engagement_client.py follow-user-id --target-user-id 44196397 --username elonmusk
    python twitter_engagement_client.py unfollow-user-id --target-user-id 44196397 --username elonmusk
    python twitter_engagement_client.py status
"""

from __future__ import annotations

import argparse
import json
import sys
from typing import Any, Dict, Optional

from twitter_client import TwitterClient
from twitter_oauth_client import RelayConfigError, load_config, send_json_request


MAX_LIST_LIMIT = 20
DEFAULT_LIST_LIMIT = 10


def is_success_response(payload: Dict[str, Any]) -> bool:
    if not isinstance(payload, dict):
        return False
    status = str(payload.get("status", "")).lower()
    if status == "success":
        return True
    if payload.get("success") is True:
        return True
    code = payload.get("code")
    return code in {0, 200}


def emit_json(payload: Dict[str, Any]) -> None:
    output = json.dumps(payload, indent=2, ensure_ascii=False)
    try:
        print(output)
    except UnicodeEncodeError:
        print(json.dumps(payload, indent=2, ensure_ascii=True))


def fail(message: str, *, action: str, target: Optional[Dict[str, Any]] = None, **extra: Any) -> None:
    response = {
        "ok": False,
        "action": action,
        "message": message,
    }
    if target is not None:
        response["target"] = target
    response.update(extra)
    emit_json(response)
    sys.exit(1)


def build_target(kind: str, value: str, **extra: Any) -> Dict[str, Any]:
    target = {"type": kind, "value": value}
    target.update({key: val for key, val in extra.items() if val not in (None, "")})
    return target


def normalize_user_input(value: str) -> str:
    normalized = value.strip()
    if not normalized:
        raise RelayConfigError("User must not be empty.")
    if normalized.startswith("@"):
        normalized = normalized[1:]
    return normalized.strip()


def normalize_for_match(value: Optional[str]) -> str:
    return (value or "").strip().casefold()


def extract_resolved_user_from_info(response: Dict[str, Any]) -> Optional[Dict[str, str]]:
    data = response.get("data") if isinstance(response, dict) else None
    if not isinstance(data, dict):
        return None
    user_id = str(data.get("id") or "").strip()
    username = str(data.get("userName") or data.get("screen_name") or "").strip()
    display_name = str(data.get("name") or username or user_id).strip()
    if not user_id or not username:
        return None
    return {
        "user_id": user_id,
        "username": username,
        "display_name": display_name,
    }


def score_user_candidate(query: str, candidate: Dict[str, Any]) -> int:
    normalized_query = normalize_for_match(query)
    screen_name = normalize_for_match(candidate.get("screen_name") or candidate.get("userName") or candidate.get("username"))
    display_name = normalize_for_match(candidate.get("name"))

    score = 0
    if normalized_query == screen_name:
        score = max(score, 400)
    if normalized_query == display_name:
        score = max(score, 350)
    if screen_name.startswith(normalized_query) and normalized_query:
        score = max(score, 250)
    if display_name.startswith(normalized_query) and normalized_query:
        score = max(score, 225)
    if normalized_query and normalized_query in screen_name:
        score = max(score, 150)
    if normalized_query and normalized_query in display_name:
        score = max(score, 125)
    if candidate.get("verified") or candidate.get("isBlueVerified"):
        score += 10
    return score


def candidate_summary(candidate: Dict[str, Any]) -> Dict[str, str]:
    username = str(candidate.get("screen_name") or candidate.get("userName") or candidate.get("username") or "").strip()
    display_name = str(candidate.get("name") or username).strip()
    user_id = str(candidate.get("id") or "").strip()
    return {
        "user_id": user_id,
        "username": username,
        "display_name": display_name,
        "label": f"{display_name} (@{username})" if username else display_name,
    }


def resolve_user(client: TwitterClient, raw_user: str) -> Dict[str, Any]:
    normalized_user = normalize_user_input(raw_user)
    info_result = client.user_info(normalized_user)
    if is_success_response(info_result):
        resolved = extract_resolved_user_from_info(info_result)
        if resolved:
            return {
                "ok": True,
                "mode": "direct",
                "resolved_user": resolved,
                "raw_response": info_result,
            }

    search_result = client.user_search(normalized_user)
    users = search_result.get("users") if isinstance(search_result, dict) else None
    if not is_success_response(search_result) and not isinstance(users, list):
        return {
            "ok": False,
            "message": "Target user not found. Try a different username or a more specific account name.",
            "raw_response": search_result,
            "candidates": [],
        }

    ranked: list[tuple[int, Dict[str, Any]]] = []
    for user in users or []:
        if not isinstance(user, dict):
            continue
        score = score_user_candidate(normalized_user, user)
        if score <= 0:
            continue
        ranked.append((score, user))

    ranked.sort(key=lambda item: item[0], reverse=True)
    if not ranked:
        return {
            "ok": False,
            "message": "Target user not found. Try a different username or a more specific account name.",
            "raw_response": search_result,
            "candidates": [],
        }

    top_score = ranked[0][0]
    top_group = [candidate for score, candidate in ranked if score == top_score]
    if len(top_group) > 1:
        return {
            "ok": False,
            "message": "I found multiple possible accounts. Please confirm which user you mean.",
            "raw_response": search_result,
            "candidates": [candidate_summary(item) for item in top_group[:3]],
        }

    best = ranked[0][1]
    summary = candidate_summary(best)
    if not summary["user_id"] or not summary["username"]:
        return {
            "ok": False,
            "message": "The user search result is missing required fields, so the action cannot continue right now.",
            "raw_response": search_result,
            "candidates": [summary],
        }

    return {
        "ok": True,
        "mode": "search",
        "resolved_user": {
            "user_id": summary["user_id"],
            "username": summary["username"],
            "display_name": summary["display_name"],
        },
        "raw_response": search_result,
    }


def extract_tweets(response: Dict[str, Any], limit: int) -> list[Dict[str, Any]]:
    data = response.get("data") if isinstance(response, dict) else None
    tweets = data.get("tweets") if isinstance(data, dict) else None
    items: list[Dict[str, Any]] = []
    for index, tweet in enumerate((tweets or [])[:limit], start=1):
        if not isinstance(tweet, dict):
            continue
        author = tweet.get("author") if isinstance(tweet.get("author"), dict) else {}
        tweet_id = str(tweet.get("id") or "").strip()
        if not tweet_id:
            continue
        items.append(
            {
                "index": index,
                "tweet_id": tweet_id,
                "text": str(tweet.get("text") or "").strip(),
                "created_at": str(tweet.get("createdAt") or "").strip(),
                "author_id": str(author.get("id") or "").strip(),
                "author_username": str(author.get("userName") or "").strip(),
                "author_name": str(author.get("name") or author.get("userName") or "").strip(),
                "tweet_url": str(tweet.get("url") or tweet.get("twitterUrl") or "").strip(),
            }
        )
    return items


def fetch_latest_tweets(client: TwitterClient, username: str, limit: int) -> Dict[str, Any]:
    result = client.user_tweets(username)
    if not is_success_response(result):
        return {
            "ok": False,
            "message": "Tweet lookup failed. Please try again later.",
            "raw_response": result,
            "tweets": [],
        }
    tweets = extract_tweets(result, limit)
    if not tweets:
        return {
            "ok": False,
            "message": f"@{username} does not have any tweets available for this action right now.",
            "raw_response": result,
            "tweets": [],
        }
    return {
        "ok": True,
        "raw_response": result,
        "tweets": tweets,
    }


def relay_action(config: Dict[str, Any], endpoint: str, payload: Dict[str, Any]) -> Dict[str, Any]:
    return send_json_request(
        f"{config['base_url']}{endpoint}",
        payload,
        timeout=config["timeout"],
        aisa_api_key=config["aisa_api_key"],
    )


def format_action_message(action: str, *, username: Optional[str] = None, label: Optional[str] = None, msg: Optional[str] = None) -> str:
    username_text = f"@{username}" if username else "that user"
    label_text = label or "that tweet"
    if action == "like_latest":
        return f"Liked the latest tweet from {username_text}."
    if action == "unlike_latest":
        return f"Removed the like from the latest tweet by {username_text}."
    if action == "like_tweet":
        return f"Liked {label_text}."
    if action == "unlike_tweet":
        return f"Removed the like from {label_text}."
    if action == "follow_user":
        return f"Followed {username_text}."
    if action == "unfollow_user":
        return f"Unfollowed {username_text}."
    if action == "like_latest_failed":
        return f"Like failed: {msg or 'please try again later'}"
    if action == "unlike_latest_failed":
        return f"Unlike failed: {msg or 'please try again later'}"
    if action == "like_tweet_failed":
        return f"Like failed: {msg or 'please try again later'}"
    if action == "unlike_tweet_failed":
        return f"Unlike failed: {msg or 'please try again later'}"
    if action == "follow_user_failed":
        return f"Follow failed: {msg or 'please try again later'}"
    if action == "unfollow_user_failed":
        return f"Unfollow failed: {msg or 'please try again later'}"
    return msg or "Action completed."


def print_json(response: Dict[str, Any], exit_code: int = 0) -> None:
    emit_json(response)
    sys.exit(exit_code)


def command_list_tweets(args: argparse.Namespace) -> None:
    client = TwitterClient([REDACTED_SECRET])
    user_resolution = resolve_user(client, args.user)
    if not user_resolution["ok"]:
        fail(
            user_resolution["message"],
            action="list_tweets",
            target=build_target("user_query", args.user),
            candidates=user_resolution.get("candidates", []),
            raw_response=user_resolution.get("raw_response"),
        )

    resolved_user = user_resolution["resolved_user"]
    tweets_result = fetch_latest_tweets(client, resolved_user["username"], args.limit)
    if not tweets_result["ok"]:
        fail(
            tweets_result["message"],
            action="list_tweets",
            target=build_target("user", resolved_user["username"], user_id=resolved_user["user_id"]),
            resolved_user=resolved_user,
            raw_response=tweets_result["raw_response"],
        )

    response = {
        "ok": True,
        "action": "list_tweets",
        "target": build_target("user", resolved_user["username"], user_id=resolved_user["user_id"]),
        "resolved_user": resolved_user,
        "tweets": tweets_result["tweets"],
        "message": f"Found {len(tweets_result['tweets'])} recent tweets from @{resolved_user['username']}.",
        "raw_response": tweets_result["raw_response"],
    }
    print_json(response)


def execute_tweet_action(
    args: argparse.Namespace,
    *,
    endpoint: str,
    action: str,
    tweet_id: str,
    username: Optional[str] = None,
    label: Optional[str] = None,
    resolved_tweet: Optional[Dict[str, Any]] = None,
) -> None:
    config = load_config(args)
    payload = {
        "aisa_api_key": config["aisa_api_key"],
        "tweet_id": tweet_id.strip(),
    }
    relay_result = relay_action(config, endpoint, payload)
    ok = relay_result.get("ok") is not False and is_success_response(relay_result)
    response = {
        "ok": ok,
        "action": action,
        "target": build_target("tweet", tweet_id, username=username, label=label),
        "message": format_action_message(
            action if ok else f"{action}_failed",
            username=username,
            label=label,
            msg=relay_result.get("msg"),
        ),
        "raw_response": relay_result,
    }
    if resolved_tweet:
        response["resolved_tweet"] = resolved_tweet
    print_json(response, exit_code=0 if ok else 1)


def execute_user_action(
    args: argparse.Namespace,
    *,
    endpoint: str,
    action: str,
    target_user_id: str,
    username: Optional[str] = None,
    resolved_user: Optional[Dict[str, Any]] = None,
) -> None:
    config = load_config(args)
    payload = {
        "aisa_api_key": config["aisa_api_key"],
        "target_user_id": target_user_id.strip(),
    }
    relay_result = relay_action(config, endpoint, payload)
    ok = relay_result.get("ok") is not False and is_success_response(relay_result)
    response = {
        "ok": ok,
        "action": action,
        "target": build_target("user", username or target_user_id, user_id=target_user_id),
        "message": format_action_message(
            action if ok else f"{action}_failed",
            username=username,
            msg=relay_result.get("msg"),
        ),
        "raw_response": relay_result,
    }
    if resolved_user:
        response["resolved_user"] = resolved_user
    print_json(response, exit_code=0 if ok else 1)


def command_like_latest(args: argparse.Namespace) -> None:
    client = TwitterClient([REDACTED_SECRET])
    user_resolution = resolve_user(client, args.user)
    if not user_resolution["ok"]:
        fail(
            user_resolution["message"],
            action=args.command.replace("-", "_"),
            target=build_target("user_query", args.user),
            candidates=user_resolution.get("candidates", []),
            raw_response=user_resolution.get("raw_response"),
        )
    resolved_user = user_resolution["resolved_user"]
    tweets_result = fetch_latest_tweets(client, resolved_user["username"], 1)
    if not tweets_result["ok"]:
        fail(
            tweets_result["message"],
            action=args.command.replace("-", "_"),
            target=build_target("user", resolved_user["username"], user_id=resolved_user["user_id"]),
            resolved_user=resolved_user,
            raw_response=tweets_result["raw_response"],
        )
    latest_tweet = tweets_result["tweets"][0]
    execute_tweet_action(
        args,
        endpoint="/like_twitter" if args.command == "like-latest" else "/unlike_twitter",
        action="like_latest" if args.command == "like-latest" else "unlike_latest",
        tweet_id=latest_tweet["tweet_id"],
        username=resolved_user["username"],
        label="the latest tweet",
        resolved_tweet=latest_tweet,
    )


def command_like_tweet(args: argparse.Namespace) -> None:
    label = (args.label or "that tweet").strip()
    execute_tweet_action(
        args,
        endpoint="/like_twitter" if args.command == "like-tweet" else "/unlike_twitter",
        action="like_tweet" if args.command == "like-tweet" else "unlike_tweet",
        tweet_id=args.tweet_id,
        username=(args.username or "").strip() or None,
        label=label,
    )


def command_follow_user(args: argparse.Namespace) -> None:
    client = TwitterClient([REDACTED_SECRET])
    user_resolution = resolve_user(client, args.user)
    if not user_resolution["ok"]:
        fail(
            user_resolution["message"],
            action=args.command.replace("-", "_"),
            target=build_target("user_query", args.user),
            candidates=user_resolution.get("candidates", []),
            raw_response=user_resolution.get("raw_response"),
        )
    resolved_user = user_resolution["resolved_user"]
    execute_user_action(
        args,
        endpoint="/follow_twitter" if args.command == "follow-user" else "/unfollow_twitter",
        action="follow_user" if args.command == "follow-user" else "unfollow_user",
        target_user_id=resolved_user["user_id"],
        username=resolved_user["username"],
        resolved_user=resolved_user,
    )


def command_follow_user_id(args: argparse.Namespace) -> None:
    execute_user_action(
        args,
        endpoint="/follow_twitter" if args.command == "follow-user-id" else "/unfollow_twitter",
        action="follow_user" if args.command == "follow-user-id" else "unfollow_user",
        target_user_id=args.target_user_id,
        username=(args.username or "").strip() or None,
    )


def command_status(args: argparse.Namespace) -> None:
    config = load_config(args)
    response = {
        "ok": True,
        "relay_base_url": config["base_url"],
        "aisa_api_key": config["aisa_api_key"],
        "timeout": config["timeout"],
        "supported_commands": [
            "list-tweets",
            "like-latest",
            "unlike-latest",
            "like-tweet",
            "unlike-tweet",
            "follow-user",
            "unfollow-user",
            "follow-user-id",
            "unfollow-user-id",
            "status",
        ],
        "supported_endpoints": [
            "/like_twitter",
            "/unlike_twitter",
            "/follow_twitter",
            "/unfollow_twitter",
        ],
    }
    print_json(response)


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description="Twitter engagement client for local relay like/follow actions",
    )
    parser.add_argument("--aisa-api-key", help="Override AISA_API_KEY")
    parser.add_argument("--timeout", type=int, help="Override TWITTER_RELAY_TIMEOUT")

    subparsers = parser.add_subparsers(dest="command", required=True)

    list_tweets = subparsers.add_parser("list-tweets", help="List a user's latest tweets for follow-up actions")
    list_tweets.add_argument("--user", required=True, help="Twitter username or display name")
    list_tweets.add_argument(
        "--limit",
        type=int,
        default=DEFAULT_LIST_LIMIT,
        help=f"How many tweets to return (1-{MAX_LIST_LIMIT}, default: {DEFAULT_LIST_LIMIT})",
    )
    list_tweets.set_defaults(func=command_list_tweets)

    like_latest = subparsers.add_parser("like-latest", help="Like the user's latest tweet")
    like_latest.add_argument("--user", required=True, help="Twitter username or display name")
    like_latest.set_defaults(func=command_like_latest)

    unlike_latest = subparsers.add_parser("unlike-latest", help="Unlike the user's latest tweet")
    unlike_latest.add_argument("--user", required=True, help="Twitter username or display name")
    unlike_latest.set_defaults(func=command_like_latest)

    like_tweet = subparsers.add_parser("like-tweet", help="Like a specific tweet ID")
    like_tweet.add_argument("--tweet-id", required=True, help="Tweet ID resolved by OpenClaw context")
    like_tweet.add_argument("--username", help="Optional username for natural-language response")
    like_tweet.add_argument("--label", help='Optional label such as "Tweet #2"')
    like_tweet.set_defaults(func=command_like_tweet)

    unlike_tweet = subparsers.add_parser("unlike-tweet", help="Unlike a specific tweet ID")
    unlike_tweet.add_argument("--tweet-id", required=True, help="Tweet ID resolved by OpenClaw context")
    unlike_tweet.add_argument("--username", help="Optional username for natural-language response")
    unlike_tweet.add_argument("--label", help='Optional label such as "Tweet #5"')
    unlike_tweet.set_defaults(func=command_like_tweet)

    follow_user = subparsers.add_parser("follow-user", help="Follow a user by username or display name")
    follow_user.add_argument("--user", required=True, help="Twitter username or display name")
    follow_user.set_defaults(func=command_follow_user)

    unfollow_user = subparsers.add_parser("unfollow-user", help="Unfollow a user by username or display name")
    unfollow_user.add_argument("--user", required=True, help="Twitter username or display name")
    unfollow_user.set_defaults(func=command_follow_user)

    follow_user_id = subparsers.add_parser("follow-user-id", help="Follow a user by resolved user ID")
    follow_user_id.add_argument("--target-user-id", required=True, help="User ID resolved by OpenClaw context")
    follow_user_id.add_argument("--username", help="Optional username for natural-language response")
    follow_user_id.set_defaults(func=command_follow_user_id)

    unfollow_user_id = subparsers.add_parser("unfollow-user-id", help="Unfollow a user by resolved user ID")
    unfollow_user_id.add_argument("--target-user-id", required=True, help="User ID resolved by OpenClaw context")
    unfollow_user_id.add_argument("--username", help="Optional username for natural-language response")
    unfollow_user_id.set_defaults(func=command_follow_user_id)

    status = subparsers.add_parser("status", help="Show current relay engagement configuration")
    status.set_defaults(func=command_status)

    return parser


def validate_args(args: argparse.Namespace) -> None:
    if getattr(args, "limit", DEFAULT_LIST_LIMIT) < 1:
        raise RelayConfigError("--limit must be at least 1.")
    if getattr(args, "limit", DEFAULT_LIST_LIMIT) > MAX_LIST_LIMIT:
        raise RelayConfigError(f"--limit must be at most {MAX_LIST_LIMIT}.")
    if getattr(args, "tweet_id", None) is not None and not str(args.tweet_id).strip():
        raise RelayConfigError("--tweet-id must not be empty.")
    if getattr(args, "target_user_id", None) is not None and not str(args.target_user_id).strip():
        raise RelayConfigError("--target-user-id must not be empty.")


def main() -> None:
    parser = build_parser()
    try:
        args = parser.parse_args()
        validate_args(args)
        args.func(args)
    except (RelayConfigError, ValueError) as exc:
        emit_json({"ok": False, "error": str(exc)})
        sys.exit(1)


if __name__ == "__main__":
    main()


path: scripts/twitter_oauth_client.py
content: #!/usr/bin/env python3
"""
Twitter relay client for local OAuth authorization and tweet publishing.

Commands:
    python twitter_oauth_client.py authorize [--callback-url <url>] [--open-browser]
    python twitter_oauth_client.py post [--text "Hello"] [--media-id <id> ...] [--media-file <path> ...] [--type <quote|reply>] [--quote-tweet-url <url>] [--in-reply-to-tweet-id <id>]
    python twitter_oauth_client.py status
"""

import argparse
import json
import mimetypes
import os
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
import unicodedata
import uuid
import webbrowser
from typing import Any, Dict, Optional


DEFAULT_TIMEOUT = 30
DEFAULT_BASE_URL = "https://api.aisa.one/apis/v1/twitter"
DEFAULT_CHROME_USER_AGENT = (
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
    "AppleWebKit/537.36 (KHTML, like Gecko) "
    "Chrome/136.0.0.0 Safari/537.36"
)



class RelayConfigError(ValueError):
    """Configuration is incomplete or invalid."""


def get_env(name: str, default: Optional[str] = None) -> Optional[str]:
    return os.environ.get(name, default)


def normalize_base_url(base_url: str) -> str:
    value = base_url.strip().rstrip("/")
    if not value:
        raise RelayConfigError("TWITTER_RELAY_BASE_URL is required.")
    parsed = urllib.parse.urlparse(value)
    if parsed.scheme not in {"http", "https"} or not parsed.netloc:
        raise RelayConfigError("TWITTER_RELAY_BASE_URL must be a valid http(s) URL.")
    return value


def load_config(args: argparse.Namespace) -> Dict[str, Any]:
    base_url = normalize_base_url(
        get_env("TWITTER_RELAY_BASE_URL", DEFAULT_BASE_URL)
    )
    aisa_api_key = getattr(args, "aisa_api_key", None) or get_env("AISA_API_KEY")
    timeout = getattr(args, "timeout", None) or int(get_env("TWITTER_RELAY_TIMEOUT", str(DEFAULT_TIMEOUT)))

    if not aisa_api_key:
        raise RelayConfigError("AISA_API_KEY is required.")

    return {
        "base_url": base_url,
        "aisa_api_key": aisa_api_key,
        "timeout": timeout,
    }


def parse_response_body(raw: str, status: Any, default_message: str) -> Dict[str, Any]:
    try:
        return json.loads(raw) if raw else {"code": status, "msg": default_message, "data": None}
    except json.JSONDecodeError:
        return {"code": status, "msg": raw or default_message, "data": None}


def build_auth_headers(aisa_api_key: str, extra_headers: Optional[Dict[str, str]] = None) -> Dict[str, str]:
    headers = {
        "Authorization": f"Bearer {aisa_api_key}",
        "User-Agent": DEFAULT_CHROME_USER_AGENT,
    }
    if extra_headers:
        headers.update(extra_headers)
    return headers


def send_json_request(
    url: str,
    payload: Dict[str, Any],
    timeout: int,
    aisa_api_key: str,
) -> Dict[str, Any]:
    request = urllib.request.Request(
        url,
        data=json.dumps(payload).encode("utf-8"),
        headers=build_auth_headers(
            aisa_api_key,
            {"Content-Type": "application/json", "Accept": "application/json"},
        ),
        method="POST",
    )
    try:
        with urllib.request.urlopen(request, timeout=timeout) as response:
            raw = response.read().decode("utf-8")
            return parse_response_body(raw, response.status, "ok")
    except urllib.error.HTTPError as exc:
        body = exc.read().decode("utf-8")
        payload = parse_response_body(body, exc.code, exc.reason)
        return {"ok": False, "status": exc.code, "payload": payload}
    except urllib.error.URLError as exc:
        return {
            "ok": False,
            "status": "NETWORK_ERROR",
            "payload": {"code": 503, "msg": str(exc.reason), "data": None},
        }


def encode_multipart_form_data(
    fields: Dict[str, Any],
    files: list[Dict[str, Any]],
) -> tuple[bytes, str]:
    boundary = f"----OpenClawTwitterBoundary{uuid.uuid4().hex}"
    body = bytearray()

    def append_text_part(name: str, value: Any) -> None:
        body.extend(f"--{boundary}\r\n".encode("utf-8"))
        body.extend(f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode("utf-8"))
        body.extend(str(value).encode("utf-8"))
        body.extend(b"\r\n")

    for field_name, field_value in fields.items():
        if field_value is None:
            continue
        if isinstance(field_value, list):
            for item in field_value:
                append_text_part(field_name, item)
            continue
        append_text_part(field_name, field_value)

    for file_payload in files:
        field_name = file_payload["field_name"]
        filename = file_payload["filename"]
        ascii_filename = "".join(ch if ord(ch) < 128 and ch not in {'"', "\\"} else "_" for ch in filename) or "upload.bin"
        quoted_filename = urllib.parse.quote(filename, safe="")
        content_type = file_payload["content_type"]

        body.extend(f"--{boundary}\r\n".encode("utf-8"))
        body.extend(
            (
                f'Content-Disposition: form-data; name="{field_name}"; '
                f'filename="{ascii_filename}"; filename*=UTF-8\'\'{quoted_filename}\r\n'
            ).encode("utf-8")
        )
        body.extend(f"Content-Type: {content_type}\r\n\r\n".encode("utf-8"))
        body.extend(file_payload["content"])
        body.extend(b"\r\n")

    body.extend(f"--{boundary}--\r\n".encode("utf-8"))
    return bytes(body), boundary


def send_multipart_request(
    url: str,
    fields: Dict[str, Any],
    files: list[Dict[str, Any]],
    timeout: int,
    aisa_api_key: str,
) -> Dict[str, Any]:
    body, boundary = encode_multipart_form_data(fields, files)
    request = urllib.request.Request(
        url,
        data=body,
        headers=build_auth_headers(
            aisa_api_key,
            {
                "Content-Type": f"multipart/form-data; boundary={boundary}",
                "Accept": "application/json",
            },
        ),
        method="POST",
    )
    try:
        with urllib.request.urlopen(request, timeout=timeout) as response:
            raw = response.read().decode("utf-8")
            return parse_response_body(raw, response.status, "ok")
    except urllib.error.HTTPError as exc:
        body = exc.read().decode("utf-8")
        payload = parse_response_body(body, exc.code, exc.reason)
        return {"ok": False, "status": exc.code, "payload": payload}
    except urllib.error.URLError as exc:
        return {
            "ok": False,
            "status": "NETWORK_ERROR",
            "payload": {"code": 503, "msg": str(exc.reason), "data": None},
        }


TWITTER_MAX_WEIGHT = 280
TWITTER_URL_WEIGHT = 23
URL_PATTERN = re.compile(r"https?://\S+", re.IGNORECASE)


def twitter_char_weight(ch: str) -> int:
    if unicodedata.east_asian_width(ch) in {"W", "F"}:
        return 2
    if unicodedata.category(ch).startswith("M"):
        return 0
    return 1


def twitter_weight_len(text: str) -> int:
    total = 0
    idx = 0
    for matched in URL_PATTERN.finditer(text):
        start, end = matched.span()
        while idx < start:
            total += twitter_char_weight(text[idx])
            idx += 1
        total += TWITTER_URL_WEIGHT
        idx = end

    while idx < len(text):
        total += twitter_char_weight(text[idx])
        idx += 1
    return total


def split_by_twitter_weight(text: str, max_len: int) -> list[str]:
    parts: list[str] = []
    current = ""
    for ch in text:
        candidate = current + ch
        if twitter_weight_len(candidate) <= max_len:
            current = candidate
            continue
        if current:
            parts.append(current)
        current = ch
    if current:
        parts.append(current)
    return parts


def split_text_for_twitter(text: str, max_len: int = TWITTER_MAX_WEIGHT) -> list[str]:
    normalized = text.strip()
    if not normalized:
        return []
    if twitter_weight_len(normalized) <= max_len:
        return [normalized]

    words = normalized.split()
    chunks: list[str] = []
    current = ""
    for word in words:
        if twitter_weight_len(word) > max_len:
            if current:
                chunks.append(current)
                current = ""
            chunks.extend(split_by_twitter_weight(word, max_len))
            continue

        candidate = word if not current else f"{current} {word}"
        if twitter_weight_len(candidate) <= max_len:
            current = candidate
        else:
            chunks.append(current)
            current = word

    if current:
        chunks.append(current)
    return chunks


def extract_tweet_id_from_url(url: str) -> str:
    parsed = urllib.parse.urlparse(url.strip())
    if parsed.scheme not in {"http", "https"}:
        raise RelayConfigError("Quote tweet URL must be a valid http(s) URL.")
    if parsed.netloc.lower() not in {"twitter.com", "www.twitter.com", "x.com", "www.x.com"}:
        raise RelayConfigError("Quote tweet URL must point to twitter.com or x.com.")

    match = re.search(r"/status/(\d+)", parsed.path)
    if not match:
        raise RelayConfigError("Quote tweet URL must contain a valid /status/<tweet_id> path.")
    return match.group(1)


def append_quote_url_to_content(content: str, quote_url: str) -> str:
    normalized_content = content.strip()
    normalized_quote_url = quote_url.strip()
    if not normalized_quote_url:
        return normalized_content
    if normalized_quote_url in normalized_content:
        return normalized_content
    if not normalized_content:
        return normalized_quote_url
    return f"{normalized_content}\n\n{normalized_quote_url}"


def extract_tweet_id(result: Dict[str, Any]) -> Optional[str]:
    data = result.get("data") if isinstance(result, dict) else None
    if not isinstance(data, dict):
        return None
    tweet_id = data.get("tweet_id")
    return str(tweet_id) if tweet_id else None


def publish_chunks(
    config: Dict[str, Any],
    chunks: list[str],
    media_ids: Optional[list[str]] = None,
    media_files: Optional[list[Dict[str, Any]]] = None,
    initial_parent_tweet_id: Optional[str] = None,
    post_type: str = "quote",
) -> Dict[str, Any]:
    should_thread = len(chunks) > 1
    previous_tweet_id = initial_parent_tweet_id
    publish_results = []

    for index, chunk in enumerate(chunks):
        current_media_ids = media_ids if index == 0 and media_ids else None
        current_media_files = media_files if index == 0 and media_files else None
        result = post_single_tweet(
            config,
            content=chunk,
            media_ids=current_media_ids,
            media_files=current_media_files,
            parent_tweet_id=previous_tweet_id,
            post_type=post_type,
        )
        publish_results.append(
            {
                "index": index + 1,
                "content": chunk,
                "parent_tweet_id": previous_tweet_id,
                "result": result,
            }
        )
        if result.get("ok") is False or result.get("code") != 200:
            return {
                "ok": False,
                "aisa_api_key": config["aisa_api_key"],
                "is_thread": should_thread,
                "total_chunks": len(chunks),
                "failed_at_chunk": index + 1,
                "results": publish_results,
            }

        latest_tweet_id = extract_tweet_id(result)
        if not latest_tweet_id:
            return {
                "ok": False,
                "aisa_api_key": config["aisa_api_key"],
                "is_thread": should_thread,
                "total_chunks": len(chunks),
                "failed_at_chunk": index + 1,
                "error": "Missing tweet_id in relay response.",
                "results": publish_results,
            }
        previous_tweet_id = latest_tweet_id

    return {
        "ok": True,
        "aisa_api_key": config["aisa_api_key"],
        "is_thread": should_thread,
        "total_chunks": len(chunks),
        "results": publish_results,
    }


def post_single_tweet(
    config: Dict[str, Any],
    *,
    content: Optional[str] = None,
    media_ids: Optional[list[str]] = None,
    media_files: Optional[list[Dict[str, Any]]] = None,
    parent_tweet_id: Optional[str] = None,
    post_type: Optional[str] = None,
) -> Dict[str, Any]:
    payload: Dict[str, Any] = {
        "aisa_api_key": config["aisa_api_key"],
    }
    if content:
        payload["content"] = content
    if post_type:
        payload["type"] = post_type
    if media_ids:
        payload["media_ids"] = media_ids
    if parent_tweet_id:
        parent_key = "in_reply_to_tweet_id" if post_type == "reply" else "quote_tweet_id"
        payload[parent_key] = parent_tweet_id

    endpoint = f"{config['base_url']}/post_twitter"
    if media_files:
        return send_multipart_request(
            endpoint,
            payload,
            media_files,
            timeout=config["timeout"],
            aisa_api_key=config["aisa_api_key"],
        )
    return send_json_request(
        endpoint,
        payload,
        timeout=config["timeout"],
        aisa_api_key=config["aisa_api_key"],
    )


def load_media_files(paths: Optional[list[str]]) -> list[Dict[str, Any]]:
    if not paths:
        return []

    media_files: list[Dict[str, Any]] = []
    media_kinds: set[str] = set()
    seen_paths: set[str] = set()

    for raw_path in paths:
        resolved_path = os.path.abspath(os.path.expanduser(raw_path))
        normalized_path = os.path.normcase(resolved_path)
        if normalized_path in seen_paths:
            continue
        seen_paths.add(normalized_path)

        if not os.path.exists(resolved_path):
            raise RelayConfigError(f"Media file does not exist: {raw_path}")
        if not os.path.isfile(resolved_path):
            raise RelayConfigError(f"Media path is not a file: {raw_path}")

        mime_type = mimetypes.guess_type(resolved_path)[0] or "application/octet-stream"
        media_kind = mime_type.split("/", 1)[0]
        if media_kind not in {"image", "video"}:
            raise RelayConfigError(
                f"Unsupported media type for {raw_path}: {mime_type}. Only image and video files are supported."
            )
        media_kinds.add(media_kind)

        with open(resolved_path, "rb") as file_handle:
            content = file_handle.read()

        media_files.append(
            {
                "field_name": "media_files",
                "filename": os.path.basename(resolved_path),
                "content_type": mime_type,
                "content": content,
            }
        )

    if len(media_kinds) > 1:
        raise RelayConfigError("Do not mix image and video files in a single post request.")

    return media_files


def command_authorize(args: argparse.Namespace) -> None:
    config = load_config(args)
    payload = {"aisa_api_key": config["aisa_api_key"]}
    result = send_json_request(
        f"{config['base_url']}/auth_twitter",
        payload,
        timeout=config["timeout"],
        aisa_api_key=config["aisa_api_key"],
    )

    if result.get("ok") is False:
        print(json.dumps(result, indent=2, ensure_ascii=False))
        sys.exit(1)

    auth_url = (result.get("data") or {}).get("auth_url")
    output = {
        "ok": result.get("code") == 200 and bool(auth_url),
        "aisa_api_key": config["aisa_api_key"],
        "authorization_url": auth_url,
        "raw_response": result,
    }
    print(json.dumps(output, indent=2, ensure_ascii=False))

    if output["ok"] and args.open_browser:
        webbrowser.open(auth_url)

    if not output["ok"]:
        sys.exit(1)


def command_post(args: argparse.Namespace) -> None:
    """Split oversized content locally, then publish chunks through the relay."""
    config = load_config(args)
    media_ids = getattr(args, "media_id", None) or []
    media_files = load_media_files(getattr(args, "media_file", None))
    normalized_text = (args.text or "").strip()
    quote_tweet_url = (getattr(args, "quote_tweet_url", None) or "").strip()
    initial_parent_tweet_id = None

    if not normalized_text and not media_ids and not media_files:
        print(
            json.dumps(
                {"ok": False, "error": "Post content must not be empty unless media files or media IDs are provided."},
                indent=2,
                ensure_ascii=False,
            )
        )
        sys.exit(1)

    if args.type == "quote":
        if args.in_reply_to_tweet_id:
            raise RelayConfigError(
                "Quote posts that reference another tweet must use --quote-tweet-url. "
                "The published post will include that link in its content."
            )
        if quote_tweet_url:
            initial_parent_tweet_id = extract_tweet_id_from_url(quote_tweet_url)
            normalized_text = append_quote_url_to_content(normalized_text, quote_tweet_url)
    else:
        if quote_tweet_url:
            raise RelayConfigError("--quote-tweet-url can only be used together with --type quote.")
        initial_parent_tweet_id = args.in_reply_to_tweet_id

    chunks = split_text_for_twitter(normalized_text) if normalized_text else [""]
    should_use_post_type = len(chunks) > 1 or bool(initial_parent_tweet_id)
    effective_post_type = None
    if should_use_post_type:
        effective_post_type = args.type
    output = publish_chunks(
        config,
        chunks,
        media_ids=media_ids,
        media_files=media_files,
        post_type=effective_post_type,
        initial_parent_tweet_id=initial_parent_tweet_id,
    )
    print(json.dumps(output, indent=2, ensure_ascii=False))
    if not output["ok"]:
        sys.exit(1)


def command_status(args: argparse.Namespace) -> None:
    config = load_config(args)
    response = {
        "ok": True,
        "relay_base_url": config["base_url"],
        "aisa_api_key": config["aisa_api_key"],
        "timeout": config["timeout"],
        "supported_commands": ["authorize", "post", "status"],
        "supported_endpoints": ["/auth_twitter", "/post_twitter"],
        "media_upload": {
            "field_name": "media_files",
            "transport": "multipart/form-data",
            "supported_media_types": ["image/*", "video/*"],
        },
    }
    print(json.dumps(response, indent=2, ensure_ascii=False))


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description="Twitter relay client for local OAuth and posting",
    )
    parser.add_argument("--aisa-api-key", help="Override AISA_API_KEY")
    parser.add_argument("--timeout", type=int, help="Override TWITTER_RELAY_TIMEOUT")

    subparsers = parser.add_subparsers(dest="command", required=True)

    authorize = subparsers.add_parser("authorize", help="Request an authorization URL from the relay service")
    authorize.add_argument("--open-browser", action="store_true", help="Open the authorization URL in the default browser")
    authorize.set_defaults(func=command_authorize)

    post = subparsers.add_parser("post", help="Publish a post through the relay service")
    post.add_argument("--text", default="", help="Post content. Optional when media is provided.")
    post.add_argument(
        "--media-id",
        action="append",
        help="Media ID to attach. Repeat the flag to send multiple media IDs.",
    )
    post.add_argument(
        "--media-file",
        action="append",
        help="Local image or video path to upload. Repeat the flag to attach multiple files of the same media kind.",
    )
    post.add_argument(
        "--type",
        choices=["quote", "reply"],
        default="quote",
        help="Relationship used to continue multi-chunk posts. Use quote to publish quote-style chains or reply for reply-style threading.",
    )
    post.add_argument(
        "--quote-tweet-url",
        help="Quoted tweet URL for quote-style posting. When provided, the URL is appended to the published content and used as the first quoted tweet.",
    )

    post.add_argument(
        "--in-reply-to-tweet-id",
        help="Optional external parent tweet ID for reply-style posting. When provided with --type reply, the first chunk starts from that tweet before continuing the thread.",
    )

    post.set_defaults(func=command_post)

    status = subparsers.add_parser("status", help="Show current relay client configuration")
    status.set_defaults(func=command_status)

    return parser


def main() -> None:
    parser = build_parser()
    args = parser.parse_args()

    try:
        args.func(args)
    except RelayConfigError as exc:
        print(json.dumps({"ok": False, "error": str(exc)}, indent=2, ensure_ascii=False))
        sys.exit(1)


if __name__ == "__main__":
    main()


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