@clawnch/clawtomaton 0.8.0 → 0.8.1

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.
@@ -1,28 +1,43 @@
1
1
  /**
2
- * Skill: clawnx — X/Twitter integration via ClawnX from @clawnch/sdk.
2
+ * Skill: clawnx — Full X/Twitter integration via ClawnX from @clawnch/clawnx.
3
+ *
4
+ * 45+ actions covering every surface of the X API v2:
5
+ *
6
+ * Content: post_tweet, post_thread, post_with_media, upload_media, delete_tweet, get_tweet, search
7
+ * Engagement: like, unlike, retweet, unretweet, bookmark, unbookmark, list_bookmarks, list_likes,
8
+ * liking_users, retweeted_by, quote_tweets
9
+ * Social: follow, unfollow, list_followers, list_following, block, unblock, mute, unmute,
10
+ * list_blocked, list_muted, get_user, search_users, lookup_users
11
+ * Timelines: get_timeline, home_timeline, get_mentions, get_my_profile
12
+ * DMs: send_dm, send_dm_to_conversation, list_dms, get_dm_conversation
13
+ * Threads: get_conversation
14
+ * Lists: create_list, delete_list, get_list, get_user_lists, add_list_member,
15
+ * remove_list_member, list_members, list_tweets
16
+ * Streaming: stream_start, stream_stop, stream_rules_set, stream_rules_get
17
+ * Orchestration: action_chain (multi-step with PREV_TWEET_ID substitution)
18
+ * Media: upload_media, post_with_media
19
+ *
20
+ * All X API logic lives in @clawnch/clawnx — this file is a thin Clawtomaton
21
+ * skill adapter that maps action names + params to ClawnX method calls.
3
22
  *
4
- * Lets the agent post tweets, search, engage, and monitor X/Twitter.
5
23
  * Requires X API credentials in environment variables:
6
24
  * X_API_KEY, X_API_SECRET, X_ACCESS_TOKEN, X_ACCESS_TOKEN_SECRET, X_BEARER_TOKEN
7
- *
8
- * ClawnX is lazy-loaded from @clawnch/sdk (optional dependency).
9
25
  */
10
26
  /** Lazy singleton — created once on first use */
11
27
  let cachedClient = null;
