Skill: mcbaivn/blotato-post-everywhere (version 1.0.1)

--- name: blotato-post-everywhere description: Đăng bài lên đa nền tảng mạng xã hội qua Blotato API. Tự động adapt nội dung cho từng platform (Twitter thread, LinkedIn professional, Instagram caption, v.v.), hỗ trợ đăng ngay hoặc hẹn giờ. Use when: user muốn "đăng bài lên nhiều nền tảng", "post everywhere", "đăng Twitter LinkedIn Facebook cùng lúc", "hẹn giờ đăng bài", "schedule post Blotato", "tạo thread Twitter", "adapt content cho từng mạng xã hội". Platforms: twitter, linkedin, facebook, instagram, tiktok, threads, bluesky, pinterest, youtube. --- # Blotato Post Everywhere > 🚀 Chưa có tài khoản Blotato? Đăng ký tại: **https://blotato.com/?ref=MCBAIVN** Đăng nội dung lên nhiều nền tảng cùng lúc qua Blotato API, với content tự động adapt cho từng platform — Twitter thread, LinkedIn professional, Instagram caption, v.v. --- ## Bước 0: Tạo tài khoản Blotato Nếu chưa có tài khoản, đăng ký tại đây (miễn phí trial): 👉 **https://blotato.com/?ref=MCBAIVN** Sau khi có tài khoản: 1. Vào **Settings > Social Accounts** → kết nối các nền tảng muốn đăng 2. Vào **Settings > API** → click **Generate API Key** --- ## Setup (hỏi user lần đầu) Yêu cầu user cung cấp: 1. **Blotato API Key** — lấy tại https://my.blotato.com/settings > API > Generate API Key _(Chưa có tài khoản? → https://blotato.com/?ref=MCBAIVN)_ 2. **Nội dung gốc** muốn đăng 3. **Nền tảng** muốn đăng (`all` hoặc list: `twitter linkedin instagram`) 4. **Thời gian** đăng — ngay lập tức, hẹn giờ cụ thể, hoặc slot tiếp theo Lưu API key vào `TOOLS.md` sau khi user cung cấp (dưới mục `### Blotato`). --- ## Workflow ### 1. Kiểm tra accounts đã kết nối ```bash python scripts/blotato_post.py --api-key KEY --list-accounts ``` ### 2. Đăng ngay lập tức ```bash python scripts/blotato_post.py \ --api-key KEY \ --content "Nội dung bài viết gốc" \ --platforms twitter linkedin instagram ``` ### 3. Hẹn giờ đăng ```bash python scripts/blotato_post.py \ --api-key KEY \ --content "Nội dung bài viết" \ --platforms all \ --schedule "2026-05-01T09:00:00+07:00" ``` ### 4. Dùng slot lịch sẵn có ```bash python scripts/blotato_post.py \ --api-key KEY \ --content "Nội dung bài viết" \ --platforms twitter linkedin \ --use-next-slot ``` ### 5. Đăng kèm ảnh/video (local file hoặc URL) ```bash python scripts/blotato_post.py \ --api-key KEY \ --content "Caption bài viết" \ --platforms instagram facebook \ --media-urls "/path/to/photo.jpg" "https://example.com/video.mp4" ``` Script tự động phát hiện local file → upload qua presigned URL → lấy public URL → đăng. Hỗ trợ tới **1GB**. Có thể mix local path và public URL trong cùng 1 lệnh. --- ## Content Adaptation Script tự động: - **Twitter**: cắt thành thread nếu >280 ký tự - **Bluesky/Threads**: cắt theo limit 300/500 ký tự - **LinkedIn/Facebook**: giữ nguyên (giới hạn rất cao) - **Instagram/TikTok/Pinterest**: cắt nếu vượt limit **Nên làm thêm (agent):** Trước khi gọi script, dùng AI viết lại content phù hợp tone từng platform: - Twitter: ngắn, punchy, hashtag - LinkedIn: professional, storytelling - Instagram: emoji, visual-first, hashtags cuối bài - Bluesky: casual như Twitter --- ## Platform Notes - **Facebook/LinkedIn**: cần `pageId` — script tự fetch từ subaccounts - **Pinterest**: cần `boardId` — phải hỏi user (API không trả về) - **TikTok**: cần thêm `privacyLevel`, `disabledComments`, v.v. — xem `references/api.md` - **YouTube**: cần `title` và `privacyStatus` — hỏi user --- ## API Reference Xem chi tiết endpoint, params, limits: `references/api.md` --- ## Troubleshoot - Lỗi 401: API key sai hoặc hết hạn → kiểm tra tại https://my.blotato.com/settings - Platform skip: chưa kết nối account → https://my.blotato.com/settings > Social Accounts - Pinterest boardId: hỏi user lấy từ URL Pinterest board - Debug requests: https://my.blotato.com/api-dashboard --- > 💡 **Chưa có Blotato?** Đăng ký và dùng thử miễn phí tại: > **https://blotato.com/?ref=MCBAIVN**

