claude-world-studio 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/.env.example +30 -0
  2. package/.mcp.json +51 -0
  3. package/README.md +224 -0
  4. package/client/App.tsx +446 -0
  5. package/client/components/ChatWindow.tsx +790 -0
  6. package/client/components/FileExplorer.tsx +218 -0
  7. package/client/components/FilePreviewModal.tsx +179 -0
  8. package/client/components/PublishDialog.tsx +307 -0
  9. package/client/components/SettingsPage.tsx +452 -0
  10. package/client/components/Sidebar.tsx +198 -0
  11. package/client/components/ToolUseBlock.tsx +140 -0
  12. package/client/index.html +12 -0
  13. package/client/index.tsx +10 -0
  14. package/client/styles/globals.css +48 -0
  15. package/demo/01-welcome.png +0 -0
  16. package/demo/02-pipeline-cards.png +0 -0
  17. package/demo/03-custom-topic-fill.png +0 -0
  18. package/demo/04-topic-typed.png +0 -0
  19. package/demo/05-loading-state.png +0 -0
  20. package/demo/06-tool-calls.png +0 -0
  21. package/demo/07-history-rich.png +0 -0
  22. package/demo/09-en-cards.png +0 -0
  23. package/demo/10-ja-cards.png +0 -0
  24. package/demo/capture-remaining.mjs +73 -0
  25. package/demo/capture.mjs +110 -0
  26. package/demo/demo-walkthrough-2.webm +0 -0
  27. package/demo/demo-walkthrough.webm +0 -0
  28. package/package.json +48 -0
  29. package/postcss.config.js +6 -0
  30. package/scripts/threads_api.py +536 -0
  31. package/server/ai-client.ts +356 -0
  32. package/server/db.ts +299 -0
  33. package/server/mcp-config.ts +85 -0
  34. package/server/routes/accounts.ts +88 -0
  35. package/server/routes/files.ts +175 -0
  36. package/server/routes/publish.ts +77 -0
  37. package/server/routes/sessions.ts +59 -0
  38. package/server/routes/settings.ts +220 -0
  39. package/server/server.ts +261 -0
  40. package/server/services/social-publisher.ts +74 -0
  41. package/server/services/studio-mcp.ts +107 -0
  42. package/server/session.ts +167 -0
  43. package/server/types.ts +86 -0
  44. package/tailwind.config.js +8 -0
  45. package/tsconfig.json +16 -0
  46. package/vite.config.ts +19 -0