12
28
  async function getClawnX() {
13
29
  if (cachedClient)
14
30
  return cachedClient;
15
- let sdk;
31
+ let mod;
16
32
  try {
17
- // @ts-ignore — @clawnch/sdk is an optional dependency
18
- sdk = await import('@clawnch/sdk');
33
+ // @ts-ignore — @clawnch/clawnx is an optional dependency
34
+ mod = await import('@clawnch/clawnx');
19
35
  }
20
36
  catch {
21
- throw new Error('@clawnch/sdk not installed. Run: npm install @clawnch/sdk\n' +
37
+ throw new Error('@clawnch/clawnx not installed. Run: npm install @clawnch/clawnx\n' +
22
38
  'Then set env vars: X_API_KEY, X_API_SECRET, X_ACCESS_TOKEN, X_ACCESS_TOKEN_SECRET, X_BEARER_TOKEN');
23
39
  }
24
- // ClawnX constructor reads credentials from env vars by default
25
- cachedClient = new sdk.ClawnX();
40
+ cachedClient = new mod.ClawnX();
26
41
  return cachedClient;
27
42
  }
28
43
  function formatTweet(tweet) {
@@ -39,20 +54,28 @@ function formatUser(user) {
39
54
  }
40
55
  export const clawnxSkill = {
41
56
  name: 'clawnx',
42
- description: 'X/Twitter: post tweets, search, like, retweet, follow, read timelines, post threads, get mentions. ' +
43
- 'Requires X API credentials (X_API_KEY, X_API_SECRET, X_ACCESS_TOKEN, X_ACCESS_TOKEN_SECRET, X_BEARER_TOKEN).',
57
+ description: 'Full X/Twitter integration: 45+ actions covering content, engagement, social graph, DMs, lists, ' +
58
+ 'streaming, media, and multi-step orchestration. Agent-native with structured metadata, action chaining, ' +
59
+ 'and atomic thread posting. Requires X API credentials.',
44
60
  parameters: [
45
61
  {
46
62
  name: 'action',
47
63
  type: 'string',
48
- description: 'Action: "post_tweet", "post_thread", "search", "get_tweet", "like", "retweet", ' +
49
- '"follow", "get_user", "get_mentions", "get_timeline", "get_my_profile", "delete_tweet".',
64
+ description: 'Action to perform. Content: post_tweet, post_thread, post_with_media, upload_media, delete_tweet, get_tweet, search. ' +
65
+ 'Engagement: like, unlike, retweet, unretweet, bookmark, unbookmark, list_bookmarks, list_likes, liking_users, retweeted_by, quote_tweets. ' +
66
+ 'Social: follow, unfollow, list_followers, list_following, block, unblock, mute, unmute, list_blocked, list_muted, get_user, search_users, lookup_users. ' +
67
+ 'Timelines: get_timeline, home_timeline, get_mentions, get_my_profile. ' +
68
+ 'DMs: send_dm, send_dm_to_conversation, list_dms, get_dm_conversation. ' +
69
+ 'Threads: get_conversation. ' +
70
+ 'Lists: create_list, delete_list, get_list, get_user_lists, add_list_member, remove_list_member, list_members, list_tweets. ' +
71
+ 'Streaming: stream_start, stream_stop, stream_rules_set, stream_rules_get. ' +
72
+ 'Orchestration: action_chain.',
50
73
  required: true,
51
74
  },
52
75
  {
53
76
  name: 'text',
54
77
  type: 'string',
55
- description: 'Tweet text (for post_tweet). Max 280 chars.',
78
+ description: 'Tweet text (for post_tweet, post_with_media) or DM text (for send_dm). Max 280 chars for tweets.',
56
79
  required: false,
57
80
  },
58
81
  {
@@ -64,31 +87,105 @@ export const clawnxSkill = {
64
87
  {
65
88
  name: 'query',
66
89
  type: 'string',
67
- description: 'Search query (for search action). Supports X query syntax ($TICKER, from:user, etc.).',
90
+ description: 'Search query (for search, search_users). Supports X query syntax ($TICKER, from:user, etc.).',
68
91
  required: false,
69
92
  },
70
93
  {
71
94
  name: 'tweet_id',
72
95
  type: 'string',
73
- description: 'Tweet ID or URL (for get_tweet, like, retweet, delete_tweet).',
96
+ description: 'Tweet ID or URL (for get_tweet, like, unlike, retweet, unretweet, delete_tweet, bookmark, unbookmark, get_conversation, liking_users, retweeted_by, quote_tweets).',
74
97
  required: false,
75
98
  },
76
99
  {
77
100
  name: 'username',
78
101
  type: 'string',
79
- description: 'Username without @ (for get_user, follow, get_timeline).',
102
+ description: 'Username without @ (for get_user, follow, unfollow, get_timeline, list_followers, list_following, block, unblock, mute, unmute, send_dm, list_likes, lookup_users).',
103
+ required: false,
104
+ },
105
+ {
106
+ name: 'usernames',
107
+ type: 'string',
108
+ description: 'Comma-separated usernames for lookup_users (e.g. "alice,bob,carol").',
80
109
  required: false,
81
110
  },
82
111
  {
83
112
  name: 'reply_to',
84
113
  type: 'string',
85
- description: 'Tweet ID or URL to reply to (for post_tweet).',
114
+ description: 'Tweet ID or URL to reply to (for post_tweet, post_with_media).',
86
115
  required: false,
87
116
  },
88
117
  {
89
118
  name: 'quote',
90
119
  type: 'string',
91
- description: 'Tweet ID or URL to quote (for post_tweet).',
120
+ description: 'Tweet ID or URL to quote (for post_tweet, post_with_media).',
121
+ required: false,
122
+ },
123
+ {
124
+ name: 'media_url',
125
+ type: 'string',
126
+ description: 'URL to media file for upload_media or post_with_media. Supports images, GIFs, videos.',
127
+ required: false,
128
+ },
129
+ {
130
+ name: 'media_type',
131
+ type: 'string',
132
+ description: 'MIME type override (e.g. "image/jpeg", "video/mp4"). Auto-detected if omitted.',
133
+ required: false,
134
+ },
135
+ {
136
+ name: 'count',
137
+ type: 'number',
138
+ description: 'Number of results to return for list/pagination actions (default: 20, max: 100).',
139
+ required: false,
140
+ default: 20,
141
+ },
142
+ {
143
+ name: 'list_id',
144
+ type: 'string',
145
+ description: 'List ID (for list actions: get_list, delete_list, add_list_member, remove_list_member, list_members, list_tweets).',
146
+ required: false,
147
+ },
148
+ {
149
+ name: 'list_name',
150
+ type: 'string',
151
+ description: 'List name (for create_list).',
152
+ required: false,
153
+ },
154
+ {
155
+ name: 'list_description',
156
+ type: 'string',
157
+ description: 'List description (for create_list).',
158
+ required: false,
159
+ },
160
+ {
161
+ name: 'list_private',
162
+ type: 'boolean',
163
+ description: 'Whether the list is private (for create_list). Default: false.',
164
+ required: false,
165
+ },
166
+ {
167
+ name: 'conversation_id',
168
+ type: 'string',
169
+ description: 'DM conversation ID (for send_dm_to_conversation, get_dm_conversation).',
170
+ required: false,
171
+ },
172
+ {
173
+ name: 'stream_rules',
174
+ type: 'string',
175
+ description: 'JSON array of stream filter rules for stream_rules_set. E.g. [{"value":"$CLAWNCH","tag":"token"}].',
176
+ required: false,
177
+ },
178
+ {
179
+ name: 'stream_duration',
180
+ type: 'number',
181
+ description: 'Stream duration in seconds for stream_start (default: 30, max: 300).',
182
+ required: false,
183
+ default: 30,
184
+ },
185
+ {
186
+ name: 'chain_steps',
187
+ type: 'string',
188
+ description: 'JSON array of action steps for action_chain. Each step is {action, ...params}. Use "PREV_TWEET_ID" to reference the previous step\'s tweet ID.',
92
189
  required: false,
93
190
  },
94
191
  ],
@@ -112,6 +209,7 @@ export const clawnxSkill = {
112
209
  callId: '',
113
210
  success: true,
114
211
  result: `Posted tweet ${tweet.id}: ${tweet.text}\nhttps://x.com/i/status/${tweet.id}`,
212
+ metadata: { tweet_id: tweet.id },
115
213
  };
116
214
  }
117
215
  case 'post_thread': {
@@ -124,11 +222,12 @@ export const clawnxSkill = {
124
222
  }
125
223
  const threadTweets = texts.map((t) => ({ text: t }));
126
224
  const result = await x.postThread(threadTweets);
127
- const ids = result.tweets.map((t) => t.data?.id ?? 'unknown');
225
+ const ids = result.tweetIds;
128
226
  return {
129
227
  callId: '',
130
228
  success: true,
131
229
  result: `Posted ${ids.length}-tweet thread.\nFirst: https://x.com/i/status/${ids[0]}\nIDs: ${ids.join(', ')}`,
230
+ metadata: { tweet_ids: ids, first_tweet_id: ids[0] },
132
231
  };
133
232
  }
134
233
  case 'search': {
@@ -189,7 +288,7 @@ export const clawnxSkill = {
189
288
  const username = params.username;
190
289
  if (!username)
191
290
  return { callId: '', success: false, result: null, error: 'username is required for get_timeline' };
192
- const result = await x.getUserTimeline({ username, maxResults: 10 });
291
+ const result = await x.getUserTimeline(username, { maxResults: 10 });
193
292
  const tweets = result.data ?? [];
194
293
  if (tweets.length === 0)
195
294
  return { callId: '', success: true, result: `No recent tweets from @${username}.` };
@@ -207,12 +306,608 @@ export const clawnxSkill = {
207
306
  await x.deleteTweet(tweetId);
208
307
  return { callId: '', success: true, result: `Deleted tweet ${tweetId}` };
209
308
  }
309
+ // ==================================================================
310
+ // Undo engagement
311
+ // ==================================================================
312
+ case 'unlike': {
313
+ const tweetId = params.tweet_id;
314
+ if (!tweetId)
315
+ return { callId: '', success: false, result: null, error: 'tweet_id is required for unlike' };
316
+ await x.unlikeTweet(tweetId);
317
+ return { callId: '', success: true, result: `Unliked tweet ${tweetId}` };
318
+ }
319
+ case 'unretweet': {
320
+ const tweetId = params.tweet_id;
321
+ if (!tweetId)
322
+ return { callId: '', success: false, result: null, error: 'tweet_id is required for unretweet' };
323
+ await x.unretweet(tweetId);
324
+ return { callId: '', success: true, result: `Unretweeted ${tweetId}` };
325
+ }
326
+ case 'unfollow': {
327
+ const username = params.username;
328
+ if (!username)
329
+ return { callId: '', success: false, result: null, error: 'username is required for unfollow' };
330
+ await x.unfollowUser(username);
331
+ return { callId: '', success: true, result: `Unfollowed @${username}` };
332
+ }
333
+ // ==================================================================
334
+ // Bookmarks
335
+ // ==================================================================
336
+ case 'bookmark': {
337
+ const tweetId = params.tweet_id;
338
+ if (!tweetId)
339
+ return { callId: '', success: false, result: null, error: 'tweet_id is required for bookmark' };
340
+ await x.bookmarkTweet(tweetId);
341
+ return { callId: '', success: true, result: `Bookmarked tweet ${tweetId}` };
342
+ }
343
+ case 'unbookmark': {
344
+ const tweetId = params.tweet_id;
345
+ if (!tweetId)
346
+ return { callId: '', success: false, result: null, error: 'tweet_id is required for unbookmark' };
347
+ await x.unbookmarkTweet(tweetId);
348
+ return { callId: '', success: true, result: `Removed bookmark for tweet ${tweetId}` };
349
+ }
350
+ case 'list_bookmarks': {
351
+ const count = params.count ?? 20;
352
+ const result = await x.getBookmarks({ maxResults: Math.min(count, 100) });
353
+ const tweets = result.data ?? [];
354
+ if (tweets.length === 0)
355
+ return { callId: '', success: true, result: 'No bookmarks.' };
356
+ const lines = tweets.map(formatTweet);
357
+ return { callId: '', success: true, result: `${tweets.length} bookmarked tweets:\n${lines.join('\n')}` };
358
+ }
359
+ // ==================================================================
360
+ // Block / Mute
361
+ // ==================================================================
362
+ case 'block': {
363
+ const username = params.username;
364
+ if (!username)
365
+ return { callId: '', success: false, result: null, error: 'username is required for block' };
366
+ await x.blockUser(username);
367
+ return { callId: '', success: true, result: `Blocked @${username}` };
368
+ }
369
+ case 'unblock': {
370
+ const username = params.username;
371
+ if (!username)
372
+ return { callId: '', success: false, result: null, error: 'username is required for unblock' };
373
+ await x.unblockUser(username);
374
+ return { callId: '', success: true, result: `Unblocked @${username}` };
375
+ }
376
+ case 'mute': {
377
+ const username = params.username;
378
+ if (!username)
379
+ return { callId: '', success: false, result: null, error: 'username is required for mute' };
380
+ await x.muteUser(username);
381
+ return { callId: '', success: true, result: `Muted @${username}` };
382
+ }
383
+ case 'unmute': {
384
+ const username = params.username;
385
+ if (!username)
386
+ return { callId: '', success: false, result: null, error: 'username is required for unmute' };
387
+ await x.unmuteUser(username);
388
+ return { callId: '', success: true, result: `Unmuted @${username}` };
389
+ }
390
+ case 'list_blocked': {
391
+ const count = params.count ?? 20;
392
+ const result = await x.getBlockedUsers({ maxResults: Math.min(count, 100) });
393
+ const users = result.data ?? [];
394
+ if (users.length === 0)
395
+ return { callId: '', success: true, result: 'No blocked users.' };
396
+ const lines = users.map(formatUser);
397
+ return { callId: '', success: true, result: `${users.length} blocked users:\n${lines.join('\n')}` };
398
+ }
399
+ case 'list_muted': {
400
+ const count = params.count ?? 20;
401
+ const result = await x.getMutedUsers({ maxResults: Math.min(count, 100) });
402
+ const users = result.data ?? [];
403
+ if (users.length === 0)
404
+ return { callId: '', success: true, result: 'No muted users.' };
405
+ const lines = users.map(formatUser);
406
+ return { callId: '', success: true, result: `${users.length} muted users:\n${lines.join('\n')}` };
407
+ }
408
+ // ==================================================================
409
+ // Social graph
410
+ // ==================================================================
411
+ case 'list_followers': {
412
+ const username = params.username;
413
+ if (!username)
414
+ return { callId: '', success: false, result: null, error: 'username is required for list_followers' };
415
+ const count = params.count ?? 20;
416
+ const result = await x.getFollowers(username, { maxResults: Math.min(count, 100) });
417
+ const users = result.data ?? [];
418
+ if (users.length === 0)
419
+ return { callId: '', success: true, result: `@${username} has no followers (or account is private).` };
420
+ const lines = users.map(formatUser);
421
+ return {
422
+ callId: '',
423
+ success: true,
424
+ result: `@${username}'s followers (${users.length}):\n${lines.join('\n')}`,
425
+ metadata: { count: users.length, username },
426
+ };
427
+ }
428
+ case 'list_following': {
429
+ const username = params.username;
430
+ if (!username)
431
+ return { callId: '', success: false, result: null, error: 'username is required for list_following' };
432
+ const count = params.count ?? 20;
433
+ const result = await x.getFollowing(username, { maxResults: Math.min(count, 100) });
434
+ const users = result.data ?? [];
435
+ if (users.length === 0)
436
+ return { callId: '', success: true, result: `@${username} follows nobody.` };
437
+ const lines = users.map(formatUser);
438
+ return {
439
+ callId: '',
440
+ success: true,
441
+ result: `@${username} follows (${users.length}):\n${lines.join('\n')}`,
442
+ metadata: { count: users.length, username },
443
+ };
444
+ }
445
+ // ==================================================================
446
+ // Engagement lookups
447
+ // ==================================================================
448
+ case 'list_likes': {
449
+ const username = params.username;
450
+ if (!username)
451
+ return { callId: '', success: false, result: null, error: 'username is required for list_likes' };
452
+ const count = params.count ?? 20;
453
+ const result = await x.getLikedTweets(username, { maxResults: Math.min(count, 100) });
454
+ const tweets = result.data ?? [];
455
+ if (tweets.length === 0)
456
+ return { callId: '', success: true, result: `@${username} has no public likes.` };
457
+ const lines = tweets.map(formatTweet);
458
+ return { callId: '', success: true, result: `@${username}'s liked tweets (${tweets.length}):\n${lines.join('\n')}` };
459
+ }
460
+ case 'liking_users': {
461
+ const tweetId = params.tweet_id;
462
+ if (!tweetId)
463
+ return { callId: '', success: false, result: null, error: 'tweet_id is required for liking_users' };
464
+ const count = params.count ?? 20;
465
+ const result = await x.getLikingUsers(tweetId, { maxResults: Math.min(count, 100) });
466
+ const users = result.data ?? [];
467
+ if (users.length === 0)
468
+ return { callId: '', success: true, result: 'No users liked this tweet.' };
469
+ const lines = users.map(formatUser);
470
+ return {
471
+ callId: '',
472
+ success: true,
473
+ result: `Users who liked ${tweetId} (${users.length}):\n${lines.join('\n')}`,
474
+ metadata: { count: users.length },
475
+ };
476
+ }
477
+ case 'retweeted_by': {
478
+ const tweetId = params.tweet_id;
479
+ if (!tweetId)
480
+ return { callId: '', success: false, result: null, error: 'tweet_id is required for retweeted_by' };
481
+ const count = params.count ?? 20;
482
+ const result = await x.getRetweetedBy(tweetId, { maxResults: Math.min(count, 100) });
483
+ const users = result.data ?? [];
484
+ if (users.length === 0)
485
+ return { callId: '', success: true, result: 'No users retweeted this tweet.' };
486
+ const lines = users.map(formatUser);
487
+ return {
488
+ callId: '',
489
+ success: true,
490
+ result: `Users who retweeted ${tweetId} (${users.length}):\n${lines.join('\n')}`,
491
+ metadata: { count: users.length },
492
+ };
493
+ }
494
+ case 'quote_tweets': {
495
+ const tweetId = params.tweet_id;
496
+ if (!tweetId)
497
+ return { callId: '', success: false, result: null, error: 'tweet_id is required for quote_tweets' };
498
+ const count = params.count ?? 20;
499
+ const result = await x.getQuoteTweets(tweetId, { maxResults: Math.min(count, 100) });
500
+ const tweets = result.data ?? [];
501
+ if (tweets.length === 0)
502
+ return { callId: '', success: true, result: 'No quote tweets.' };
503
+ const lines = tweets.map(formatTweet);
504
+ return { callId: '', success: true, result: `Quote tweets of ${tweetId} (${tweets.length}):\n${lines.join('\n')}` };
505
+ }
506
+ // ==================================================================
507
+ // Home timeline
508
+ // ==================================================================
509
+ case 'home_timeline': {
510
+ const count = params.count ?? 20;
511
+ const result = await x.getHomeTimeline({ maxResults: Math.min(count, 100) });
512
+ const tweets = result.data ?? [];
513
+ if (tweets.length === 0)
514
+ return { callId: '', success: true, result: 'Home timeline is empty.' };
515
+ const lines = tweets.map(formatTweet);
516
+ return { callId: '', success: true, result: `Home timeline (${tweets.length} tweets):\n${lines.join('\n')}` };
517
+ }
518
+ // ==================================================================
519
+ // Tweet metrics (detailed)
520
+ // ==================================================================
521
+ case 'get_tweet_metrics': {
522
+ const tweetId = params.tweet_id;
523
+ if (!tweetId)
524
+ return { callId: '', success: false, result: null, error: 'tweet_id is required for get_tweet_metrics' };
525
+ const result = await x.getTweetMetrics(tweetId);
526
+ const t = result.data;
527
+ const m = t.public_metrics;
528
+ const lines = [
529
+ `Tweet ${t.id}: ${t.text?.substring(0, 100)}${(t.text?.length ?? 0) > 100 ? '...' : ''}`,
530
+ m ? ` Likes: ${m.like_count} Retweets: ${m.retweet_count} Replies: ${m.reply_count} Quotes: ${m.quote_count ?? 0} Impressions: ${m.impression_count ?? 'N/A'} Bookmarks: ${m.bookmark_count ?? 'N/A'}` : ' Metrics unavailable',
531
+ ];
532
+ return { callId: '', success: true, result: lines.join('\n'), metadata: { metrics: m } };
533
+ }
534
+ // ==================================================================
535
+ // Conversation / thread retrieval
536
+ // ==================================================================
537
+ case 'get_conversation': {
538
+ const tweetId = params.tweet_id;
539
+ if (!tweetId)
540
+ return { callId: '', success: false, result: null, error: 'tweet_id is required for get_conversation' };
541
+ const count = params.count ?? 20;
542
+ const result = await x.getConversation(tweetId, { maxResults: Math.min(count, 100) });
543
+ const tweets = result.data ?? [];
544
+ if (tweets.length === 0)
545
+ return { callId: '', success: true, result: 'No conversation thread found.' };
546
+ const lines = tweets.map(formatTweet);
547
+ return { callId: '', success: true, result: `Conversation thread (${tweets.length} tweets):\n${lines.join('\n')}` };
548
+ }
549
+ // ==================================================================
550
+ // Direct Messages
551
+ // ==================================================================
552
+ case 'send_dm': {
553
+ const username = params.username;
554
+ const text = params.text;
555
+ if (!username)
556
+ return { callId: '', success: false, result: null, error: 'username is required for send_dm' };
557
+ if (!text)
558
+ return { callId: '', success: false, result: null, error: 'text is required for send_dm' };
559
+ const result = await x.sendDM(username, { text });
560
+ const eventId = result.data?.dm_event_id ?? result.data?.id ?? 'sent';
561
+ return {
562
+ callId: '',
563
+ success: true,
564
+ result: `DM sent to @${username}: "${text.substring(0, 60)}${text.length > 60 ? '...' : ''}"\nEvent: ${eventId}`,
565
+ metadata: { dm_event_id: eventId, recipient: username },
566
+ };
567
+ }
568
+ case 'send_dm_to_conversation': {
569
+ const conversationId = params.conversation_id;
570
+ const text = params.text;
571
+ if (!conversationId)
572
+ return { callId: '', success: false, result: null, error: 'conversation_id is required for send_dm_to_conversation' };
573
+ if (!text)
574
+ return { callId: '', success: false, result: null, error: 'text is required for send_dm_to_conversation' };
575
+ const result = await x.sendDMToConversation(conversationId, { text });
576
+ const eventId = result.data?.dm_event_id ?? result.data?.id ?? 'sent';
577
+ return {
578
+ callId: '',
579
+ success: true,
580
+ result: `DM sent to conversation ${conversationId}: "${text.substring(0, 60)}${text.length > 60 ? '...' : ''}"\nEvent: ${eventId}`,
581
+ metadata: { dm_event_id: eventId, conversation_id: conversationId },
582
+ };
583
+ }
584
+ case 'list_dms': {
585
+ const count = params.count ?? 20;
586
+ const result = await x.getDMEvents({ maxResults: Math.min(count, 100) });
587
+ const events = result.data ?? [];
588
+ if (events.length === 0)
589
+ return { callId: '', success: true, result: 'No recent DM events.' };
590
+ const lines = events.map((e) => {
591
+ const sender = e.sender_id ?? 'unknown';
592
+ const text = e.text ?? e.dm_conversation_id ?? '';
593
+ const time = e.created_at ? ` (${e.created_at})` : '';
594
+ return ` ${e.id}: [${sender}] ${text.substring(0, 80)}${text.length > 80 ? '...' : ''}${time}`;
595
+ });
596
+ return { callId: '', success: true, result: `${events.length} recent DM events:\n${lines.join('\n')}` };
597
+ }
598
+ case 'get_dm_conversation': {
599
+ const conversationId = params.conversation_id;
600
+ if (!conversationId)
601
+ return { callId: '', success: false, result: null, error: 'conversation_id is required for get_dm_conversation' };
602
+ const count = params.count ?? 20;
603
+ const result = await x.getDMConversation(conversationId, { maxResults: Math.min(count, 100) });
604
+ const events = result.data ?? [];
605
+ if (events.length === 0)
606
+ return { callId: '', success: true, result: 'No messages in this conversation.' };
607
+ const lines = events.map((e) => {
608
+ const sender = e.sender_id ?? 'unknown';
609
+ const text = e.text ?? '';
610
+ const time = e.created_at ? ` (${e.created_at})` : '';
611
+ return ` [${sender}] ${text}${time}`;
612
+ });
613
+ return { callId: '', success: true, result: `Conversation ${conversationId} (${events.length} messages):\n${lines.join('\n')}` };
614
+ }
615
+ // ==================================================================
616
+ // Lists management
617
+ // ==================================================================
618
+ case 'create_list': {
619
+ const name = params.list_name;
620
+ if (!name)
621
+ return { callId: '', success: false, result: null, error: 'list_name is required for create_list' };
622
+ const result = await x.createList({
623
+ name,
624
+ description: params.list_description ?? '',
625
+ private: params.list_private ?? false,
626
+ });
627
+ const list = result.data;
628
+ return {
629
+ callId: '',
630
+ success: true,
631
+ result: `List created: "${list.name}" (ID: ${list.id})`,
632
+ metadata: { list_id: list.id, list_name: list.name },
633
+ };
634
+ }
635
+ case 'delete_list': {
636
+ const listId = params.list_id;
637
+ if (!listId)
638
+ return { callId: '', success: false, result: null, error: 'list_id is required for delete_list' };
639
+ await x.deleteList(listId);
640
+ return { callId: '', success: true, result: `Deleted list ${listId}` };
641
+ }
642
+ case 'get_list': {
643
+ const listId = params.list_id;
644
+ if (!listId)
645
+ return { callId: '', success: false, result: null, error: 'list_id is required for get_list' };
646
+ const result = await x.getList(listId);
647
+ const l = result.data;
648
+ return { callId: '', success: true, result: `List "${l.name}" (${l.id}): ${l.description ?? 'no description'} | ${l.member_count ?? '?'} members | ${l.private ? 'private' : 'public'}` };
649
+ }
650
+ case 'get_user_lists': {
651
+ const username = params.username;
652
+ if (!username)
653
+ return { callId: '', success: false, result: null, error: 'username is required for get_user_lists' };
654
+ const count = params.count ?? 20;
655
+ const result = await x.getUserLists(username, { maxResults: Math.min(count, 100) });
656
+ const lists = result.data ?? [];
657
+ if (lists.length === 0)
658
+ return { callId: '', success: true, result: `@${username} has no public lists.` };
659
+ const lines = lists.map((l) => ` ${l.id}: "${l.name}" — ${l.member_count ?? '?'} members`);
660
+ return { callId: '', success: true, result: `@${username}'s lists (${lists.length}):\n${lines.join('\n')}` };
661
+ }
662
+ case 'add_list_member': {
663
+ const listId = params.list_id;
664
+ const username = params.username;
665
+ if (!listId)
666
+ return { callId: '', success: false, result: null, error: 'list_id is required for add_list_member' };
667
+ if (!username)
668
+ return { callId: '', success: false, result: null, error: 'username is required for add_list_member' };
669
+ await x.addListMember(listId, username);
670
+ return { callId: '', success: true, result: `Added @${username} to list ${listId}` };
671
+ }
672
+ case 'remove_list_member': {
673
+ const listId = params.list_id;
674
+ const username = params.username;
675
+ if (!listId)
676
+ return { callId: '', success: false, result: null, error: 'list_id is required for remove_list_member' };
677
+ if (!username)
678
+ return { callId: '', success: false, result: null, error: 'username is required for remove_list_member' };
679
+ await x.removeListMember(listId, username);
680
+ return { callId: '', success: true, result: `Removed @${username} from list ${listId}` };
681
+ }
682
+ case 'list_members': {
683
+ const listId = params.list_id;
684
+ if (!listId)
685
+ return { callId: '', success: false, result: null, error: 'list_id is required for list_members' };
686
+ const count = params.count ?? 20;
687
+ const result = await x.getListMembers(listId, { maxResults: Math.min(count, 100) });
688
+ const users = result.data ?? [];
689
+ if (users.length === 0)
690
+ return { callId: '', success: true, result: 'No members in this list.' };
691
+ const lines = users.map(formatUser);
692
+ return { callId: '', success: true, result: `List ${listId} members (${users.length}):\n${lines.join('\n')}` };
693
+ }
694
+ case 'list_tweets': {
695
+ const listId = params.list_id;
696
+ if (!listId)
697
+ return { callId: '', success: false, result: null, error: 'list_id is required for list_tweets' };
698
+ const count = params.count ?? 20;
699
+ const result = await x.getListTweets(listId, { maxResults: Math.min(count, 100) });
700
+ const tweets = result.data ?? [];
701
+ if (tweets.length === 0)
702
+ return { callId: '', success: true, result: 'No tweets in this list.' };
703
+ const lines = tweets.map(formatTweet);
704
+ return { callId: '', success: true, result: `List ${listId} tweets (${tweets.length}):\n${lines.join('\n')}` };
705
+ }
706
+ // ==================================================================
707
+ // User lookup & search
708
+ // ==================================================================
709
+ case 'search_users': {
710
+ const query = params.query;
711
+ if (!query)
712
+ return { callId: '', success: false, result: null, error: 'query is required for search_users' };
713
+ const count = params.count ?? 20;
714
+ const result = await x.searchUsers(query, { maxResults: Math.min(count, 100) });
715
+ const users = result.data ?? [];
716
+ if (users.length === 0)
717
+ return { callId: '', success: true, result: `No users match "${query}".` };
718
+ const lines = users.map(formatUser);
719
+ return { callId: '', success: true, result: `Users matching "${query}" (${users.length}):\n${lines.join('\n')}` };
720
+ }
721
+ case 'lookup_users': {
722
+ const usernamesStr = params.usernames;
723
+ if (!usernamesStr)
724
+ return { callId: '', success: false, result: null, error: 'usernames (comma-separated) is required for lookup_users' };
725
+ const usernames = usernamesStr.split(',').map(u => u.trim()).filter(Boolean);
726
+ if (usernames.length === 0)
727
+ return { callId: '', success: false, result: null, error: 'No valid usernames provided' };
728
+ const result = await x.getUsersByUsernames(usernames);
729
+ const users = result.data ?? [];
730
+ if (users.length === 0)
731
+ return { callId: '', success: true, result: 'No users found.' };
732
+ const lines = users.map(formatUser);
733
+ return { callId: '', success: true, result: `${users.length} users:\n${lines.join('\n')}` };
734
+ }
735
+ // ==================================================================
736
+ // Media upload
737
+ // ==================================================================
738
+ case 'upload_media': {
739
+ const mediaUrl = params.media_url;
740
+ if (!mediaUrl)
741
+ return { callId: '', success: false, result: null, error: 'media_url is required for upload_media' };
742
+ const result = await x.uploadMediaFromUrl({
743
+ url: mediaUrl,
744
+ mimeType: params.media_type,
745
+ });
746
+ return {
747
+ callId: '',
748
+ success: true,
749
+ result: `Media uploaded. media_id: ${result.media_id_string}\nUse this ID with post_with_media or post_tweet.`,
750
+ metadata: { media_id: result.media_id_string },
751
+ };
752
+ }
753
+ case 'post_with_media': {
754
+ const text = params.text;
755
+ const mediaUrl = params.media_url;
756
+ if (!text)
757
+ return { callId: '', success: false, result: null, error: 'text is required for post_with_media' };
758
+ if (!mediaUrl)
759
+ return { callId: '', success: false, result: null, error: 'media_url is required for post_with_media' };
760
+ // Upload media first
761
+ const media = await x.uploadMediaFromUrl({
762
+ url: mediaUrl,
763
+ mimeType: params.media_type,
764
+ });
765
+ // Post tweet with media
766
+ const opts = { text, mediaIds: [media.media_id_string] };
767
+ if (params.reply_to)
768
+ opts.replyTo = params.reply_to;
769
+ if (params.quote)
770
+ opts.quoteTweetId = params.quote;
771
+ const result = await x.postTweet(opts);
772
+ const tweet = result.data;
773
+ return {
774
+ callId: '',
775
+ success: true,
776
+ result: `Posted tweet with media ${tweet.id}: ${tweet.text}\nhttps://x.com/i/status/${tweet.id}`,
777
+ metadata: { tweet_id: tweet.id, media_id: media.media_id_string },
778
+ };
779
+ }
780
+ // ==================================================================
781
+ // New: Streaming
782
+ // ==================================================================
783
+ case 'stream_start': {
784
+ const durationSec = Math.min(params.stream_duration ?? 30, 300);
785
+ // Stop any active stream first
786
+ x.stopStream();
787
+ const tweets = await x.streamFiltered({
788
+ durationMs: durationSec * 1000,
789
+ maxTweets: 50,
790
+ });
791
+ if (tweets.length === 0) {
792
+ return { callId: '', success: true, result: 'Stream completed. No tweets matched your filter rules. Set rules with stream_rules_set first.' };
793
+ }
794
+ const lines = tweets.map(formatTweet);
795
+ return {
796
+ callId: '',
797
+ success: true,
798
+ result: `Streamed ${tweets.length} tweets in ${durationSec}s:\n${lines.join('\n')}`,
799
+ metadata: { tweet_count: tweets.length, duration_sec: durationSec },
800
+ };
801
+ }
802
+ case 'stream_stop': {
803
+ x.stopStream();
804
+ return { callId: '', success: true, result: 'Stream stopped.' };
805
+ }
806
+ case 'stream_rules_set': {
807
+ const rulesJson = params.stream_rules;
808
+ if (!rulesJson)
809
+ return { callId: '', success: false, result: null, error: 'stream_rules (JSON array) is required for stream_rules_set' };
810
+ let rules;
811
+ try {
812
+ rules = JSON.parse(rulesJson);
813
+ }
814
+ catch {
815
+ return { callId: '', success: false, result: null, error: 'stream_rules is not valid JSON. Must be a JSON array of {value, tag?} objects.' };
816
+ }
817
+ if (!Array.isArray(rules) || rules.length === 0) {
818
+ return { callId: '', success: false, result: null, error: 'stream_rules must be a non-empty JSON array of {value, tag?} objects' };
819
+ }
820
+ const result = await x.setStreamRules(rules);
821
+ const added = result.length ?? 0;
822
+ return {
823
+ callId: '',
824
+ success: true,
825
+ result: `Stream rules updated. ${added} rules active.\nRules: ${rules.map(r => `"${r.value}"${r.tag ? ` (${r.tag})` : ''}`).join(', ')}`,
826
+ metadata: { rules_count: added },
827
+ };
828
+ }
829
+ case 'stream_rules_get': {
830
+ const rules = await x.getStreamRules();
831
+ if (rules.length === 0) {
832
+ return { callId: '', success: true, result: 'No stream filter rules configured. Use stream_rules_set to add rules.' };
833
+ }
834
+ const lines = rules.map((r) => ` ${r.id}: "${r.value}"${r.tag ? ` (${r.tag})` : ''}`);
835
+ return { callId: '', success: true, result: `${rules.length} active stream rules:\n${lines.join('\n')}` };
836
+ }
837
+ // ==================================================================
838
+ // New: Action chaining
839
+ // ==================================================================
840
+ case 'action_chain': {
841
+ const stepsJson = params.chain_steps;
842
+ if (!stepsJson)
843
+ return { callId: '', success: false, result: null, error: 'chain_steps (JSON array) is required for action_chain' };
844
+ let steps;
845
+ try {
846
+ steps = JSON.parse(stepsJson);
847
+ }
848
+ catch {
849
+ return { callId: '', success: false, result: null, error: 'chain_steps is not valid JSON. Must be a JSON array of action objects.' };
850
+ }
851
+ if (!Array.isArray(steps) || steps.length === 0) {
852
+ return { callId: '', success: false, result: null, error: 'chain_steps must be a non-empty JSON array of action objects' };
853
+ }
854
+ const results = [];
855
+ let prevTweetId = null;
856
+ for (let i = 0; i < steps.length; i++) {
857
+ const step = { ...steps[i] };
858
+ // Replace PREV_TWEET_ID placeholder with actual tweet ID from previous step
859
+ for (const [key, val] of Object.entries(step)) {
860
+ if (val === 'PREV_TWEET_ID') {
861
+ if (!prevTweetId) {
862
+ results.push(`Step ${i + 1} (${step.action}): FAILED — references PREV_TWEET_ID but no previous step produced a tweet ID`);
863
+ return {
864
+ callId: '',
865
+ success: false,
866
+ result: `Action chain halted at step ${i + 1}:\n${results.join('\n')}`,
867
+ error: `Step ${i + 1} references PREV_TWEET_ID but no previous step produced a tweet ID`,
868
+ };
869
+ }
870
+ step[key] = prevTweetId;
871
+ }
872
+ }
873
+ // Recursively execute each step through this same skill
874
+ const stepResult = await clawnxSkill.execute(step, {});
875
+ results.push(`Step ${i + 1} (${step.action}): ${stepResult.success ? 'OK' : 'FAILED'} — ${stepResult.result ?? stepResult.error}`);
876
+ // Extract tweet ID for chaining
877
+ if (stepResult.metadata?.tweet_id) {
878
+ prevTweetId = stepResult.metadata.tweet_id;
879
+ }
880
+ else if (stepResult.metadata?.first_tweet_id) {
881
+ prevTweetId = stepResult.metadata.first_tweet_id;
882
+ }
883
+ // Stop chain on failure
884
+ if (!stepResult.success) {
885
+ results.push(`Chain halted at step ${i + 1}.`);
886
+ break;
887
+ }
888
+ }
889
+ return {
890
+ callId: '',
891
+ success: true,
892
+ result: `Action chain completed (${steps.length} steps):\n${results.join('\n')}`,
893
+ metadata: { steps_executed: results.length, last_tweet_id: prevTweetId },
894
+ };
895
+ }
210
896
  default:
211
897
  return {
212
898
  callId: '',
213
899
  success: false,
214
900
  result: null,
215
- error: `Unknown ClawnX action: "${action}". Valid: post_tweet, post_thread, search, get_tweet, like, retweet, follow, get_user, get_mentions, get_timeline, get_my_profile, delete_tweet.`,
901
+ error: `Unknown ClawnX action: "${action}". Valid actions: ` +
902
+ 'Content: post_tweet, post_thread, post_with_media, upload_media, delete_tweet, get_tweet, search. ' +
903
+ 'Engagement: like, unlike, retweet, unretweet, bookmark, unbookmark, list_bookmarks, list_likes, liking_users, retweeted_by, quote_tweets. ' +
904
+ 'Social: follow, unfollow, list_followers, list_following, block, unblock, mute, unmute, list_blocked, list_muted, get_user, search_users, lookup_users. ' +
905
+ 'Timelines: get_timeline, home_timeline, get_mentions, get_my_profile, get_tweet_metrics. ' +
906
+ 'DMs: send_dm, send_dm_to_conversation, list_dms, get_dm_conversation. ' +
907
+ 'Threads: get_conversation. ' +
908
+ 'Lists: create_list, delete_list, get_list, get_user_lists, add_list_member, remove_list_member, list_members, list_tweets. ' +
909
+ 'Streaming: stream_start, stream_stop, stream_rules_set, stream_rules_get. ' +
910
+ 'Orchestration: action_chain.',
216
911
  };
217
912
  }
218
913
  }