path: references/api.md
content: # Blotato API Reference

Base URL: `https://backend.blotato.com/v2`
Auth Header: `blotato-[REDACTED_SECRET]`
Content-Type: `application/json`

All creation ops are ASYNC — submit then poll for status.

---

## Endpoints

### User / Accounts
- `GET /users/me` — verify key, get user info
- `GET /users/me/accounts` — list connected accounts (get `accountId`)
- `GET /users/me/accounts?platform=twitter` — filter by platform
- `GET /users/me/accounts/{accountId}/subaccounts` — get `pageId` (Facebook/LinkedIn) or playlist (YouTube)

### Publishing
- `POST /posts` — create/publish a post (30 req/min)
- `GET /posts/{postSubmissionId}` — poll post status (60 req/min)

### Schedules
- `GET /schedules` — list future scheduled posts
- `PATCH /schedules/{id}` — update scheduled post
- `DELETE /schedules/{id}` — delete scheduled post

### Schedule Slots
- `GET /schedule/slots` — list recurring time slots
- `POST /schedule/slots` — create slots
- `POST /schedule/slots/next-available` — find next open slot for platform/account

---

## POST /posts — Request Body

```json
{
  "post": {
    "accountId": "98432",
    "content": {
      "text": "Post text here",
      "mediaUrls": [],
      "platform": "twitter",
      "additionalPosts": []
    },
    "target": {
      "targetType": "twitter"
    }
  },
  "scheduledTime": "2026-03-10T15:30:00Z"
}
```

### Scheduling Options (top-level, NOT inside `post`)
- `scheduledTime`: ISO 8601 timestamp — schedule at specific time
- `useNextFreeSlot: true` — schedule at next calendar slot
- Neither: publish immediately

### Supported Platforms
`twitter`, `linkedin`, `facebook`, `instagram`, `pinterest`, `tiktok`, `threads`, `bluesky`, `youtube`

### Required `target` fields per platform

| Platform  | targetType    | Required extra fields                                                                 |
|-----------|---------------|---------------------------------------------------------------------------------------|
| Twitter   | twitter       | none                                                                                  |
| LinkedIn  | linkedin      | none (optional: `pageId` for company page)                                            |
| Facebook  | facebook      | `pageId`                                                                              |
| Instagram | instagram     | none (optional: `mediaType`)                                                          |
| TikTok    | tiktok        | `privacyLevel`, `disabledComments`, `disabledDuet`, `disabledStitch`, `isBrandedContent`, `isYourBrand`, `isAiGenerated` |
| Pinterest | pinterest     | `boardId`                                                                             |
| Threads   | threads       | none                                                                                  |
| Bluesky   | bluesky       | none                                                                                  |
| YouTube   | youtube       | `title`, `privacyStatus`, `shouldNotifySubscribers`                                  |

### Twitter Thread example
```json
{
  "content": {
    "text": "Tweet 1",
    "mediaUrls": [],
    "platform": "twitter",
    "additionalPosts": [
      {"text": "Tweet 2", "mediaUrls": []},
      {"text": "Tweet 3", "mediaUrls": []}
    ]
  }
}
```

