Skill: baofeng-tech/aisa-twitter-research-engage-relay (version 1.0.6)

--- name: aisa-twitter-research-engage-relay description: 'Run Twitter/X likes, follows, replies, and OAuth-gated posting through AIsa. Use when: the user already knows which account, tweet, or campaign to act on and needs explicit engagement workflows. Supports read context, engagement actions, and approved posting.' author: AIsa version: 1.0.6 license: MIT-0 user-invocable: true primaryEnv: AISA_API_KEY requires: bins: - python3 env: - AISA_API_KEY metadata: aisa: emoji: 🐦 requires: bins: - python3 env: - AISA_API_KEY primaryEnv: AISA_API_KEY compatibility: - openclaw - claude-code - hermes openclaw: emoji: 🐦 requires: bins: - python3 env: - AISA_API_KEY primaryEnv: AISA_API_KEY --- # AIsa Twitter Engagement Suite Run Twitter/X likes, follows, replies, and OAuth-gated posting through AIsa. Use when: the user already knows which account, tweet, or campaign to act on and needs explicit engagement workflows. Supports read context, engagement actions, and approved posting. ## When to use - The user already knows which tweet, account, or campaign needs action. - The user needs explicit likes, follows, replies, or other engagement workflows after review. - The user wants approved posting and engagement without sharing passwords. ## High-Intent Workflows - Research the target, then like or follow with explicit approval. - Use engagement actions as follow-through after a post or campaign review. - Combine read context, posting, and engagement in one approved workflow. ## Quick Reference - `python3 scripts/twitter_client.py --help` - `python3 scripts/twitter_engagement_client.py --help` - `python3 scripts/twitter_oauth_client.py --help` ## Setup - `AISA_API_KEY` is required for AIsa-backed API access. - Use repo-relative `scripts/` paths from the shipped package. - Twitter/X reads, OAuth requests, and user-approved media uploads use the fixed AIsa API endpoint `https://api.aisa.one/apis/v1/twitter`. - Provide only `AISA_API_KEY`; do not use passwords, cookies, or browser credential export. ## Example Requests - Research a target account and like its latest tweet - Authorize a reply or follow-up action after a launch post - Run a reply, like, or follow workflow on a confirmed target ## Guardrails - Do not ask for passwords, cookies, or browser credentials. - Do not make likes, follows, replies, or uploads sound silent or automatic. - Do not claim an engagement action succeeded until the API confirms it. - Only upload local files the user explicitly attached, and make it clear those files are sent to AIsa's Twitter/X API endpoint.

path: README.md
content: # AIsa Twitter Research Engage Relay

Release-ready ClawHub skill package generated from `targetSkills/aisa-twitter-research-engage-relay`.

- Skill name: `aisa-twitter-research-engage-relay`
- Entry point: `SKILL.md`
- Runtime assets: `scripts/`, `references/`, `assets/` when present

## Notes

- Run Twitter/X likes, follows, replies, and OAuth-gated posting through AIsa. Use when: the user already knows which account, tweet, or campaign to act on and needs explicit engagement workflows. Supports read context, engagement actions, and approved posting.
- This README is release-specific and replaces source READMEs that were written for other runtimes.
- If the underlying instructions mention OpenClaw, treat that as source-context or compatibility guidance unless the skill is specifically about OpenClaw setup.

## Quick Start

1. Open `SKILL.md` to review invocation guidance and runtime requirements.
2. Set any required environment variables before running bundled scripts.
3. Use repo-relative paths like `python3 scripts/...` when following command examples.


path: references/engage_twitter.md
content: # AIsa Twitter Engagement

