viruagent-cli 0.6.1 → 0.7.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.
@@ -0,0 +1,626 @@
1
+ const fs = require('fs');
2
+ const { loadXSession, cookiesToHeader, loadRateLimits, saveRateLimits } = require('./session');
3
+ const { USER_AGENT, BEARER_TOKEN } = require('./auth');
4
+ const { getOperation, invalidateCache } = require('./graphqlSync');
5
+
6
+ const randomDelay = (minSec, maxSec) => {
7
+ const ms = (minSec + Math.random() * (maxSec - minSec)) * 1000;
8
+ return new Promise((resolve) => setTimeout(resolve, ms));
9
+ };
10
+
11
+ // ──────────────────────────────────────────────────────────────
12
+ // X (Twitter) Safe Action Rules (2026, community research)
13
+ //
14
+ // Account age matters significantly:
15
+ // New (0~30 days): use limits below (conservative)
16
+ // Mature (90+ days): can roughly double these
17
+ //
18
+ // [Minimum action intervals + random jitter ±30%]
19
+ // Tweet: 120~300s (2~5min)
20
+ // Like: 30~60s
21
+ // Retweet: 60~120s
22
+ // Follow: 120~180s
23
+ // Unfollow: 120~180s
24
+ //
25
+ // [Hourly / Daily limits (new account safe zone)]
26
+ // Tweet: 10/h, 50/day (hard cap 2,400/day including replies)
27
+ // Like: 15/h, 200/day (hard cap ~500-1,000)
28
+ // Retweet: 10/h, 50/day (counts toward tweet cap)
29
+ // Follow: 10/h, 100/day (hard cap 400/day)
30
+ // Unfollow: 8/h, 80/day
31
+ //
32
+ // [226 error triggers]
33
+ // - Burst patterns / fixed intervals
34
+ // - Repetitive content
35
+ // - Write-only (no read behavior)
36
+ // - New account + high volume
37
+ // - Cooldown: 12~48h after 226, don't resume immediately
38
+ // ──────────────────────────────────────────────────────────────
39
+
40
+ const DELAY = {
41
+ tweet: [120, 300], // 2~5min
42
+ like: [30, 60], // 30~60s
43
+ retweet: [60, 120], // 1~2min
44
+ follow: [120, 180], // 2~3min
45
+ unfollow: [120, 180], // 2~3min
46
+ };
47
+
48
+ const HOURLY_LIMIT = {
49
+ tweet: 10,
50
+ like: 15,
51
+ retweet: 10,
52
+ follow: 10,
53
+ unfollow: 8,
54
+ };
55
+
56
+ const DAILY_LIMIT = {
57
+ tweet: 50,
58
+ like: 200,
59
+ retweet: 50,
60
+ follow: 100,
61
+ unfollow: 80,
62
+ };
63
+
64
+ let lastActionTime = 0;
65
+
66
+ const createXApiClient = ({ sessionPath }) => {
67
+ let cachedCookies = null;
68
+ let countersCache = null;
69
+
70
+ // ── Cookie helpers ──
71
+
72
+ const getCookies = () => {
73
+ if (cachedCookies) return cachedCookies;
74
+ const cookies = loadXSession(sessionPath);
75
+ if (!cookies) {
76
+ throw new Error('No session file found. Please log in first.');
77
+ }
78
+ const authToken = cookies.find((c) => c.name === 'auth_token');
79
+ if (!authToken?.value) {
80
+ throw new Error('No valid cookies in session. Please log in again.');
81
+ }
82
+ cachedCookies = cookies;
83
+ return cookies;
84
+ };
85
+
86
+ const getCt0 = () => {
87
+ const cookies = getCookies();
88
+ const ct0 = cookies.find((c) => c.name === 'ct0');
89
+ return ct0?.value || '';
90
+ };
91
+
92
+ // ── Rate limit counters ──
93
+
94
+ const loadCounters = () => {
95
+ if (countersCache) return countersCache;
96
+ try {
97
+ const saved = loadRateLimits(sessionPath);
98
+ countersCache = saved || {};
99
+ } catch {
100
+ countersCache = {};
101
+ }
102
+ return countersCache;
103
+ };
104
+
105
+ const persistCounters = () => {
106
+ try {
107
+ if (!countersCache) return;
108
+ saveRateLimits(sessionPath, countersCache);
109
+ } catch {
110
+ // silent
111
+ }
112
+ };
113
+
114
+ const getCounter = (type) => {
115
+ const counters = loadCounters();
116
+ if (!counters[type]) {
117
+ counters[type] = { hourly: 0, daily: 0, hourStart: Date.now(), dayStart: Date.now() };
118
+ }
119
+ const c = counters[type];
120
+ const now = Date.now();
121
+ if (now - c.hourStart > 3600000) { c.hourly = 0; c.hourStart = now; }
122
+ if (now - c.dayStart > 86400000) { c.daily = 0; c.dayStart = now; }
123
+ return c;
124
+ };
125
+
126
+ const checkLimit = (type) => {
127
+ const c = getCounter(type);
128
+ const hourlyMax = HOURLY_LIMIT[type];
129
+ const dailyMax = DAILY_LIMIT[type];
130
+ if (hourlyMax && c.hourly >= hourlyMax) {
131
+ const waitMin = Math.ceil((3600000 - (Date.now() - c.hourStart)) / 60000);
132
+ throw new Error(`hourly_limit: ${type} exceeded hourly limit of ${hourlyMax}. Retry in ${waitMin} minutes.`);
133
+ }
134
+ if (dailyMax && c.daily >= dailyMax) {
135
+ throw new Error(`daily_limit: ${type} exceeded daily limit of ${dailyMax}. Try again tomorrow.`);
136
+ }
137
+ };
138
+
139
+ const incrementCounter = (type) => {
140
+ const c = getCounter(type);
141
+ c.hourly++;
142
+ c.daily++;
143
+ persistCounters();
144
+ };
145
+
146
+ const withDelay = async (type, fn) => {
147
+ checkLimit(type);
148
+ const [min, max] = DELAY[type] || [10, 20];
149
+ const elapsed = (Date.now() - lastActionTime) / 1000;
150
+ if (lastActionTime > 0 && elapsed < min) {
151
+ await randomDelay(min - elapsed, max - elapsed);
152
+ }
153
+ const result = await fn();
154
+ lastActionTime = Date.now();
155
+ incrementCounter(type);
156
+ return result;
157
+ };
158
+
159
+ // ── Core request layer ──
160
+
161
+ const buildHeaders = () => ({
162
+ 'User-Agent': USER_AGENT,
163
+ Authorization: `Bearer ${BEARER_TOKEN}`,
164
+ 'x-csrf-token': getCt0(),
165
+ 'x-twitter-auth-type': 'OAuth2Session',
166
+ 'x-twitter-active-user': 'yes',
167
+ 'x-twitter-client-language': 'ko',
168
+ Cookie: cookiesToHeader(getCookies()),
169
+ });
170
+
171
+ const request = async (url, options = {}) => {
172
+ const headers = { ...buildHeaders(), ...options.headers };
173
+ const res = await fetch(url, { ...options, headers, redirect: 'manual' });
174
+
175
+ if (res.status === 401 || res.status === 403) {
176
+ throw new Error(`Authentication error (${res.status}). Session expired.`);
177
+ }
178
+
179
+ if (res.status === 429) {
180
+ throw new Error('rate_limit: X API rate limit exceeded. Please wait and try again.');
181
+ }
182
+
183
+ if (!res.ok && !options.allowError) {
184
+ throw new Error(`X API error: ${res.status} ${res.statusText}`);
185
+ }
186
+
187
+ return res;
188
+ };
189
+
190
+ // ── GraphQL helpers ──
191
+
192
+ const buildFeatures = (featureSwitches) => {
193
+ const features = {};
194
+ for (const f of featureSwitches) features[f] = true;
195
+ return features;
196
+ };
197
+
198
+ const buildFieldToggles = (fieldToggles) => {
199
+ const toggles = {};
200
+ for (const f of fieldToggles) toggles[f] = true;
201
+ return toggles;
202
+ };
203
+
204
+ const graphqlQuery = async (operationName, variables = {}) => {
205
+ const op = await getOperation(operationName);
206
+ const features = buildFeatures(op.featureSwitches);
207
+ const fieldToggles = buildFieldToggles(op.fieldToggles);
208
+
209
+ const params = new URLSearchParams({
210
+ variables: JSON.stringify(variables),
211
+ features: JSON.stringify(features),
212
+ fieldToggles: JSON.stringify(fieldToggles),
213
+ });
214
+
215
+ const url = `https://x.com/i/api/graphql/${op.queryId}/${operationName}?${params}`;
216
+
217
+ // Use POST if URL is too long (>2000 chars) to avoid 404
218
+ let res;
219
+ if (url.length > 2000) {
220
+ res = await request(`https://x.com/i/api/graphql/${op.queryId}/${operationName}`, {
221
+ method: 'POST',
222
+ headers: { 'Content-Type': 'application/json' },
223
+ body: JSON.stringify({ variables, features, fieldToggles, queryId: op.queryId }),
224
+ });
225
+ } else {
226
+ res = await request(url);
227
+ }
228
+
229
+ const data = await res.json();
230
+
231
+ // If we get errors indicating stale queryId, re-sync and retry once
232
+ if (data.errors?.some((e) => e.message?.includes('Could not resolve'))) {
233
+ invalidateCache();
234
+ const retryOp = await getOperation(operationName);
235
+ const retryFeatures = buildFeatures(retryOp.featureSwitches);
236
+ const retryFieldToggles = buildFieldToggles(retryOp.fieldToggles);
237
+ const retryRes = await request(`https://x.com/i/api/graphql/${retryOp.queryId}/${operationName}`, {
238
+ method: 'POST',
239
+ headers: { 'Content-Type': 'application/json' },
240
+ body: JSON.stringify({ variables, features: retryFeatures, fieldToggles: retryFieldToggles, queryId: retryOp.queryId }),
241
+ });
242
+ return retryRes.json();
243
+ }
244
+
245
+ return data;
246
+ };
247
+
248
+ const graphqlMutation = async (operationName, variables = {}) => {
249
+ const op = await getOperation(operationName);
250
+ const body = JSON.stringify({
251
+ variables,
252
+ features: buildFeatures(op.featureSwitches),
253
+ fieldToggles: buildFieldToggles(op.fieldToggles),
254
+ queryId: op.queryId,
255
+ });
256
+ const res = await request(`https://x.com/i/api/graphql/${op.queryId}/${operationName}`, {
257
+ method: 'POST',
258
+ headers: { 'Content-Type': 'application/json' },
259
+ body,
260
+ });
261
+ const data = await res.json();
262
+
263
+ if (data.errors?.some((e) => e.message?.includes('Could not resolve'))) {
264
+ invalidateCache();
265
+ const retryOp = await getOperation(operationName);
266
+ const retryBody = JSON.stringify({
267
+ variables,
268
+ features: buildFeatures(retryOp.featureSwitches),
269
+ fieldToggles: buildFieldToggles(retryOp.fieldToggles),
270
+ queryId: retryOp.queryId,
271
+ });
272
+ const retryRes = await request(`https://x.com/i/api/graphql/${retryOp.queryId}/${operationName}`, {
273
+ method: 'POST',
274
+ headers: { 'Content-Type': 'application/json' },
275
+ body: retryBody,
276
+ });
277
+ return retryRes.json();
278
+ }
279
+
280
+ return data;
281
+ };
282
+
283
+ // ── API Methods ──
284
+
285
+ const getViewer = async () => {
286
+ const data = await graphqlQuery('Viewer');
287
+ const viewer = data?.data?.viewer?.user_results?.result;
288
+ if (!viewer) throw new Error('Failed to fetch viewer info.');
289
+ return {
290
+ id: viewer.rest_id,
291
+ username: viewer.core?.screen_name || viewer.legacy?.screen_name,
292
+ name: viewer.core?.name || viewer.legacy?.name,
293
+ description: viewer.legacy?.description,
294
+ followerCount: viewer.legacy?.followers_count || viewer.legacy?.normal_followers_count,
295
+ followingCount: viewer.legacy?.friends_count,
296
+ tweetCount: viewer.legacy?.statuses_count,
297
+ isVerified: viewer.is_blue_verified,
298
+ profileImageUrl: viewer.legacy?.profile_image_url_https || viewer.avatar?.image_url,
299
+ };
300
+ };
301
+
302
+ const getUserByScreenName = async (screenName) => {
303
+ const data = await graphqlQuery('UserByScreenName', { screen_name: screenName });
304
+ const user = data?.data?.user?.result;
305
+ if (!user) throw new Error(`User not found: ${screenName}`);
306
+ return {
307
+ id: user.rest_id,
308
+ username: user.core?.screen_name || user.legacy?.screen_name,
309
+ name: user.core?.name || user.legacy?.name,
310
+ description: user.legacy?.description,
311
+ followerCount: user.legacy?.followers_count || user.legacy?.normal_followers_count,
312
+ followingCount: user.legacy?.friends_count,
313
+ tweetCount: user.legacy?.statuses_count,
314
+ isVerified: user.is_blue_verified,
315
+ profileImageUrl: user.legacy?.profile_image_url_https || user.avatar?.image_url,
316
+ bannerUrl: user.legacy?.profile_banner_url,
317
+ location: user.legacy?.location,
318
+ url: user.legacy?.url,
319
+ createdAt: user.legacy?.created_at || user.core?.created_at,
320
+ };
321
+ };
322
+
323
+ const parseTweet = (entry) => {
324
+ const result = entry?.content?.itemContent?.tweet_results?.result
325
+ || entry?.tweet_results?.result
326
+ || entry;
327
+ const tweet = result?.tweet || result;
328
+ const legacy = tweet?.legacy;
329
+ if (!legacy) return null;
330
+
331
+ const userResult = tweet?.core?.user_results?.result;
332
+ const username = userResult?.core?.screen_name || userResult?.legacy?.screen_name;
333
+ const name = userResult?.core?.name || userResult?.legacy?.name;
334
+
335
+ return {
336
+ id: legacy.id_str || tweet.rest_id,
337
+ text: legacy.full_text,
338
+ username,
339
+ name,
340
+ likeCount: legacy.favorite_count,
341
+ retweetCount: legacy.retweet_count,
342
+ replyCount: legacy.reply_count,
343
+ quoteCount: legacy.quote_count,
344
+ viewCount: tweet.views?.count ? Number(tweet.views.count) : null,
345
+ createdAt: legacy.created_at,
346
+ url: legacy.id_str ? `https://x.com/i/status/${legacy.id_str}` : null,
347
+ isRetweet: Boolean(legacy.retweeted_status_result),
348
+ isReply: Boolean(legacy.in_reply_to_status_id_str),
349
+ mediaUrls: legacy.entities?.media?.map((m) => m.media_url_https) || [],
350
+ };
351
+ };
352
+
353
+ const getUserTweets = async (userId, count = 20) => {
354
+ const data = await graphqlQuery('UserTweets', {
355
+ userId,
356
+ count,
357
+ includePromotedContent: false,
358
+ withQuickPromoteEligibilityTweetFields: false,
359
+ withVoice: false,
360
+ withV2Timeline: true,
361
+ });
362
+ const timeline = data?.data?.user?.result?.timeline_v2?.timeline
363
+ || data?.data?.user?.result?.timeline?.timeline;
364
+ const instructions = timeline?.instructions || [];
365
+
366
+ return collectEntries(instructions)
367
+ .map(parseTweet)
368
+ .filter(Boolean);
369
+ };
370
+
371
+ const getTweetDetail = async (tweetId) => {
372
+ const data = await graphqlQuery('TweetDetail', {
373
+ focalTweetId: tweetId,
374
+ with_rux_injections: false,
375
+ rankingMode: 'Relevance',
376
+ includePromotedContent: false,
377
+ withCommunity: true,
378
+ withQuickPromoteEligibilityTweetFields: true,
379
+ withBirdwatchNotes: true,
380
+ withVoice: true,
381
+ withV2Timeline: true,
382
+ });
383
+ const instructions = data?.data?.threaded_conversation_with_injections_v2?.instructions || [];
384
+ const entries = instructions
385
+ .find((i) => i.type === 'TimelineAddEntries')?.entries || [];
386
+ const focal = entries.find((e) => e.entryId?.startsWith('tweet-'));
387
+ if (!focal) throw new Error(`Tweet not found: ${tweetId}`);
388
+ return parseTweet(focal);
389
+ };
390
+
391
+ const collectEntries = (instructions) => {
392
+ const entries = [];
393
+ for (const inst of instructions) {
394
+ if (inst.type === 'TimelineAddEntries' && inst.entries) {
395
+ entries.push(...inst.entries);
396
+ }
397
+ if (inst.entry) entries.push(inst.entry);
398
+ }
399
+ return entries;
400
+ };
401
+
402
+ const getHomeTimeline = async (count = 20) => {
403
+ const data = await graphqlQuery('HomeLatestTimeline', {
404
+ count,
405
+ includePromotedContent: false,
406
+ latestControlAvailable: true,
407
+ requestContext: 'launch',
408
+ withCommunity: true,
409
+ });
410
+ const instructions = data?.data?.home?.home_timeline_urt?.instructions || [];
411
+ return collectEntries(instructions)
412
+ .map(parseTweet)
413
+ .filter(Boolean);
414
+ };
415
+
416
+ const searchTimeline = async (query, count = 20) => {
417
+ const data = await graphqlQuery('SearchTimeline', {
418
+ rawQuery: query,
419
+ count,
420
+ querySource: 'typed_query',
421
+ product: 'Latest',
422
+ });
423
+ const instructions = data?.data?.search_by_raw_query?.search_timeline?.timeline?.instructions || [];
424
+ return collectEntries(instructions)
425
+ .map(parseTweet)
426
+ .filter(Boolean);
427
+ };
428
+
429
+ // ── Write operations (rate-limited) ──
430
+
431
+ const createTweet = (text, options = {}) => withDelay('tweet', async () => {
432
+ const variables = {
433
+ tweet_text: text,
434
+ dark_request: false,
435
+ media: {
436
+ media_entities: options.mediaIds?.map((id) => ({ media_id: id, tagged_users: [] })) || [],
437
+ possibly_sensitive: false,
438
+ },
439
+ semantic_annotation_ids: [],
440
+ };
441
+
442
+ if (options.replyTo) {
443
+ variables.reply = {
444
+ in_reply_to_tweet_id: options.replyTo,
445
+ exclude_reply_user_ids: [],
446
+ };
447
+ }
448
+
449
+ const data = await graphqlMutation('CreateTweet', variables);
450
+
451
+ // Check for errors (e.g., 226 automated request detection)
452
+ if (data.errors?.length) {
453
+ const err = data.errors[0];
454
+ throw new Error(`CreateTweet failed: ${err.message || JSON.stringify(err)}`);
455
+ }
456
+
457
+ const result = data?.data?.create_tweet?.tweet_results?.result;
458
+ const tweetId = result?.rest_id || result?.tweet?.rest_id;
459
+
460
+ if (!tweetId) {
461
+ throw new Error('CreateTweet: No tweet ID in response. The tweet may not have been created.');
462
+ }
463
+
464
+ return {
465
+ id: tweetId,
466
+ text,
467
+ url: `https://x.com/i/status/${tweetId}`,
468
+ };
469
+ });
470
+
471
+ const deleteTweet = async (tweetId) => {
472
+ const data = await graphqlMutation('DeleteTweet', { tweet_id: tweetId, dark_request: false });
473
+ return { status: data?.data?.delete_tweet?.tweet_results ? 'ok' : 'fail' };
474
+ };
475
+
476
+ const likeTweet = (tweetId) => withDelay('like', async () => {
477
+ const data = await graphqlMutation('FavoriteTweet', { tweet_id: tweetId });
478
+ return { status: data?.data?.favorite_tweet ? 'ok' : 'fail' };
479
+ });
480
+
481
+ const unlikeTweet = (tweetId) => withDelay('like', async () => {
482
+ const data = await graphqlMutation('UnfavoriteTweet', { tweet_id: tweetId });
483
+ return { status: data?.data?.unfavorite_tweet ? 'ok' : 'fail' };
484
+ });
485
+
486
+ const retweet = (tweetId) => withDelay('retweet', async () => {
487
+ const data = await graphqlMutation('CreateRetweet', { tweet_id: tweetId, dark_request: false });
488
+ return { status: data?.data?.create_retweet?.retweet_results ? 'ok' : 'fail' };
489
+ });
490
+
491
+ const unretweet = async (tweetId) => {
492
+ const data = await graphqlMutation('DeleteRetweet', { source_tweet_id: tweetId, dark_request: false });
493
+ return { status: data?.data?.unretweet ? 'ok' : 'fail' };
494
+ };
495
+
496
+ // ── Follow / Unfollow (v1.1 REST API) ──
497
+
498
+ const followUser = (userId) => withDelay('follow', async () => {
499
+ const body = new URLSearchParams({ user_id: userId });
500
+ const res = await request('https://x.com/i/api/1.1/friendships/create.json', {
501
+ method: 'POST',
502
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
503
+ body: body.toString(),
504
+ });
505
+ const data = await res.json();
506
+ return { status: data?.id_str ? 'ok' : 'fail', following: true, username: data?.screen_name };
507
+ });
508
+
509
+ const unfollowUser = (userId) => withDelay('unfollow', async () => {
510
+ const body = new URLSearchParams({ user_id: userId });
511
+ const res = await request('https://x.com/i/api/1.1/friendships/destroy.json', {
512
+ method: 'POST',
513
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
514
+ body: body.toString(),
515
+ });
516
+ const data = await res.json();
517
+ return { status: data?.id_str ? 'ok' : 'fail', following: false, username: data?.screen_name };
518
+ });
519
+
520
+ // ── Media upload (chunked, v1.1 REST API) ──
521
+
522
+ const uploadMedia = async (buffer, mediaType = 'image/jpeg') => {
523
+ const totalBytes = buffer.length;
524
+ const mediaCategory = mediaType.startsWith('video/') ? 'tweet_video'
525
+ : mediaType === 'image/gif' ? 'tweet_gif' : 'tweet_image';
526
+
527
+ // INIT
528
+ const initBody = new URLSearchParams({
529
+ command: 'INIT',
530
+ total_bytes: totalBytes.toString(),
531
+ media_type: mediaType,
532
+ media_category: mediaCategory,
533
+ });
534
+ const initRes = await request('https://upload.x.com/i/media/upload.json', {
535
+ method: 'POST',
536
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
537
+ body: initBody.toString(),
538
+ });
539
+ const initData = await initRes.json();
540
+ const mediaId = initData.media_id_string;
541
+
542
+ // APPEND (single chunk for images, could extend for video)
543
+ const formData = new FormData();
544
+ formData.append('command', 'APPEND');
545
+ formData.append('media_id', mediaId);
546
+ formData.append('segment_index', '0');
547
+ formData.append('media_data', buffer.toString('base64'));
548
+
549
+ await request('https://upload.x.com/i/media/upload.json', {
550
+ method: 'POST',
551
+ body: formData,
552
+ });
553
+
554
+ // FINALIZE
555
+ const finalizeBody = new URLSearchParams({
556
+ command: 'FINALIZE',
557
+ media_id: mediaId,
558
+ });
559
+ const finalizeRes = await request('https://upload.x.com/i/media/upload.json', {
560
+ method: 'POST',
561
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
562
+ body: finalizeBody.toString(),
563
+ });
564
+ const finalizeData = await finalizeRes.json();
565
+
566
+ // Wait for processing if needed (video)
567
+ if (finalizeData.processing_info) {
568
+ let processing = finalizeData.processing_info;
569
+ while (processing.state === 'pending' || processing.state === 'in_progress') {
570
+ const waitSec = processing.check_after_secs || 5;
571
+ await new Promise((r) => setTimeout(r, waitSec * 1000));
572
+ const statusRes = await request(
573
+ `https://upload.x.com/i/media/upload.json?command=STATUS&media_id=${mediaId}`,
574
+ );
575
+ const statusData = await statusRes.json();
576
+ processing = statusData.processing_info;
577
+ if (!processing) break;
578
+ if (processing.state === 'failed') {
579
+ throw new Error(`Media processing failed: ${processing.error?.message || 'unknown'}`);
580
+ }
581
+ }
582
+ }
583
+
584
+ return mediaId;
585
+ };
586
+
587
+ const resetState = () => {
588
+ cachedCookies = null;
589
+ countersCache = null;
590
+ };
591
+
592
+ return {
593
+ getCookies,
594
+ getCt0,
595
+ getViewer,
596
+ getUserByScreenName,
597
+ getUserTweets,
598
+ getTweetDetail,
599
+ getHomeTimeline,
600
+ searchTimeline,
601
+ createTweet,
602
+ deleteTweet,
603
+ likeTweet,
604
+ unlikeTweet,
605
+ retweet,
606
+ unretweet,
607
+ followUser,
608
+ unfollowUser,
609
+ uploadMedia,
610
+ resetState,
611
+ getRateLimitStatus: () => {
612
+ const status = {};
613
+ for (const type of Object.keys(HOURLY_LIMIT)) {
614
+ const c = getCounter(type);
615
+ status[type] = {
616
+ hourly: `${c.hourly}/${HOURLY_LIMIT[type]}`,
617
+ daily: `${c.daily}/${DAILY_LIMIT[type]}`,
618
+ delay: `${DELAY[type]?.[0]}~${DELAY[type]?.[1]}s`,
619
+ };
620
+ }
621
+ return status;
622
+ },
623
+ };
624
+ };
625
+
626
+ module.exports = createXApiClient;
@@ -0,0 +1,87 @@
1
+ const { readXCredentials } = require('./utils');
2
+ const { saveXSession } = require('./session');
3
+
4
+ const USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36';
5
+ const BEARER_TOKEN = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs=1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
6
+
7
+ const createSetCredentials = ({ sessionPath }) => async ({
8
+ authToken,
9
+ ct0,
10
+ } = {}) => {
11
+ const creds = readXCredentials();
12
+ const resolvedAuthToken = authToken || creds.authToken;
13
+ const resolvedCt0 = ct0 || creds.ct0;
14
+
15
+ if (!resolvedAuthToken || !resolvedCt0) {
16
+ throw new Error(
17
+ 'X login requires auth_token and ct0 cookies. ' +
18
+ 'Please set X_AUTH_TOKEN / X_CT0 environment variables, ' +
19
+ 'or provide them via --auth-token / --ct0 flags.',
20
+ );
21
+ }
22
+
23
+ const cookies = [
24
+ { name: 'auth_token', value: resolvedAuthToken, domain: '.x.com', path: '/' },
25
+ { name: 'ct0', value: resolvedCt0, domain: '.x.com', path: '/' },
26
+ ];
27
+
28
+ // Verify the cookies work by calling Viewer query
29
+ const { getOperation } = require('./graphqlSync');
30
+ const op = await getOperation('Viewer');
31
+
32
+ const features = {};
33
+ for (const f of op.featureSwitches) features[f] = true;
34
+ const fieldToggles = {};
35
+ for (const f of op.fieldToggles) fieldToggles[f] = true;
36
+
37
+ const params = new URLSearchParams({
38
+ variables: JSON.stringify({}),
39
+ features: JSON.stringify(features),
40
+ fieldToggles: JSON.stringify(fieldToggles),
41
+ });
42
+
43
+ const res = await fetch(
44
+ `https://x.com/i/api/graphql/${op.queryId}/Viewer?${params}`,
45
+ {
46
+ headers: {
47
+ 'User-Agent': USER_AGENT,
48
+ Authorization: `Bearer ${BEARER_TOKEN}`,
49
+ 'x-csrf-token': resolvedCt0,
50
+ 'x-twitter-auth-type': 'OAuth2Session',
51
+ 'x-twitter-active-user': 'yes',
52
+ 'x-twitter-client-language': 'ko',
53
+ Cookie: `auth_token=${resolvedAuthToken}; ct0=${resolvedCt0}`,
54
+ },
55
+ },
56
+ );
57
+
58
+ if (res.status === 401 || res.status === 403) {
59
+ throw new Error(`Authentication failed (${res.status}). Please check your auth_token and ct0 cookies.`);
60
+ }
61
+
62
+ const data = await res.json();
63
+ const viewer = data?.data?.viewer;
64
+ const userResult = viewer?.user_results?.result;
65
+ const username = userResult?.core?.screen_name
66
+ || userResult?.legacy?.screen_name
67
+ || null;
68
+
69
+ if (!username) {
70
+ throw new Error('Failed to verify X session. The cookies may be expired.');
71
+ }
72
+
73
+ saveXSession(sessionPath, cookies);
74
+
75
+ return {
76
+ provider: 'x',
77
+ loggedIn: true,
78
+ username,
79
+ sessionPath,
80
+ };
81
+ };
82
+
83
+ module.exports = {
84
+ createSetCredentials,
85
+ USER_AGENT,
86
+ BEARER_TOKEN,
87
+ };