### Notes
- `content.platform` must match `target.targetType`
- `pageId` for Facebook/LinkedIn: fetch from `/users/me/accounts/{id}/subaccounts`
- `boardId` for Pinterest: NOT available via API — must ask user
- Media: pass public URL in `mediaUrls` directly, no upload needed

---

## Media Upload

### Option 1: Public URL (simplest)
Pass directly into `mediaUrls` — Blotato handles the rest. No upload needed.

### Option 2: Upload from URL → Blotato storage
```
POST /media
{ "url": "https://example.com/image.jpg" }
→ { "url": "https://database.blotato.com/..." }
```
Limit: 200MB. Rate limit: 30 req/min.

### Option 3: Presigned Upload (local files, up to 1GB)
**Step 1:** Get presigned URL
```
POST /media/uploads
{ "filename": "photo.jpg" }
→ { "presignedUrl": "...", "publicUrl": "..." }
```
**Step 2:** PUT file to presignedUrl with correct Content-Type header
```
PUT {presignedUrl}
Content-Type: image/jpeg
[binary file data]
```
**Step 3:** Use `publicUrl` in `mediaUrls` when posting.

Rate limit: 120 req/min. Max size: 1GB.

---

 (for content adaptation)

| Platform  | Limit           |
|-----------|-----------------|
| Twitter/X | 280 chars/tweet (threads: multiple tweets) |
| LinkedIn  | 3000 chars      |
| Facebook  | 63206 chars     |
| Instagram | 2200 chars      |
| Threads   | 500 chars       |
| Bluesky   | 300 chars       |
| TikTok    | 2200 chars (caption) |
| Pinterest | 500 chars (desc)|
| YouTube   | 5000 chars (desc)|

## Platform Content Styles

| Platform  | Optimal style |
|-----------|---------------|
| Twitter/X | Short, punchy, hashtags, break into thread if long |
| LinkedIn  | Professional, story-driven, no excessive hashtags |
| Facebook  | Conversational, can be longer, emojis OK |
| Instagram | Visual-first, emoji-heavy, hashtags at end |
| Threads   | Casual, conversational |
| Bluesky   | Similar to Twitter, concise |
| TikTok    | Trendy, casual, hashtags |
| Pinterest | Descriptive, keyword-rich |
| YouTube   | Detailed description, keywords, timestamps if applicable |


path: scripts/blotato_post.py
content: #!/usr/bin/env python3
"""
blotato_post.py - Post content to multiple platforms via Blotato API

Usage:
  python blotato_post.py --help

Examples:
  python blotato_post.py --api-key KEY --platforms twitter linkedin --content "Your content"
  python blotato_post.py --api-key KEY --platforms all --content "Your content" --schedule "2026-05-01T10:00:00+07:00"
  python blotato_post.py --api-key KEY --platforms twitter --list-accounts
"""

import argparse
import json
import sys
import time
import os
import mimetypes
import urllib.request
import urllib.error

BASE_URL = "https://backend.blotato.com/v2"

PLATFORM_LIMITS = {
    "twitter": 280,
    "linkedin": 3000,
    "facebook": 63206,
    "instagram": 2200,
    "threads": 500,
    "bluesky": 300,
    "tiktok": 2200,
    "pinterest": 500,
    "youtube": 5000,
}

ALL_PLATFORMS = list(PLATFORM_LIMITS.keys())


def api_request(method, path, api_key, data=None):
    url = f"{BASE_URL}{path}"
    headers = {
        "blotato-api-key": api_key,
        "Content-Type": "application/json",
    }
    body = json.dumps(data).encode() if data else None
    req = urllib.request.Request(url, data=body, headers=headers, method=method)
    try:
        with urllib.request.urlopen(req) as resp:
            return json.loads(resp.read())
    except urllib.error.HTTPError as e:
        error_body = e.read().decode()
        print(f"[ERROR] HTTP {e.code}: {error_body}", file=sys.stderr)
        sys.exit(1)


def list_accounts(api_key):
    resp = api_request("GET", "/users/me/accounts", api_key)
    return resp.get("items", [])


def get_subaccounts(api_key, account_id):
    resp = api_request("GET", f"/users/me/accounts/{account_id}/subaccounts", api_key)
    return resp.get("items", [])