Like, unlike, follow, and unfollow X/Twitter content through the relay-backed runtime.

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`.

## Quick Start

```bash
export AISA_API_KEY="your-key"
```

## Python Client

```bash
python3 scripts/twitter_engagement_client.py list-tweets --user "@elonmusk" --limit 10
python3 scripts/twitter_engagement_client.py like-latest --user "@elonmusk"
python3 scripts/twitter_engagement_client.py unlike-latest --user "@elonmusk"
python3 scripts/twitter_engagement_client.py follow-user --user "@elonmusk"
python3 scripts/twitter_engagement_client.py unfollow-user --user "@elonmusk"
```

## Agent Instructions

When the user asks to like, unlike, follow, or unfollow on X/Twitter:

1. Do not ask the user to manually paste tweet links or IDs.
2. If the user first asked to query tweets, keep the returned `tweets[]` structure in the current agent context.
3. Map ordinal follow-up requests to remembered tweet or author context.
4. If multiple user candidates match a natural-language name, stop and ask the user to confirm the account.
5. Do not claim the action succeeded unless the relay request actually returns success.

## Guardrails

- Do not guess a target account when multiple candidates are returned.
- Do not fabricate tweet IDs or user IDs from natural language.
- Do not rely on custom local memory files for this workflow; rely on the active agent context.
- Relay endpoints for this workflow are `/like_twitter`, `/unlike_twitter`, `/follow_twitter`, and `/unfollow_twitter`.

## ClawHub Suspicious Review Notes

- Engagement actions are relay-based and go to `api.aisa.one`.
- Required secret: `AISA_API_KEY`.
- This workflow does not use passwords, browser cookies, cache sync, or home-directory persistence.


path: references/post_twitter.md
content: # AIsa Twitter OAuth

OAuth-based X/Twitter posting for autonomous agents through the AISA relay.

## When to use

- The user wants to publish, reply, or quote on X/Twitter.
- The user has approved OAuth or is ready to receive an authorization link.
- The user attached local image or video files that should be uploaded as part of the post.

## Quick Start

```bash
export AISA_API_KEY="your-key"
```

## Python Client

```bash
# Show current client configuration
python3 scripts/twitter_oauth_client.py status

# Request an authorization link
python3 scripts/twitter_oauth_client.py authorize

# Optional: open the authorization link in the default browser
python3 scripts/twitter_oauth_client.py authorize --open-browser

# Publish a text post
python3 scripts/twitter_oauth_client.py post --text "Hello from Twitter OAuth"

# Publish an image-only post
python3 scripts/twitter_oauth_client.py post --media-file ./workspace/photo.png

# Publish a video-only post
python3 scripts/twitter_oauth_client.py post --media-file ./workspace/demo.mp4

# Publish an image with text
python3 scripts/twitter_oauth_client.py post --text "Shipping day." --media-file ./workspace/photo.png

# Publish a video with text
python3 scripts/twitter_oauth_client.py post --text "Demo clip" --media-file ./workspace/demo.mp4

# Publish a threaded post using reply relationships between chunks
python3 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 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 scripts/twitter_oauth_client.py post --text "Reply content" --type reply --in-reply-to-tweet-id "1888888888888888888"
```

## Core Behavior

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.
4. Use `--open-browser` only when the user explicitly wants local browser launch instead of receiving the URL.

### Attachment Flow

When the user provides image or video files from the local workspace:

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.

## 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 or video path with `--media-file`.
4. If the user explicitly wants to quote another tweet, require the tweet URL and pass it with `--quote-tweet-url`.
5. If posting indicates that authorization is required, run `authorize` and return the approval link.
6. 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 default to `--open-browser`; return the authorization link unless the user explicitly wants local browser launch.
- 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.

## ClawHub Suspicious Review Notes

- Posting, OAuth, and approved media uploads are relay-based and go to `api.aisa.one`.
- Required secret: `AISA_API_KEY`.
- This workflow does not use passwords, browser cookies, cache sync, or home-directory persistence.


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
from typing import Any, Dict, List, Optional


REQUEST_TIMEOUT = 60
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."
            )

    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":
            request_data = json.dumps(data or {}).encode("utf-8")

        req = urllib.request.Request(url, data=request_data, headers=headers, method=method)

        try:
            with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT) 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 actions through AIsa API.

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()
    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()
    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()
    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_present": bool(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 AIsa like/follow actions",
    )

    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 prior agent 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 prior agent 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 prior agent 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 prior agent 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
from __future__ import annotations

"""
Twitter OAuth client for local authorization and tweet publishing through AIsa API.

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"
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("AIsa API base URL is required.")
    parsed = urllib.parse.urlparse(value)
    if parsed.scheme not in {"http", "https"} or not parsed.netloc:
        raise RelayConfigError("AIsa API 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(DEFAULT_BASE_URL)
    aisa_api_key = get_env("AISA_API_KEY")
    timeout = 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']}/twitter/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']}/twitter/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:
        print("Browser auto-open is disabled in the Claude release. Open authorization_url manually.")

    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_present": bool(config["aisa_api_key"]),
        "timeout": config["timeout"],
        "supported_commands": ["authorize", "post", "status"],
        "supported_endpoints": ["/twitter/auth_twitter", "/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 OAuth client for local AIsa posting flows",
    )

    subparsers = parser.add_subparsers(dest="command", required=True)

    authorize = subparsers.add_parser("authorize", help="Request an authorization URL from the AIsa 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 AIsa 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)