My custom setup for picoclaw

This commit is contained in:
Kristjan Kruus 2026-03-24 13:57:50 +02:00
parent 2a51fc7231
commit 61400eb783
6 changed files with 1110 additions and 3 deletions

View file

@ -73,12 +73,25 @@ RUN curl -LsSf https://astral.sh/uv/install.sh | sh && \
uv --version uv --version
# Install Python packages (system-wide via uv to avoid PEP 668 issues) # Install Python packages (system-wide via uv to avoid PEP 668 issues)
RUN uv pip install --system --break-system-packages atproto # atproto: Bluesky AT Protocol SDK
# moviepy: Programmatic video editing (timed text overlays, compositing)
# librosa: Audio analysis (beat detection, loudness, onset detection)
# pydub: Simple audio slicing/mixing
# srt: SRT subtitle generation for ffmpeg lyric burn-in
RUN uv pip install --system --break-system-packages atproto moviepy librosa pydub srt
# Install bsky CLI (Python wrapper around atproto SDK) # Install bsky CLI (Python wrapper around atproto SDK)
COPY docker/bsky.py /usr/local/bin/bsky COPY docker/bsky.py /usr/local/bin/bsky
RUN sed -i 's/\r$//' /usr/local/bin/bsky && chmod +x /usr/local/bin/bsky RUN sed -i 's/\r$//' /usr/local/bin/bsky && chmod +x /usr/local/bin/bsky
# Install threads CLI (Threads API wrapper)
COPY docker/threads.py /usr/local/bin/threads
RUN sed -i 's/\r$//' /usr/local/bin/threads && chmod +x /usr/local/bin/threads
# Install instagram CLI (Instagram Graph API wrapper)
COPY docker/instagram.py /usr/local/bin/instagram
RUN sed -i 's/\r$//' /usr/local/bin/instagram && chmod +x /usr/local/bin/instagram
# Health check # Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -q --spider http://localhost:18790/health || exit 1 CMD wget -q --spider http://localhost:18790/health || exit 1

View file