def adapt_content(original_text, platform):
    """Adapt content for platform limits and style."""
    limit = PLATFORM_LIMITS.get(platform, 500)
    text = original_text.strip()

    if platform == "twitter":
        if len(text) <= limit:
            return {"text": text, "additionalPosts": None}
        # Split into thread
        words = text.split()
        tweets = []
        current = ""
        for word in words:
            if len(current) + len(word) + 1 <= limit - 10:  # leave room for numbering
                current = (current + " " + word).strip()
            else:
                tweets.append(current)
                current = word
        if current:
            tweets.append(current)
        additional = [{"text": t, "mediaUrls": []} for t in tweets[1:]]
        return {"text": tweets[0], "additionalPosts": additional if additional else None}

    elif platform == "linkedin":
        # Professional tone hint kept — agent should adapt before calling
        if len(text) > limit:
            text = text[:limit - 3] + "..."
        return {"text": text, "additionalPosts": None}

    else:
        if len(text) > limit:
            text = text[:limit - 3] + "..."
        return {"text": text, "additionalPosts": None}


def upload_media_from_url(api_key, url):
    """Upload media to Blotato from a public URL. Returns hosted Blotato URL."""
    resp = api_request("POST", "/media", api_key, {"url": url})
    return resp.get("url")


def upload_local_file(api_key, file_path):
    """Upload a local file to Blotato via presigned URL. Returns public URL."""
    filename = os.path.basename(file_path)
    # Step 1: Get presigned URL
    resp = api_request("POST", "/media/uploads", api_key, {"filename": filename})
    presigned_url = resp.get("presignedUrl")
    public_url = resp.get("publicUrl")

    if not presigned_url:
        print(f"[ERROR] Failed to get presigned URL for {filename}", file=sys.stderr)
        sys.exit(1)

    # Step 2: PUT file to presigned URL
    mime_type, _ = mimetypes.guess_type(file_path)
    mime_type = mime_type or "application/octet-stream"

    with open(file_path, "rb") as f:
        file_data = f.read()

    req = urllib.request.Request(presigned_url, data=file_data, method="PUT")
    req.add_header("Content-Type", mime_type)
    try:
        with urllib.request.urlopen(req) as r:
            pass  # 200 OK = success
    except urllib.error.HTTPError as e:
        print(f"[ERROR] Upload failed: {e.code} {e.read().decode()}", file=sys.stderr)
        sys.exit(1)

    print(f"       Uploaded: {filename} → {public_url}")
    return public_url


def resolve_media(api_key, media_inputs):
    """
    Resolve a list of media inputs to public URLs.
    Each item can be:
      - local file path (e.g. /path/to/photo.jpg)
      - public URL (https://...)
    Returns list of public URLs ready for mediaUrls.
    """
    resolved = []
    for item in media_inputs:
        if item.startswith("http://") or item.startswith("https://"):
            resolved.append(item)
        elif os.path.isfile(item):
            print(f"[UPLOAD] Local file: {item}")
            url = upload_local_file(api_key, item)
            resolved.append(url)
        else:
            print(f"[WARN] Media not found, skipping: {item}")
    return resolved


def publish_post(api_key, account_id, platform, text, additional_posts=None,
                 media_urls=None, page_id=None, board_id=None,
                 scheduled_time=None, use_next_slot=False,
                 tiktok_opts=None, youtube_opts=None):
    content = {
        "text": text,
        "mediaUrls": media_urls or [],
        "platform": platform,
    }
    if additional_posts:
        content["additionalPosts"] = additional_posts

    target = {"targetType": platform}
    if page_id and platform in ("facebook", "linkedin"):
        target["pageId"] = page_id
    if board_id and platform == "pinterest":
        target["boardId"] = board_id
    if platform == "tiktok" and tiktok_opts:
        target.update(tiktok_opts)
    if platform == "youtube" and youtube_opts:
        target.update(youtube_opts)

    payload = {
        "post": {
            "accountId": account_id,
            "content": content,
            "target": target,
        }
    }

    if scheduled_time:
        payload["scheduledTime"] = scheduled_time
    elif use_next_slot:
        payload["useNextFreeSlot"] = True

    resp = api_request("POST", "/posts", api_key, payload)
    return resp