@@ -0,0 +1,536 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Threads API Client — Full lifecycle: search, analyze, generate, publish.
4
+ Based on Meta's official Threads API (graph.threads.net/v1.0).
5
+
6
+ Tokens are auto-loaded from ../.env (relative to this script).
7
+ Use --account to select which account (cw or lf). --token overrides env.
8
+
9
+ Usage:
10
+ python threads_api.py me # uses THREADS_TOKEN_CW from .env
11
+ python threads_api.py me --account lf # uses THREADS_TOKEN_LF from .env
12
+ python threads_api.py search --query "AI" --limit 20
13
+ python threads_api.py publish --text "Your post content" --account lf
14
+ python threads_api.py batch-publish --file posts.json --delay 1800
15
+ python threads_api.py schedule --file posts.json --times "21:00,12:00,07:30"
16
+ python threads_api.py me --token TOKEN # explicit token overrides .env
17
+ """
18
+
19
+ import argparse
20
+ import json
21
+ import sys
22
+ import time
23
+ import os
24
+ from datetime import datetime, timedelta
25
+ from urllib.request import Request, urlopen
26
+ from urllib.parse import urlencode, quote
27
+ from urllib.error import HTTPError
28
+
29
+
30
+ def load_env():
31
+ """Load .env file from the skill root (one level up from scripts/)."""
32
+ env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", ".env")
33
+ env_path = os.path.normpath(env_path)
34
+ if not os.path.isfile(env_path):
35
+ return
36
+ with open(env_path, "r", encoding="utf-8") as f:
37
+ for line in f:
38
+ line = line.strip()
39
+ if not line or line.startswith("#"):
40
+ continue
41
+ if "=" not in line:
42
+ continue
43
+ key, value = line.split("=", 1)
44
+ key, value = key.strip(), value.strip()
45
+ if key and key not in os.environ:
46
+ os.environ[key] = value
47
+
48
+
49
+ load_env()
50
+
51
+
52
+ def resolve_token(args):
53
+ """Resolve the API token from --token flag or environment variables."""
54
+ if args.token:
55
+ return args.token
56
+ account = getattr(args, "account", "cw")
57
+ if account == "lf":
58
+ token = os.environ.get("THREADS_TOKEN_LF")
59
+ else:
60
+ token = os.environ.get("THREADS_TOKEN_CW")
61
+ if not token:
62
+ # Fallback to generic THREADS_ACCESS_TOKEN
63
+ token = os.environ.get("THREADS_ACCESS_TOKEN")
64
+ if not token:
65
+ print("Error: No token provided. Use --token or set THREADS_TOKEN_CW / THREADS_TOKEN_LF in .env", file=sys.stderr)
66
+ sys.exit(1)
67
+ return token
68
+
69
+
70
+ API_BASE = "https://graph.threads.net/v1.0"
71
+
72
+
73
+ class ThreadsAPI:
74
+ def __init__(self, access_token: str):
75
+ self.token = access_token
76
+ self.user_id = None
77
+ self.username = None
78
+ self.rate_limit_remaining = None
79
+
80
+ def _request(self, method: str, endpoint: str, params: dict = None, data: dict = None) -> dict:
81
+ """Make an API request to Threads."""
82
+ url = f"{API_BASE}{endpoint}"
83
+
84
+ if params is None:
85
+ params = {}
86
+ params["access_token"] = self.token
87
+
88
+ if method == "GET":
89
+ url += "?" + urlencode(params)
90
+ req = Request(url, method="GET")
91
+ else:
92
+ url += "?" + urlencode({"access_token": self.token})
93
+ body = urlencode({k: v for k, v in (data or params).items() if k != "access_token"})
94
+ req = Request(url, data=body.encode(), method="POST")
95
+ req.add_header("Content-Type", "application/x-www-form-urlencoded")
96
+
97
+ try:
98
+ with urlopen(req, timeout=30) as resp:
99
+ return json.loads(resp.read().decode())
100
+ except HTTPError as e:
101
+ error_body = e.read().decode()
102
+ print(f"API Error {e.code}: {error_body}", file=sys.stderr)
103
+ raise
104
+
105
+ # ── Identity ──
106
+
107
+ def get_me(self) -> dict:
108
+ """Get current user profile."""
109
+ data = self._request("GET", "/me", {
110
+ "fields": "id,username,name,threads_profile_picture_url,threads_biography"
111
+ })
112
+ self.user_id = data.get("id")
113
+ self.username = data.get("username", data.get("name", "unknown"))
114
+ return data
115
+
116
+ # ── Search ──
117
+
118
+ def search(self, query: str, limit: int = 20) -> list:
119
+ """
120
+ Search Threads for posts matching a keyword.
121
+ Returns posts sorted by heat score.
122
+ Rate limit: 500 queries per rolling 7 days.
123
+ """
124
+ data = self._request("GET", "/keyword_search", {
125
+ "q": query,
126
+ "fields": "id,text,username,timestamp,like_count,reply_count,repost_count",
127
+ "limit": str(min(limit, 50))
128
+ })
129
+ posts = data.get("data", [])
130
+
131
+ # Calculate heat score for each post
132
+ for post in posts:
133
+ likes = post.get("like_count", 0) or 0
134
+ replies = post.get("reply_count", 0) or 0
135
+ reposts = post.get("repost_count", 0) or 0
136
+ post["heat_score"] = likes + (replies * 3) + (reposts * 5)
137
+
138
+ # Sort by heat score descending
139
+ posts.sort(key=lambda p: p["heat_score"], reverse=True)
140
+ return posts
141
+
142
+ def search_multiple(self, queries: list, limit_per_query: int = 10) -> dict:
143
+ """Search multiple keywords and return results grouped by query."""
144
+ results = {}
145
+ for q in queries:
146
+ try:
147
+ posts = self.search(q, limit_per_query)
148
+ results[q] = posts
149
+ time.sleep(1) # Rate limit courtesy
150
+ except Exception as e:
151
+ print(f"Search failed for '{q}': {e}", file=sys.stderr)
152
+ results[q] = []
153
+ return results
154
+
155
+ # ── Publishing ──
156
+
157
+ def create_container(self, text: str, reply_to: str = None,
158
+ image_url: str = None, poll_options: list = None,
159
+ tag: str = None) -> str:
160
+ """
161
+ Create a media container for a post.
162
+ Returns the creation_id needed for publishing.
163
+ Text limit: 500 characters.
164
+ Supports: text, image, poll attachments.
165
+ Poll options: list of 2-4 strings (max 25 chars each).
166
+ """
167
+ if len(text) > 500:
168
+ print(f"WARNING: Text is {len(text)} chars, truncating to 500", file=sys.stderr)
169
+ text = text[:497] + "..."
170
+
171
+ if not self.user_id:
172
+ self.get_me()
173
+
174
+ if image_url:
175
+ params = {
176
+ "media_type": "IMAGE",
177
+ "image_url": image_url,
178
+ "text": text,
179
+ }
180
+ else:
181
+ params = {
182
+ "media_type": "TEXT",
183
+ "text": text,
184
+ }
185
+
186
+ if reply_to:
187
+ params["reply_to_id"] = reply_to
188
+
189
+ if poll_options and not image_url:
190
+ poll = {}
191
+ keys = ["option_a", "option_b", "option_c", "option_d"]
192
+ for i, opt in enumerate(poll_options[:4]):
193
+ poll[keys[i]] = opt[:25]
194
+ params["poll_attachment"] = json.dumps(poll)
195
+
196
+ if tag:
197
+ params["tag"] = tag
198
+
199
+ data = self._request("POST", f"/{self.user_id}/threads", data=params)
200
+ return data.get("id")
201
+
202
+ def publish_container(self, creation_id: str) -> dict:
203
+ """Publish a previously created media container."""
204
+ if not self.user_id:
205
+ self.get_me()
206
+
207
+ data = self._request("POST", f"/{self.user_id}/threads_publish", data={
208
+ "creation_id": creation_id
209
+ })
210
+ return data
211
+
212
+ def publish_text(self, text: str, image_url: str = None,
213
+ poll_options: list = None, link_comment: str = None,
214
+ tag: str = None) -> dict:
215
+ """
216
+ Full publish flow: create container → wait → publish.
217
+ If link_comment is provided, auto-reply with the link (avoids reach penalty).
218
+ If poll_options is provided, attach a native poll (2-4 options, max 25 chars each).
219
+ """
220
+ container_id = self.create_container(text, image_url=image_url,
221
+ poll_options=poll_options,
222
+ tag=tag)
223
+ if not container_id:
224
+ raise Exception("Failed to create media container")
225
+
226
+ # Wait for processing (Meta recommends at least a few seconds)
227
+ time.sleep(5)
228
+
229
+ result = self.publish_container(container_id)
230
+ post_id = result.get("id")
231
+
232
+ output = {
233
+ "post_id": post_id,
234
+ "container_id": container_id,
235
+ "text_length": len(text),
236
+ "published_at": datetime.now().isoformat()
237
+ }
238
+
239
+ # Auto-reply with link to avoid reach penalty from URL in main post
240
+ if link_comment and post_id:
241
+ time.sleep(3)
242
+ try:
243
+ reply_cid = self.create_container(link_comment, reply_to=post_id)
244
+ time.sleep(5)
245
+ reply_result = self.publish_container(reply_cid)
246
+ output["link_reply_id"] = reply_result.get("id")
247
+ print(f" ✓ Link reply posted: {output['link_reply_id']}", file=sys.stderr)
248
+ except Exception as e:
249
+ output["link_reply_error"] = str(e)
250
+ print(f" ✗ Link reply failed: {e}", file=sys.stderr)
251
+
252
+ if poll_options:
253
+ output["poll_options"] = poll_options[:4]
254
+ if image_url:
255
+ output["image_url"] = image_url
256
+ if tag:
257
+ output["tag"] = tag
258
+
259
+ return output
260
+
261
+ def publish_thread(self, posts: list) -> list:
262
+ """
263
+ Publish a thread (串文) — a series of connected posts.
264
+ First post is the parent, subsequent posts are replies.
265
+ """
266
+ results = []
267
+ parent_id = None
268
+
269
+ for i, text in enumerate(posts):
270
+ if i == 0:
271
+ result = self.publish_text(text)
272
+ parent_id = result["post_id"]
273
+ else:
274
+ container_id = self.create_container(text, reply_to=parent_id)
275
+ time.sleep(5)
276
+ pub = self.publish_container(container_id)
277
+ result = {
278
+ "post_id": pub.get("id"),
279
+ "container_id": container_id,
280
+ "text_length": len(text),
281
+ "reply_to": parent_id,
282
+ "published_at": datetime.now().isoformat()
283
+ }
284
+
285
+ results.append(result)
286
+ time.sleep(3) # Brief pause between thread posts
287
+
288
+ return results
289
+
290
+ def batch_publish(self, posts: list, delay_seconds: int = 1800) -> list:
291
+ """
292
+ Publish multiple independent posts with delay between each.
293
+ Default delay: 30 minutes (1800 seconds).
294
+ """
295
+ results = []
296
+ total = len(posts)
297
+
298
+ for i, post in enumerate(posts):
299
+ text = post if isinstance(post, str) else post.get("text", "")
300
+ print(f"[{i+1}/{total}] Publishing ({len(text)} chars)...", file=sys.stderr)
301
+
302
+ try:
303
+ result = self.publish_text(text)
304
+ result["index"] = i
305
+ result["status"] = "published"
306
+ results.append(result)
307
+ print(f" ✓ Published: {result['post_id']}", file=sys.stderr)
308
+ except Exception as e:
309
+ results.append({
310
+ "index": i,
311
+ "status": "failed",
312
+ "error": str(e),
313
+ "text_preview": text[:50]
314
+ })
315
+ print(f" ✗ Failed: {e}", file=sys.stderr)
316
+
317
+ if i < total - 1:
318
+ print(f" Waiting {delay_seconds}s before next post...", file=sys.stderr)
319
+ time.sleep(delay_seconds)
320
+
321
+ return results
322
+
323
+
324
+ # ── CLI ──
325
+
326
+ def cmd_me(args):
327
+ token = resolve_token(args)
328
+ api = ThreadsAPI(token)
329
+ data = api.get_me()
330
+ print(json.dumps(data, indent=2, ensure_ascii=False))
331
+
332
+
333
+ def cmd_search(args):
334
+ token = resolve_token(args)
335
+ api = ThreadsAPI(token)
336
+ api.get_me()
337
+
338
+ if args.multi:
339
+ queries = [q.strip() for q in args.query.split(",")]
340
+ results = api.search_multiple(queries, args.limit)
341
+ output = {
342
+ "searched_at": datetime.now().isoformat(),
343
+ "queries": queries,
344
+ "results": {}
345
+ }
346
+ for q, posts in results.items():
347
+ output["results"][q] = {
348
+ "count": len(posts),
349
+ "top_heat": posts[0]["heat_score"] if posts else 0,
350
+ "posts": posts[:args.limit]
351
+ }
352
+ else:
353
+ posts = api.search(args.query, args.limit)
354
+ output = {
355
+ "searched_at": datetime.now().isoformat(),
356
+ "query": args.query,
357
+ "count": len(posts),
358
+ "posts": posts
359
+ }
360
+
361
+ print(json.dumps(output, indent=2, ensure_ascii=False))
362
+
363
+
364
+ def cmd_publish(args):
365
+ token = resolve_token(args)
366
+ api = ThreadsAPI(token)
367
+ api.get_me()
368
+
369
+ if args.thread:
370
+ # Split text by "---" delimiter for thread posts
371
+ parts = [p.strip() for p in args.text.split("---") if p.strip()]
372
+ results = api.publish_thread(parts)
373
+ else:
374
+ poll_opts = None
375
+ if args.poll:
376
+ poll_opts = [o.strip() for o in args.poll.split("|") if o.strip()]
377
+ results = api.publish_text(
378
+ args.text,
379
+ image_url=args.image,
380
+ poll_options=poll_opts,
381
+ link_comment=args.link_comment,
382
+ tag=args.tag,
383
+ )
384
+
385
+ print(json.dumps(results, indent=2, ensure_ascii=False))
386
+
387
+
388
+ def cmd_batch_publish(args):
389
+ token = resolve_token(args)
390
+ api = ThreadsAPI(token)
391
+ api.get_me()
392
+
393
+ with open(args.file, "r", encoding="utf-8") as f:
394
+ data = json.load(f)
395
+
396
+ posts = data if isinstance(data, list) else data.get("posts", [])
397
+ results = api.batch_publish(posts, args.delay)
398
+
399
+ output = {
400
+ "batch_id": datetime.now().strftime("%Y%m%d_%H%M%S"),
401
+ "total": len(posts),
402
+ "published": sum(1 for r in results if r.get("status") == "published"),
403
+ "failed": sum(1 for r in results if r.get("status") == "failed"),
404
+ "delay_seconds": args.delay,
405
+ "results": results
406
+ }
407
+ print(json.dumps(output, indent=2, ensure_ascii=False))
408
+
409
+
410
+ def cmd_schedule(args):
411
+ """Generate a scheduling plan based on optimal posting times."""
412
+ with open(args.file, "r", encoding="utf-8") as f:
413
+ data = json.load(f)
414
+
415
+ posts = data if isinstance(data, list) else data.get("posts", [])
416
+
417
+ # Parse target times
418
+ if args.times:
419
+ times = [t.strip() for t in args.times.split(",")]
420
+ else:
421
+ # Default optimal times from patent analysis
422
+ times = ["21:00", "12:00", "17:30", "07:30"]
423
+
424
+ now = datetime.now()
425
+ schedule = []
426
+
427
+ for i, post in enumerate(posts):
428
+ text = post if isinstance(post, str) else post.get("text", "")
429
+ time_slot = times[i % len(times)]
430
+ hour, minute = map(int, time_slot.split(":"))
431
+
432
+ target = now.replace(hour=hour, minute=minute, second=0)
433
+ if target <= now:
434
+ target += timedelta(days=1)
435
+ target += timedelta(days=i // len(times))
436
+
437
+ schedule.append({
438
+ "index": i,
439
+ "text_preview": text[:60] + "..." if len(text) > 60 else text,
440
+ "text_length": len(text),
441
+ "scheduled_time": target.isoformat(),
442
+ "time_slot": time_slot,
443
+ "full_text": text
444
+ })
445
+
446
+ output = {
447
+ "schedule_created": now.isoformat(),
448
+ "total_posts": len(posts),
449
+ "time_slots": times,
450
+ "schedule": schedule
451
+ }
452
+ print(json.dumps(output, indent=2, ensure_ascii=False))
453
+
454
+ # If --execute flag, actually wait and publish
455
+ if args.execute:
456
+ token = resolve_token(args)
457
+ api = ThreadsAPI(token)
458
+ api.get_me()
459
+ print(f"\n--- EXECUTING SCHEDULE (User: @{api.username}) ---", file=sys.stderr)
460
+
461
+ for item in schedule:
462
+ target_time = datetime.fromisoformat(item["scheduled_time"])
463
+ wait_seconds = (target_time - datetime.now()).total_seconds()
464
+
465
+ if wait_seconds > 0:
466
+ print(f"Waiting {wait_seconds:.0f}s until {item['time_slot']}...", file=sys.stderr)
467
+ time.sleep(wait_seconds)
468
+
469
+ try:
470
+ result = api.publish_text(item["full_text"])
471
+ print(f"✓ [{item['index']+1}] Published at {item['time_slot']}: {result['post_id']}", file=sys.stderr)
472
+ except Exception as e:
473
+ print(f"✗ [{item['index']+1}] Failed: {e}", file=sys.stderr)
474
+
475
+
476
+ def main():
477
+ parser = argparse.ArgumentParser(description="Threads API Client")
478
+ sub = parser.add_subparsers(dest="command")
479
+
480
+ # me
481
+ me_parser = sub.add_parser("me", help="Get current user info")
482
+ me_parser.add_argument("--token", default=None, help="Access token (falls back to .env)")
483
+ me_parser.add_argument("--account", choices=["cw", "lf"], default="cw", help="Account alias (configure tokens in .env)")
484
+
485
+ # search
486
+ search_parser = sub.add_parser("search", help="Search trending posts")
487
+ search_parser.add_argument("--token", default=None, help="Access token (falls back to .env)")
488
+ search_parser.add_argument("--account", choices=["cw", "lf"], default="cw", help="Account alias (configure tokens in .env)")
489
+ search_parser.add_argument("--query", "-q", required=True)
490
+ search_parser.add_argument("--limit", "-l", type=int, default=20)
491
+ search_parser.add_argument("--multi", action="store_true", help="Comma-separated queries")
492
+
493
+ # publish
494
+ pub_parser = sub.add_parser("publish", help="Publish a single post")
495
+ pub_parser.add_argument("--token", default=None, help="Access token (falls back to .env)")
496
+ pub_parser.add_argument("--account", choices=["cw", "lf"], default="cw", help="Account alias (configure tokens in .env)")
497
+ pub_parser.add_argument("--text", "-t", required=True)
498
+ pub_parser.add_argument("--image", default=None, help="Public image URL to attach")
499
+ pub_parser.add_argument("--poll", default=None, help="Poll options separated by | (2-4, max 25 chars each)")
500
+ pub_parser.add_argument("--link-comment", default=None, help="Auto-reply with this link (avoids reach penalty)")
501
+ pub_parser.add_argument("--tag", default=None, help="Topic tag (no # prefix, one per post)")
502
+ pub_parser.add_argument("--thread", action="store_true", help="Split by --- into thread")
503
+
504
+ # batch-publish
505
+ batch_parser = sub.add_parser("batch-publish", help="Publish multiple posts")
506
+ batch_parser.add_argument("--token", default=None, help="Access token (falls back to .env)")
507
+ batch_parser.add_argument("--account", choices=["cw", "lf"], default="cw", help="Account alias (configure tokens in .env)")
508
+ batch_parser.add_argument("--file", "-f", required=True)
509
+ batch_parser.add_argument("--delay", "-d", type=int, default=1800)
510
+
511
+ # schedule
512
+ sched_parser = sub.add_parser("schedule", help="Schedule posts at optimal times")
513
+ sched_parser.add_argument("--token", default=None, help="Access token (falls back to .env)")
514
+ sched_parser.add_argument("--account", choices=["cw", "lf"], default="cw", help="Account alias (configure tokens in .env)")
515
+ sched_parser.add_argument("--file", "-f", required=True)
516
+ sched_parser.add_argument("--times", help="Comma-separated times, e.g. '21:00,12:00'")
517
+ sched_parser.add_argument("--execute", action="store_true", help="Actually wait and publish")
518
+
519
+ args = parser.parse_args()
520
+
521
+ commands = {
522
+ "me": cmd_me,
523
+ "search": cmd_search,
524
+ "publish": cmd_publish,
525
+ "batch-publish": cmd_batch_publish,
526
+ "schedule": cmd_schedule,
527
+ }
528
+
529
+ if args.command in commands:
530
+ commands[args.command](args)
531
+ else:
532
+ parser.print_help()
533
+
534
+
535
+ if __name__ == "__main__":
536
+ main()