@ -10,11 +10,15 @@ Config is stored at $XDG_CONFIG_HOME/bsky/config.json (defaults to ~/.config/bsk
import argparse import argparse
import json import json
import os import os
import re
import sys import sys
import textwrap import textwrap
from pathlib import Path from pathlib import Path
from atproto import Client, models from atproto import Client, client_utils, models
# URL regex for detecting links in post text
_URL_RE = re.compile(r'https?://[^\s)>"]+')
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Config helpers # Config helpers
@ -190,10 +194,27 @@ def cmd_post(args) -> None:
if images: if images:
embed = models.AppBskyEmbedImages.Main(images=images) embed = models.AppBskyEmbedImages.Main(images=images)
resp = client.send_post(text=text, embed=embed) # Build rich text with clickable links
tb = _build_rich_text(text)
resp = client.send_post(text=tb, embed=embed)
print(f"Posted: {resp.uri}") print(f"Posted: {resp.uri}")
def _build_rich_text(text: str) -> client_utils.TextBuilder:
"""Parse URLs in text and return a TextBuilder with link facets."""
tb = client_utils.TextBuilder()
last_end = 0
for m in _URL_RE.finditer(text):
if m.start() > last_end:
tb.text(text[last_end:m.start()])
url = m.group(0)
tb.link(url, url)
last_end = m.end()
if last_end < len(text):
tb.text(text[last_end:])
return tb
def cmd_create_thread(args) -> None: def cmd_create_thread(args) -> None:
client = _client() client = _client()
texts = args.texts texts = args.texts

View file

@ -43,6 +43,9 @@ services:
- DISPLAY=:99 - DISPLAY=:99
- BSKY_HANDLE=${BSKY_HANDLE} - BSKY_HANDLE=${BSKY_HANDLE}
- BSKY_APP_PASSWORD=${BSKY_APP_PASSWORD} - BSKY_APP_PASSWORD=${BSKY_APP_PASSWORD}
- THREADS_ACCESS_TOKEN=${THREADS_ACCESS_TOKEN}
- INSTAGRAM_ACCESS_TOKEN=${INSTAGRAM_ACCESS_TOKEN}
- INSTAGRAM_USER_ID=${INSTAGRAM_USER_ID}
ports: ports:
- "127.0.0.1:18790:18790" - "127.0.0.1:18790:18790"
- "192.168.1.169:18790:18790" - "192.168.1.169:18790:18790"

449
docker/instagram.py Normal file
View file

@ -0,0 +1,449 @@
#!/usr/bin/env python3
"""instagram — Instagram CLI for picoclaw.
Thin wrapper around the Instagram Graph API (graph.facebook.com).
Installed to /usr/local/bin/instagram inside the Docker image
so the agent can call `instagram post`, `instagram whoami`, etc.
Auth: Set INSTAGRAM_ACCESS_TOKEN env var or pass --token.
User ID: Set INSTAGRAM_USER_ID env var or pass --user-id.
"""
import argparse
import json
import os
import sys
import time
import urllib.request
import urllib.parse
import urllib.error
API_BASE = "https://graph.instagram.com/v21.0"
# ---------------------------------------------------------------------------
# HTTP helpers (stdlib only — no extra deps needed)
# ---------------------------------------------------------------------------
def _get_token(args) -> str:
token = getattr(args, "token", None) or os.environ.get("INSTAGRAM_ACCESS_TOKEN")
if not token:
print("No access token. Set INSTAGRAM_ACCESS_TOKEN or pass --token.", file=sys.stderr)
sys.exit(1)
return token
def _get_user_id(args) -> str:
uid = getattr(args, "user_id", None) or os.environ.get("INSTAGRAM_USER_ID")
if not uid:
print("No user ID. Set INSTAGRAM_USER_ID or pass --user-id.", file=sys.stderr)
sys.exit(1)
return uid
def _api_get(path: str, params: dict) -> dict:
qs = urllib.parse.urlencode(params)
url = f"{API_BASE}/{path}?{qs}"
req = urllib.request.Request(url, method="GET")
try:
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
body = e.read().decode()
print(f"API error {e.code}: {body}", file=sys.stderr)
sys.exit(1)
def _api_post(path: str, params: dict) -> dict:
url = f"{API_BASE}/{path}"
data = urllib.parse.urlencode(params).encode()
req = urllib.request.Request(url, data=data, method="POST")
try:
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
body = e.read().decode()
print(f"API error {e.code}: {body}", file=sys.stderr)
sys.exit(1)
# ---------------------------------------------------------------------------
# Commands
# ---------------------------------------------------------------------------
def cmd_whoami(args) -> None:
token = _get_token(args)
uid = _get_user_id(args)
data = _api_get(uid, {
"fields": "id,username,name,biography,followers_count,media_count",
"access_token": token,
})
if args.json_output:
print(json.dumps(data, indent=2))
else:
print(f"User ID: {data.get('id', '?')}")
print(f"Username: @{data.get('username', '?')}")
if data.get("name"):
print(f"Name: {data['name']}")
if data.get("biography"):
print(f"Bio: {data['biography']}")
if data.get("followers_count") is not None:
print(f"Followers: {data['followers_count']}")
if data.get("media_count") is not None:
print(f"Posts: {data['media_count']}")
def cmd_post_image(args) -> None:
"""Post a single image (Instagram requires an image — no text-only posts)."""
token = _get_token(args)
uid = _get_user_id(args)
if args.dry_run:
print(f"[DRY RUN] Would post image:")
print(f" Caption: {args.caption}")
print(f" Image: {args.image_url}")
return
# Step 1: Create media container
create_params = {
"image_url": args.image_url,
"caption": args.caption,
"access_token": token,
}
result = _api_post(f"{uid}/media", create_params)
container_id = result.get("id")
if not container_id:
print(f"Failed to create container: {result}", file=sys.stderr)
sys.exit(1)
# Step 2: Wait for container to be ready
_wait_for_container(token, container_id)
# Step 3: Publish
publish_result = _api_post(f"{uid}/media_publish", {
"creation_id": container_id,
"access_token": token,
})
post_id = publish_result.get("id")
print(f"Posted: {post_id}")
def cmd_post_video(args) -> None:
"""Post a reel (video). Instagram treats all video uploads as Reels."""
token = _get_token(args)
uid = _get_user_id(args)
if args.dry_run:
print(f"[DRY RUN] Would post reel:")
print(f" Caption: {args.caption}")
print(f" Video: {args.video_url}")
return
# Step 1: Create media container (media_type=REELS for video)
create_params = {
"media_type": "REELS",
"video_url": args.video_url,
"caption": args.caption,
"access_token": token,
}
result = _api_post(f"{uid}/media", create_params)
container_id = result.get("id")
if not container_id:
print(f"Failed to create container: {result}", file=sys.stderr)
sys.exit(1)
# Step 2: Wait for video processing (can take a while)
_wait_for_container(token, container_id, max_wait=120)
# Step 3: Publish
publish_result = _api_post(f"{uid}/media_publish", {
"creation_id": container_id,
"access_token": token,
})
post_id = publish_result.get("id")
print(f"Posted: {post_id}")
def cmd_post_carousel(args) -> None:
"""Post a carousel (multiple images/videos)."""
token = _get_token(args)
uid = _get_user_id(args)
if args.dry_run:
print(f"[DRY RUN] Would post carousel:")
print(f" Caption: {args.caption}")
for i, url in enumerate(args.urls):
print(f" Item {i+1}: {url}")
return
# Step 1: Create individual item containers
children_ids = []
for url in args.urls:
# Detect if video (simple heuristic: extension)
is_video = any(url.lower().endswith(ext) for ext in (".mp4", ".mov", ".avi"))
if is_video:
params = {
"media_type": "VIDEO",
"video_url": url,
"is_carousel_item": "true",
"access_token": token,
}
else:
params = {
"image_url": url,
"is_carousel_item": "true",
"access_token": token,
}
result = _api_post(f"{uid}/media", params)
child_id = result.get("id")
if not child_id:
print(f"Failed to create carousel item: {result}", file=sys.stderr)
sys.exit(1)
_wait_for_container(token, child_id, max_wait=120 if is_video else 30)
children_ids.append(child_id)
# Step 2: Create carousel container
result = _api_post(f"{uid}/media", {
"media_type": "CAROUSEL",
"caption": args.caption,
"children": ",".join(children_ids),
"access_token": token,
})
container_id = result.get("id")
if not container_id:
print(f"Failed to create carousel container: {result}", file=sys.stderr)
sys.exit(1)
_wait_for_container(token, container_id)
# Step 3: Publish
publish_result = _api_post(f"{uid}/media_publish", {
"creation_id": container_id,
"access_token": token,
})
print(f"Posted carousel: {publish_result.get('id')}")
def cmd_post_story(args) -> None:
"""Post a story (image or video)."""
token = _get_token(args)
uid = _get_user_id(args)
if args.dry_run:
print(f"[DRY RUN] Would post story:")
print(f" Media: {args.media_url}")
return
is_video = any(args.media_url.lower().endswith(ext) for ext in (".mp4", ".mov", ".avi"))
create_params = {
"media_type": "STORIES",
"access_token": token,
}
if is_video:
create_params["video_url"] = args.media_url
else:
create_params["image_url"] = args.media_url
result = _api_post(f"{uid}/media", create_params)
container_id = result.get("id")
if not container_id:
print(f"Failed to create story container: {result}", file=sys.stderr)
sys.exit(1)
_wait_for_container(token, container_id, max_wait=120 if is_video else 30)
publish_result = _api_post(f"{uid}/media_publish", {
"creation_id": container_id,
"access_token": token,
})
print(f"Story posted: {publish_result.get('id')}")
def cmd_reply(args) -> None:
"""Reply to a comment on a post."""
token = _get_token(args)
result = _api_post(f"{args.comment_id}/replies", {
"message": args.text,
"access_token": token,
})
print(f"Replied: {result.get('id')}")
def cmd_posts(args) -> None:
"""List recent posts."""
token = _get_token(args)
uid = _get_user_id(args)
data = _api_get(f"{uid}/media", {
"fields": "id,caption,timestamp,media_type,permalink,like_count,comments_count",
"limit": args.n,
"access_token": token,
})
if args.json_output:
print(json.dumps(data, indent=2))
else:
for post in data.get("data", []):
ts = post.get("timestamp", "")
caption = post.get("caption", "(no caption)")
pid = post.get("id", "?")
mtype = post.get("media_type", "?")
likes = post.get("like_count", 0)
comments = post.get("comments_count", 0)
permalink = post.get("permalink", "")
print(f" [{ts}] {pid} ({mtype})")
for line in caption.splitlines()[:3]:
print(f" {line}")
print(f" Likes: {likes} Comments: {comments}")
if permalink:
print(f" {permalink}")
print()
def cmd_insights(args) -> None:
"""Get insights for a specific post."""
token = _get_token(args)
data = _api_get(f"{args.post_id}/insights", {
"metric": "impressions,reach,likes,comments,shares,saved",
"access_token": token,
})
if args.json_output:
print(json.dumps(data, indent=2))
else:
print(f"Insights for {args.post_id}:")
for item in data.get("data", []):
name = item.get("name", "?")
values = item.get("values", [{}])
val = values[0].get("value", "?") if values else "?"
print(f" {name}: {val}")
def cmd_profile(args) -> None:
"""View profile details."""
token = _get_token(args)
uid = _get_user_id(args)
data = _api_get(uid, {
"fields": "id,username,name,biography,followers_count,follows_count,media_count,profile_picture_url,website",
"access_token": token,
})
if args.json_output:
print(json.dumps(data, indent=2))
else:
print(f"@{data.get('username', '?')}")
if data.get("name"):
print(f" Name: {data['name']}")
if data.get("biography"):
print(f" Bio: {data['biography']}")
print(f" Followers: {data.get('followers_count', '?')}")
print(f" Following: {data.get('follows_count', '?')}")
print(f" Posts: {data.get('media_count', '?')}")
if data.get("website"):
print(f" Website: {data['website']}")
print(f" ID: {data.get('id', '?')}")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _wait_for_container(token: str, container_id: str, max_wait: int = 30) -> None:
"""Poll container status until FINISHED or timeout."""
s = None
for _ in range(max_wait):
status = _api_get(container_id, {
"fields": "status_code",
"access_token": token,
})
s = status.get("status_code")
if s == "FINISHED":
return
if s == "ERROR":
# Try to get error details
err_info = _api_get(container_id, {
"fields": "status_code,status",
"access_token": token,
})
print(f"Container error: {err_info}", file=sys.stderr)
sys.exit(1)
time.sleep(1)
print(f"Timeout waiting for container {container_id} (status: {s})", file=sys.stderr)
sys.exit(1)
# ---------------------------------------------------------------------------
# Argument parser
# ---------------------------------------------------------------------------
def main() -> None:
parser = argparse.ArgumentParser(prog="instagram", description="Instagram CLI for picoclaw")
parser.add_argument("--json", dest="json_output", action="store_true", help="JSON output")
parser.add_argument("--token", help="Access token (overrides INSTAGRAM_ACCESS_TOKEN env var)")
parser.add_argument("--user-id", help="Instagram User ID (overrides INSTAGRAM_USER_ID env var)")
sub = parser.add_subparsers(dest="command")
# whoami
sub.add_parser("whoami", help="Show current user info")
# post-image
p = sub.add_parser("post-image", help="Post a single image")
p.add_argument("image_url", help="Public image URL (JPEG recommended)")
p.add_argument("--caption", default="", help="Post caption")
p.add_argument("--dry-run", action="store_true", help="Preview without posting")
# post-video (reel)
p = sub.add_parser("post-video", help="Post a reel (video)")
p.add_argument("video_url", help="Public video URL (mp4)")
p.add_argument("--caption", default="", help="Reel caption")
p.add_argument("--dry-run", action="store_true", help="Preview without posting")
# post-carousel
p = sub.add_parser("post-carousel", help="Post a carousel (multiple images/videos)")
p.add_argument("urls", nargs="+", help="Public media URLs (2-10 images/videos)")
p.add_argument("--caption", default="", help="Carousel caption")
p.add_argument("--dry-run", action="store_true", help="Preview without posting")
# post-story
p = sub.add_parser("post-story", help="Post a story (image or video)")
p.add_argument("media_url", help="Public media URL")
p.add_argument("--dry-run", action="store_true", help="Preview without posting")
# reply
p = sub.add_parser("reply", help="Reply to a comment")
p.add_argument("comment_id", help="Comment ID to reply to")
p.add_argument("text", help="Reply text")
# posts
p = sub.add_parser("posts", help="List recent posts")
p.add_argument("-n", type=int, default=10, help="Number of posts")
# insights
p = sub.add_parser("insights", help="Get insights for a post")
p.add_argument("post_id", help="Post ID")
# profile
sub.add_parser("profile", help="View your profile")
args = parser.parse_args()
if not args.command:
parser.print_help()
sys.exit(1)
handlers = {
"whoami": cmd_whoami,
"post-image": cmd_post_image,
"post-video": cmd_post_video,
"post-carousel": cmd_post_carousel,
"post-story": cmd_post_story,
"reply": cmd_reply,
"posts": cmd_posts,
"insights": cmd_insights,
"profile": cmd_profile,
}
try:
handlers[args.command](args)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()

236
docker/spotify-playlist.py Normal file
View file

@ -0,0 +1,236 @@
#!/usr/bin/env python3
"""Fetch all tracks from a Spotify playlist and write them to JSON.
Usage:
spotify-playlist.py [--playlist URL_OR_ID] [--output PATH]
Requires SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET env vars.
Get them at https://developer.spotify.com/dashboard (create a free app).
Add http://127.0.0.1:8888/callback as a Redirect URI in your app settings.
On first run, you'll be prompted to visit a URL and paste back the redirect.
The token is cached at ~/.spotify-token.json for subsequent runs.
Example:
export SPOTIFY_CLIENT_ID="abc123"
export SPOTIFY_CLIENT_SECRET="xyz789"
python3 spotify-playlist.py \
--playlist 530uTl6cSboMQWNRJSJrjz \
--output /root/.picoclaw/workspace/data/playlist.json
"""
import argparse
import base64
import json
import os
import re
import secrets
import sys
import webbrowser
from urllib.request import Request, urlopen
from urllib.parse import urlencode, urlparse, parse_qs
DEFAULT_PLAYLIST = "530uTl6cSboMQWNRJSJrjz"
DEFAULT_OUTPUT = "playlist.json"
TOKEN_URL = "https://accounts.spotify.com/api/token"
AUTH_URL = "https://accounts.spotify.com/authorize"
REDIRECT_URI = "http://127.0.0.1:8888/callback"
SCOPE = "playlist-read-private playlist-read-collaborative"
API_BASE = "https://api.spotify.com/v1"
TOKEN_CACHE = os.path.join(os.path.expanduser("~"), ".spotify-token.json")
def _load_cached_token() -> dict | None:
"""Load cached token if it exists."""
if os.path.exists(TOKEN_CACHE):
with open(TOKEN_CACHE) as f:
return json.load(f)
return None
def _save_token(token_data: dict) -> None:
"""Cache the token to disk."""
with open(TOKEN_CACHE, "w") as f:
json.dump(token_data, f)
def _refresh_token(client_id: str, client_secret: str, refresh_token: str) -> dict:
"""Refresh an expired access token."""
creds = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
data = urlencode({
"grant_type": "refresh_token",
"refresh_token": refresh_token,
}).encode()
req = Request(TOKEN_URL, data=data, headers={
"Authorization": f"Basic {creds}",
"Content-Type": "application/x-www-form-urlencoded",
})
with urlopen(req) as resp:
result = json.loads(resp.read())
# Spotify may or may not return a new refresh token
if "refresh_token" not in result:
result["refresh_token"] = refresh_token
_save_token(result)
return result
def _authorize(client_id: str, client_secret: str) -> dict:
"""Authorization Code flow via manual URL paste (no local server needed)."""
state = secrets.token_urlsafe(16)
params = urlencode({
"client_id": client_id,
"response_type": "code",
"redirect_uri": REDIRECT_URI,
"scope": SCOPE,
"state": state,
})
url = f"{AUTH_URL}?{params}"
print("1. Visit this URL and authorize the app:\n")
print(f" {url}\n")
webbrowser.open(url)
print("2. After authorizing, you'll be redirected to a page that won't load.")
print(" Copy the FULL URL from your browser's address bar and paste it here.\n")
redirect_url = input("Paste redirect URL: ").strip()
qs = parse_qs(urlparse(redirect_url).query)
if qs.get("state", [None])[0] != state:
print("Error: State mismatch — possible CSRF. Try again.", file=sys.stderr)
sys.exit(1)
if "error" in qs:
print(f"Authorization denied: {qs['error'][0]}", file=sys.stderr)
sys.exit(1)
auth_code = qs.get("code", [None])[0]
if not auth_code:
print("Error: No authorization code found in URL.", file=sys.stderr)
sys.exit(1)
# Exchange code for token
creds = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
data = urlencode({
"grant_type": "authorization_code",
"code": auth_code,
"redirect_uri": REDIRECT_URI,
}).encode()
req = Request(TOKEN_URL, data=data, headers={
"Authorization": f"Basic {creds}",
"Content-Type": "application/x-www-form-urlencoded",
})
with urlopen(req) as resp:
token_data = json.loads(resp.read())
_save_token(token_data)
return token_data
def get_token(client_id: str, client_secret: str) -> str:
"""Get a valid access token, refreshing or re-authorizing as needed."""
cached = _load_cached_token()
if cached:
# Try using the cached token
try:
req = Request(f"{API_BASE}/me", headers={
"Authorization": f"Bearer {cached['access_token']}"
})
urlopen(req)
return cached["access_token"]
except Exception:
pass
# Try refreshing
if cached.get("refresh_token"):
try:
refreshed = _refresh_token(client_id, client_secret, cached["refresh_token"])
return refreshed["access_token"]
except Exception:
pass
# Full re-authorization
token_data = _authorize(client_id, client_secret)
return token_data["access_token"]
def _api_get(token: str, url: str) -> dict:
"""GET a Spotify API endpoint."""
req = Request(url, headers={"Authorization": f"Bearer {token}"})
with urlopen(req) as resp:
return json.loads(resp.read())
def _extract_playlist_id(url_or_id: str) -> str:
"""Extract playlist ID from a URL or return as-is."""
m = re.search(r"playlist/([a-zA-Z0-9]+)", url_or_id)
return m.group(1) if m else url_or_id
def fetch_playlist(token: str, playlist_id: str) -> dict:
"""Fetch playlist metadata and all tracks (handles pagination)."""
info = _api_get(token, f"{API_BASE}/playlists/{playlist_id}?fields=name,description,external_urls")
tracks = []
url = f"{API_BASE}/playlists/{playlist_id}/items?limit=50"
while url:
page = _api_get(token, url)
for item in page.get("items", []):
t = item.get("item") or item.get("track")
if not t or not t.get("id") or t.get("type") == "episode":
continue # skip local files / unavailable tracks
album = t.get("album") or {}
images = album.get("images") or []
art_url = images[0]["url"] if images else None
tracks.append({
"id": t["id"],
"name": t["name"],
"artists": [a["name"] for a in t.get("artists", [])],
"album": album.get("name", ""),
"release_date": album.get("release_date", ""),
"duration_ms": t.get("duration_ms", 0),
"spotify_url": (t.get("external_urls") or {}).get("spotify", ""),
"preview_url": t.get("preview_url"),
"album_art_url": art_url,
})
url = page.get("next")
return {
"playlist_name": info.get("name", ""),
"playlist_description": info.get("description", ""),
"playlist_url": (info.get("external_urls") or {}).get("spotify", ""),
"total_tracks": len(tracks),
"tracks": tracks,
}
def main():
parser = argparse.ArgumentParser(description="Fetch Spotify playlist tracks to JSON")
parser.add_argument("--playlist", default=DEFAULT_PLAYLIST,
help="Spotify playlist URL or ID (default: The Primer playlist)")
parser.add_argument("--output", "-o", default=DEFAULT_OUTPUT,
help="Output JSON file path (default: playlist.json)")
args = parser.parse_args()
client_id = os.environ.get("SPOTIFY_CLIENT_ID", "")
client_secret = os.environ.get("SPOTIFY_CLIENT_SECRET", "")
if not client_id or not client_secret:
print("Error: Set SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET env vars.", file=sys.stderr)
print("Get them at https://developer.spotify.com/dashboard", file=sys.stderr)
sys.exit(1)
playlist_id = _extract_playlist_id(args.playlist)
print(f"Fetching playlist {playlist_id}...")
token = get_token(client_id, client_secret)
data = fetch_playlist(token, playlist_id)
os.makedirs(os.path.dirname(os.path.abspath(args.output)), exist_ok=True)
with open(args.output, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
print(f"Wrote {data['total_tracks']} tracks to {args.output}")
print(f"Playlist: {data['playlist_name']}")
if __name__ == "__main__":
main()

385
docker/threads.py Normal file
View file

@ -0,0 +1,385 @@
#!/usr/bin/env python3
"""threads — Threads CLI for picoclaw.
Thin wrapper around the Threads API (graph.threads.net).
Installed to /usr/local/bin/threads inside the Docker image
so the agent can call `threads post`, `threads whoami`, etc.
Auth: Set THREADS_ACCESS_TOKEN env var or pass --token.
"""
import argparse
import json
import os
import sys
import time
import urllib.request
import urllib.parse
import urllib.error
API_BASE = "https://graph.threads.net/v1.0"
# ---------------------------------------------------------------------------
# HTTP helpers (stdlib only — no extra deps needed)
# ---------------------------------------------------------------------------
def _get_token(args) -> str:
token = getattr(args, "token", None) or os.environ.get("THREADS_ACCESS_TOKEN")
if not token:
print("No access token. Set THREADS_ACCESS_TOKEN or pass --token.", file=sys.stderr)
sys.exit(1)
return token
def _api_get(path: str, params: dict) -> dict:
qs = urllib.parse.urlencode(params)
url = f"{API_BASE}/{path}?{qs}"
req = urllib.request.Request(url, method="GET")
try:
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
body = e.read().decode()
print(f"API error {e.code}: {body}", file=sys.stderr)
sys.exit(1)
def _api_post(path: str, params: dict) -> dict:
url = f"{API_BASE}/{path}"
data = urllib.parse.urlencode(params).encode()
req = urllib.request.Request(url, data=data, method="POST")
try:
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
body = e.read().decode()
print(f"API error {e.code}: {body}", file=sys.stderr)
sys.exit(1)
def _get_user_id(token: str) -> str:
data = _api_get("me", {"fields": "id", "access_token": token})
return data["id"]
# ---------------------------------------------------------------------------
# Commands
# ---------------------------------------------------------------------------
def cmd_whoami(args) -> None:
token = _get_token(args)
data = _api_get("me", {
"fields": "id,username,threads_profile_picture_url,threads_biography",
"access_token": token,
})
if args.json_output:
print(json.dumps(data, indent=2))
else:
print(f"User ID: {data.get('id', '?')}")
print(f"Username: @{data.get('username', '?')}")
if data.get("threads_biography"):
print(f"Bio: {data['threads_biography']}")
def cmd_post(args) -> None:
token = _get_token(args)
user_id = _get_user_id(token)
text = args.text
if args.dry_run:
print(f"[DRY RUN] Would post ({len(text)} chars):")
print(text)
return
create_params = {
"media_type": "TEXT",
"text": text,
"access_token": token,
}
# Attach image if provided
if args.image:
create_params["media_type"] = "IMAGE"
create_params["image_url"] = args.image
# Step 1: Create media container
result = _api_post(f"{user_id}/threads", create_params)
container_id = result.get("id")
if not container_id:
print(f"Failed to create container: {result}", file=sys.stderr)
sys.exit(1)
# Step 2: Wait for container to be ready (poll status)
_wait_for_container(token, container_id)
# Step 3: Publish
publish_result = _api_post(f"{user_id}/threads_publish", {
"creation_id": container_id,
"access_token": token,
})
post_id = publish_result.get("id")
print(f"Posted: {post_id}")
def cmd_post_image_url(args) -> None:
"""Post with an image URL (Threads requires publicly accessible URLs)."""
token = _get_token(args)
user_id = _get_user_id(token)
if args.dry_run:
print(f"[DRY RUN] Would post image ({len(args.text)} chars):")
print(f" Text: {args.text}")
print(f" Image: {args.image_url}")
return
result = _api_post(f"{user_id}/threads", {
"media_type": "IMAGE",
"text": args.text,
"image_url": args.image_url,
"access_token": token,
})
container_id = result.get("id")
if not container_id:
print(f"Failed to create container: {result}", file=sys.stderr)
sys.exit(1)
_wait_for_container(token, container_id)
publish_result = _api_post(f"{user_id}/threads_publish", {
"creation_id": container_id,
"access_token": token,
})
print(f"Posted: {publish_result.get('id')}")
def cmd_post_video_url(args) -> None:
"""Post with a video URL (must be publicly accessible, mp4)."""
token = _get_token(args)
user_id = _get_user_id(token)
if args.dry_run:
print(f"[DRY RUN] Would post video ({len(args.text)} chars):")
print(f" Text: {args.text}")
print(f" Video: {args.video_url}")
return
result = _api_post(f"{user_id}/threads", {
"media_type": "VIDEO",
"text": args.text,
"video_url": args.video_url,
"access_token": token,
})
container_id = result.get("id")
if not container_id:
print(f"Failed to create container: {result}", file=sys.stderr)
sys.exit(1)
# Videos take longer to process
_wait_for_container(token, container_id, max_wait=120)
publish_result = _api_post(f"{user_id}/threads_publish", {
"creation_id": container_id,
"access_token": token,
})
print(f"Posted: {publish_result.get('id')}")
def cmd_reply(args) -> None:
token = _get_token(args)
user_id = _get_user_id(token)
result = _api_post(f"{user_id}/threads", {
"media_type": "TEXT",
"text": args.text,
"reply_to_id": args.post_id,
"access_token": token,
})
container_id = result.get("id")
if not container_id:
print(f"Failed to create reply container: {result}", file=sys.stderr)
sys.exit(1)
_wait_for_container(token, container_id)
publish_result = _api_post(f"{user_id}/threads_publish", {
"creation_id": container_id,
"access_token": token,
})
print(f"Replied: {publish_result.get('id')}")
def cmd_profile(args) -> None:
token = _get_token(args)
data = _api_get("me", {
"fields": "id,username,threads_profile_picture_url,threads_biography",
"access_token": token,
})
if args.json_output:
print(json.dumps(data, indent=2))
else:
print(f"@{data.get('username', '?')}")
if data.get("threads_biography"):
print(f" Bio: {data['threads_biography']}")
print(f" ID: {data.get('id', '?')}")
def cmd_insights(args) -> None:
"""Get profile-level insights (follower count, views)."""
token = _get_token(args)
user_id = _get_user_id(token)
data = _api_get(f"{user_id}/threads_insights", {
"metric": "views,likes,replies,reposts,quotes,followers_count",
"access_token": token,
})
if args.json_output:
print(json.dumps(data, indent=2))
else:
for item in data.get("data", []):
name = item.get("name", "?")
values = item.get("values", [{}])
val = values[0].get("value", "?") if values else "?"
print(f" {name}: {val}")
def cmd_posts(args) -> None:
"""List recent posts."""
token = _get_token(args)
user_id = _get_user_id(token)
data = _api_get(f"{user_id}/threads", {
"fields": "id,text,timestamp,media_type,shortcode,permalink",
"limit": args.n,
"access_token": token,
})
if args.json_output:
print(json.dumps(data, indent=2))
else:
for post in data.get("data", []):
ts = post.get("timestamp", "")
text = post.get("text", "")
pid = post.get("id", "?")
permalink = post.get("permalink", "")
print(f" [{ts}] {pid}")
for line in text.splitlines():
print(f" {line}")
if permalink:
print(f" {permalink}")
print()
def cmd_post_insights(args) -> None:
"""Get insights for a specific post."""
token = _get_token(args)
data = _api_get(f"{args.post_id}/insights", {
"metric": "views,likes,replies,reposts,quotes",
"access_token": token,
})
if args.json_output:
print(json.dumps(data, indent=2))
else:
print(f"Insights for {args.post_id}:")
for item in data.get("data", []):
name = item.get("name", "?")
values = item.get("values", [{}])
val = values[0].get("value", "?") if values else "?"
print(f" {name}: {val}")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _wait_for_container(token: str, container_id: str, max_wait: int = 30) -> None:
"""Poll container status until FINISHED or timeout."""
for _ in range(max_wait):
status = _api_get(container_id, {
"fields": "status,error_message",
"access_token": token,
})
s = status.get("status")
if s == "FINISHED":
return
if s == "ERROR":
print(f"Container error: {status.get('error_message', 'unknown')}", file=sys.stderr)
sys.exit(1)
time.sleep(1)
print(f"Timeout waiting for container {container_id} (status: {s})", file=sys.stderr)
sys.exit(1)
# ---------------------------------------------------------------------------
# Argument parser
# ---------------------------------------------------------------------------
def main() -> None:
parser = argparse.ArgumentParser(prog="threads", description="Threads CLI for picoclaw")
parser.add_argument("--json", dest="json_output", action="store_true", help="JSON output")
parser.add_argument("--token", help="Access token (overrides THREADS_ACCESS_TOKEN env var)")
sub = parser.add_subparsers(dest="command")
# whoami
sub.add_parser("whoami", help="Show current user info")
# post
p = sub.add_parser("post", help="Create a text post")
p.add_argument("text", help="Post text")
p.add_argument("--image", help="Public image URL to attach")
p.add_argument("--dry-run", action="store_true", help="Preview without posting")
# post-image
p = sub.add_parser("post-image", help="Post with a public image URL")
p.add_argument("text", help="Post text")
p.add_argument("image_url", help="Public image URL")
p.add_argument("--dry-run", action="store_true", help="Preview without posting")
# post-video
p = sub.add_parser("post-video", help="Post with a public video URL")
p.add_argument("text", help="Post text")
p.add_argument("video_url", help="Public video URL (mp4)")
p.add_argument("--dry-run", action="store_true", help="Preview without posting")
# reply
p = sub.add_parser("reply", help="Reply to a post")
p.add_argument("post_id", help="Post ID to reply to")
p.add_argument("text", help="Reply text")
# profile
sub.add_parser("profile", help="View your profile")
# insights
sub.add_parser("insights", help="View profile insights (followers, views)")
# posts
p = sub.add_parser("posts", help="List recent posts")
p.add_argument("-n", type=int, default=10, help="Number of posts")
# post-insights
p = sub.add_parser("post-insights", help="Get insights for a post")
p.add_argument("post_id", help="Post ID")
args = parser.parse_args()
if not args.command:
parser.print_help()
sys.exit(1)
handlers = {
"whoami": cmd_whoami,
"post": cmd_post,
"post-image": cmd_post_image_url,
"post-video": cmd_post_video_url,
"reply": cmd_reply,
"profile": cmd_profile,
"insights": cmd_insights,
"posts": cmd_posts,
"post-insights": cmd_post_insights,
}
try:
handlers[args.command](args)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()