def poll_post_status(api_key, submission_id, max_wait=60):
    for _ in range(max_wait // 5):
        time.sleep(5)
        resp = api_request("GET", f"/posts/{submission_id}", api_key)
        status = resp.get("status", "unknown")
        if status in ("published", "scheduled", "failed", "error"):
            return resp
    return {"status": "timeout", "postSubmissionId": submission_id}


def main():
    parser = argparse.ArgumentParser(description="Post to multiple platforms via Blotato API")
    parser.add_argument("--api-key", required=True, help="Blotato API key")
    parser.add_argument("--content", help="Content to post (will be adapted per platform)")
    parser.add_argument("--platforms", nargs="+", default=["twitter"],
                        help=f"Platforms to post to. Use 'all' for all. Options: {', '.join(ALL_PLATFORMS)}")
    parser.add_argument("--media-urls", nargs="*", default=[], help="Public media URLs to attach")
    parser.add_argument("--schedule", help="ISO 8601 datetime to schedule (e.g. 2026-05-01T10:00:00+07:00)")
    parser.add_argument("--use-next-slot", action="store_true", help="Use next available calendar slot")
    parser.add_argument("--list-accounts", action="store_true", help="List connected accounts and exit")
    parser.add_argument("--no-poll", action="store_true", help="Don't poll for post status")
    parser.add_argument("--accounts-json", help="JSON file mapping platform->accountId (skip auto-detect)")
    args = parser.parse_args()

    if args.list_accounts:
        accounts = list_accounts(args.api_key)
        print(json.dumps(accounts, indent=2))
        return

    if not args.content:
        print("[ERROR] --content is required", file=sys.stderr)
        sys.exit(1)

    platforms = ALL_PLATFORMS if "all" in args.platforms else args.platforms

    # Resolve media (upload local files if needed)
    media_urls = resolve_media(args.api_key, args.media_urls) if args.media_urls else []
    account_map = {}
    if args.accounts_json:
        with open(args.accounts_json) as f:
            account_map = json.load(f)
    else:
        print("[INFO] Fetching connected accounts...")
        all_accounts = list_accounts(args.api_key)
        for acc in all_accounts:
            plat = acc["platform"]
            if plat not in account_map:
                account_map[plat] = {"accountId": acc["id"], "fullname": acc.get("fullname", "")}

    results = []
    for platform in platforms:
        if platform not in account_map:
            print(f"[SKIP] No account connected for: {platform}")
            continue

        acc_info = account_map[platform]
        account_id = acc_info["accountId"] if isinstance(acc_info, dict) else acc_info
        page_id = acc_info.get("pageId") if isinstance(acc_info, dict) else None

        # Auto-fetch pageId for Facebook/LinkedIn if not provided
        if platform in ("facebook", "linkedin") and not page_id:
            subaccs = get_subaccounts(args.api_key, account_id)
            if subaccs:
                page_id = subaccs[0]["id"]

        adapted = adapt_content(args.content, platform)
        text = adapted["text"]
        additional = adapted["additionalPosts"]

        print(f"[POST] {platform} | {len(text)} chars" + (" | thread" if additional else ""))

        resp = publish_post(
            [REDACTED_SECRET],
            account_id=account_id,
            platform=platform,
            text=text,
            additional_posts=additional,
            media_urls=media_urls,
            page_id=page_id,
            scheduled_time=args.schedule,
            use_next_slot=args.use_next_slot,
        )

        submission_id = resp.get("postSubmissionId") or resp.get("id")
        if not args.no_poll and submission_id:
            status_resp = poll_post_status(args.api_key, submission_id)
            resp["polledStatus"] = status_resp.get("status")
            print(f"       Status: {resp['polledStatus']}")
        else:
            print(f"       Submitted: {submission_id}")

        results.append({"platform": platform, "result": resp})

    print("\n[DONE] Summary:")
    for r in results:
        status = r["result"].get("polledStatus") or r["result"].get("status", "submitted")
        print(f"  {r['platform']:12} → {status}")


if __name__ == "__main__":
    main()


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