viruagent-cli 0.8.0 → 0.9.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,487 @@
1
+ const fs = require('fs');
2
+ const { loadThreadsSession, loadRateLimits, saveRateLimits } = require('./session');
3
+ const { THREADS_APP_ID, THREADS_USER_AGENT, BLOKS_VERSION, BASE_URL } = require('./auth');
4
+
5
+ const randomDelay = (minSec, maxSec) => {
6
+ const ms = (minSec + Math.random() * (maxSec - minSec)) * 1000;
7
+ return new Promise((resolve) => setTimeout(resolve, ms));
8
+ };
9
+
10
+ // ──────────────────────────────────────────────────────────────
11
+ // Threads Safe Action Rules (conservative, Instagram-based)
12
+ // ──────────────────────────────────────────────────────────────
13
+
14
+ const DELAY = {
15
+ publish: [120, 300], // 2~5min
16
+ like: [20, 40], // 20~40s
17
+ comment: [120, 300], // 2~5min
18
+ follow: [60, 120], // 1~2min
19
+ unfollow: [60, 120], // 1~2min
20
+ };
21
+
22
+ const HOURLY_LIMIT = {
23
+ publish: 10,
24
+ like: 15,
25
+ comment: 5,
26
+ follow: 15,
27
+ unfollow: 10,
28
+ };
29
+
30
+ const DAILY_LIMIT = {
31
+ publish: 50,
32
+ like: 500,
33
+ comment: 100,
34
+ follow: 250,
35
+ unfollow: 200,
36
+ };
37
+
38
+ let lastActionTime = 0;
39
+
40
+ const createThreadsApiClient = ({ sessionPath }) => {
41
+ let cachedSession = null;
42
+ let countersCache = null;
43
+
44
+ // ── Session helpers ──
45
+
46
+ const getSession = () => {
47
+ if (cachedSession) return cachedSession;
48
+ const session = loadThreadsSession(sessionPath);
49
+ if (!session) {
50
+ throw new Error('No session file found. Please log in first.');
51
+ }
52
+ if (!session.token) {
53
+ throw new Error('No valid token in session. Please log in again.');
54
+ }
55
+ cachedSession = session;
56
+ return session;
57
+ };
58
+
59
+ const getToken = () => getSession().token;
60
+ const getUserIdFromSession = () => getSession().userId;
61
+ const getDeviceId = () => getSession().deviceId;
62
+
63
+ // ── Rate Limit counters ──
64
+
65
+ const loadCounters = () => {
66
+ if (countersCache) return countersCache;
67
+ try {
68
+ const userId = getUserIdFromSession();
69
+ if (!userId) { countersCache = {}; return countersCache; }
70
+ const saved = loadRateLimits(sessionPath, userId);
71
+ countersCache = saved || {};
72
+ } catch {
73
+ countersCache = {};
74
+ }
75
+ return countersCache;
76
+ };
77
+
78
+ const persistCounters = () => {
79
+ try {
80
+ const userId = getUserIdFromSession();
81
+ if (!userId || !countersCache) return;
82
+ saveRateLimits(sessionPath, userId, countersCache);
83
+ } catch {
84
+ // Save failure does not affect operation
85
+ }
86
+ };
87
+
88
+ const getCounter = (type) => {
89
+ const counters = loadCounters();
90
+ if (!counters[type]) {
91
+ counters[type] = { hourly: 0, daily: 0, hourStart: Date.now(), dayStart: Date.now() };
92
+ }
93
+ const c = counters[type];
94
+ const now = Date.now();
95
+ if (now - c.hourStart > 3600000) { c.hourly = 0; c.hourStart = now; }
96
+ if (now - c.dayStart > 86400000) { c.daily = 0; c.dayStart = now; }
97
+ return c;
98
+ };
99
+
100
+ const checkLimit = (type) => {
101
+ const c = getCounter(type);
102
+ const hourlyMax = HOURLY_LIMIT[type];
103
+ const dailyMax = DAILY_LIMIT[type];
104
+ if (hourlyMax && c.hourly >= hourlyMax) {
105
+ const waitMin = Math.ceil((3600000 - (Date.now() - c.hourStart)) / 60000);
106
+ throw new Error(`hourly_limit: ${type} exceeded hourly limit of ${hourlyMax}. Retry in ${waitMin} minutes.`);
107
+ }
108
+ if (dailyMax && c.daily >= dailyMax) {
109
+ throw new Error(`daily_limit: ${type} exceeded daily limit of ${dailyMax}. Try again tomorrow.`);
110
+ }
111
+ };
112
+
113
+ const incrementCounter = (type) => {
114
+ const c = getCounter(type);
115
+ c.hourly++;
116
+ c.daily++;
117
+ persistCounters();
118
+ };
119
+
120
+ const withDelay = async (type, fn) => {
121
+ checkLimit(type);
122
+
123
+ const [min, max] = DELAY[type] || [20, 40];
124
+ const elapsed = (Date.now() - lastActionTime) / 1000;
125
+ if (lastActionTime > 0 && elapsed < min) {
126
+ await randomDelay(min - elapsed, max - elapsed);
127
+ }
128
+
129
+ const result = await fn();
130
+ lastActionTime = Date.now();
131
+ incrementCounter(type);
132
+ return result;
133
+ };
134
+
135
+ // ── HTTP request helper ──
136
+
137
+ const getHeaders = () => ({
138
+ 'User-Agent': `${THREADS_USER_AGENT} (30/11; 420dpi; 1080x2400; samsung; SM-A325F; a32; exynos850)`,
139
+ 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
140
+ 'Authorization': `Bearer IGT:2:${getToken()}`,
141
+ 'X-IG-App-ID': THREADS_APP_ID,
142
+ 'X-Bloks-Version-Id': BLOKS_VERSION,
143
+ 'Sec-Fetch-Site': 'same-origin',
144
+ 'Sec-Fetch-Mode': 'cors',
145
+ 'Sec-Fetch-Dest': 'empty',
146
+ });
147
+
148
+ const request = async (url, options = {}) => {
149
+ const headers = { ...getHeaders(), ...options.headers };
150
+
151
+ const res = await fetch(url, {
152
+ ...options,
153
+ headers,
154
+ redirect: options.followRedirect ? 'follow' : 'manual',
155
+ });
156
+
157
+ if (res.status === 302 || res.status === 301) {
158
+ const location = res.headers.get('location') || '';
159
+ if (location.includes('/accounts/login') || location.includes('login')) {
160
+ throw new Error('Session expired. Please log in again.');
161
+ }
162
+ throw new Error(`Redirect occurred: ${res.status} -> ${location}`);
163
+ }
164
+
165
+ if (res.status === 401 || res.status === 403) {
166
+ throw new Error(`Authentication error (${res.status}). Please log in again.`);
167
+ }
168
+
169
+ if (!res.ok && !options.allowError) {
170
+ throw new Error(`Threads API error: ${res.status} ${res.statusText}`);
171
+ }
172
+
173
+ return res;
174
+ };
175
+
176
+ // ── API methods ──
177
+
178
+ const getUserId = async (username) => {
179
+ const res = await request(
180
+ `${BASE_URL}/api/v1/users/search/?q=${encodeURIComponent(username)}`,
181
+ );
182
+ const data = await res.json();
183
+ const user = data?.users?.find(
184
+ (u) => u.username?.toLowerCase() === username.toLowerCase(),
185
+ );
186
+ if (!user) throw new Error(`User not found: ${username}`);
187
+ return user.pk_id || user.pk || String(user.pk);
188
+ };
189
+
190
+ const getUserProfile = async (userId) => {
191
+ const res = await request(`${BASE_URL}/api/v1/users/${userId}/info/`);
192
+ const data = await res.json();
193
+ const user = data?.user;
194
+ if (!user) throw new Error(`Profile not found for userId: ${userId}`);
195
+ return {
196
+ id: user.pk || userId,
197
+ username: user.username,
198
+ fullName: user.full_name,
199
+ biography: user.biography,
200
+ followerCount: user.follower_count || 0,
201
+ followingCount: user.following_count || 0,
202
+ isPrivate: user.is_private,
203
+ isVerified: user.is_verified,
204
+ profilePicUrl: user.hd_profile_pic_url_info?.url || user.profile_pic_url,
205
+ };
206
+ };
207
+
208
+ const getTimeline = async () => {
209
+ const res = await request(`${BASE_URL}/api/v1/feed/text_post_app_timeline/`, {
210
+ method: 'POST',
211
+ body: '',
212
+ });
213
+ const data = await res.json();
214
+ const items = data?.items || [];
215
+ return items
216
+ .filter((item) => item.post || item.thread_items)
217
+ .slice(0, 20)
218
+ .map((item) => {
219
+ const threadItems = item.thread_items || [item];
220
+ const first = threadItems[0]?.post || threadItems[0];
221
+ return {
222
+ id: first?.pk || first?.id,
223
+ code: first?.code,
224
+ username: first?.user?.username,
225
+ caption: first?.caption?.text || '',
226
+ likeCount: first?.like_count || 0,
227
+ replyCount: first?.text_post_app_info?.direct_reply_count || 0,
228
+ timestamp: first?.taken_at,
229
+ };
230
+ });
231
+ };
232
+
233
+ const getUserThreads = async (userId, limit = 20) => {
234
+ const res = await request(`${BASE_URL}/api/v1/text_feed/${userId}/profile/`);
235
+ const data = await res.json();
236
+ const threads = data?.threads || [];
237
+ return threads.slice(0, limit).map((thread) => {
238
+ const items = thread.thread_items || [];
239
+ const first = items[0]?.post;
240
+ return {
241
+ id: first?.pk || first?.id,
242
+ code: first?.code,
243
+ caption: first?.caption?.text || '',
244
+ likeCount: first?.like_count || 0,
245
+ replyCount: first?.text_post_app_info?.direct_reply_count || 0,
246
+ timestamp: first?.taken_at,
247
+ };
248
+ });
249
+ };
250
+
251
+ const getThreadReplies = async (postId) => {
252
+ const res = await request(`${BASE_URL}/api/v1/text_feed/${postId}/replies/`);
253
+ const data = await res.json();
254
+ const items = data?.thread_items || [];
255
+ return items.map((item) => {
256
+ const post = item.post;
257
+ return {
258
+ id: post?.pk || post?.id,
259
+ username: post?.user?.username,
260
+ text: post?.caption?.text || '',
261
+ likeCount: post?.like_count || 0,
262
+ timestamp: post?.taken_at,
263
+ };
264
+ });
265
+ };
266
+
267
+ const publishTextThread = (text, replyToId) => withDelay('publish', async () => {
268
+ const userId = getUserIdFromSession();
269
+ const uploadId = Date.now().toString();
270
+ const deviceId = getDeviceId();
271
+
272
+ const payload = {
273
+ publish_mode: 'text_post',
274
+ text_post_app_info: JSON.stringify({ reply_control: 0 }),
275
+ timezone_offset: '32400',
276
+ source_type: '4',
277
+ caption: text,
278
+ upload_id: uploadId,
279
+ device_id: deviceId,
280
+ _uid: userId,
281
+ };
282
+
283
+ if (replyToId) {
284
+ payload.text_post_app_info = JSON.stringify({
285
+ reply_control: 0,
286
+ reply_id: replyToId,
287
+ });
288
+ }
289
+
290
+ const body = `signed_body=SIGNATURE.${encodeURIComponent(JSON.stringify(payload))}`;
291
+
292
+ const res = await request(`${BASE_URL}/api/v1/media/configure_text_only_post/`, {
293
+ method: 'POST',
294
+ body,
295
+ });
296
+
297
+ const data = await res.json();
298
+ if (data.status !== 'ok') {
299
+ throw new Error(`Thread publish failed: ${data.message || JSON.stringify(data)}`);
300
+ }
301
+
302
+ return {
303
+ id: data.media?.pk || data.media?.id,
304
+ code: data.media?.code,
305
+ caption: data.media?.caption?.text || text,
306
+ status: data.status,
307
+ };
308
+ });
309
+
310
+ const uploadImage = async (imageBuffer) => {
311
+ const uploadId = Date.now().toString();
312
+ const uploadName = `${uploadId}_0_${Math.floor(Math.random() * 9000000000 + 1000000000)}`;
313
+
314
+ const res = await request(
315
+ `https://www.instagram.com/rupload_igphoto/${uploadName}`,
316
+ {
317
+ method: 'POST',
318
+ headers: {
319
+ 'Content-Type': 'image/jpeg',
320
+ 'X-Entity-Name': uploadName,
321
+ 'X-Entity-Length': imageBuffer.length.toString(),
322
+ 'X-Instagram-Rupload-Params': JSON.stringify({
323
+ media_type: 1,
324
+ upload_id: uploadId,
325
+ upload_media_height: 1080,
326
+ upload_media_width: 1080,
327
+ }),
328
+ Offset: '0',
329
+ },
330
+ body: imageBuffer,
331
+ followRedirect: true,
332
+ },
333
+ );
334
+ const data = await res.json();
335
+ if (data.status !== 'ok') {
336
+ throw new Error(`Image upload failed: ${data.message || 'unknown'}`);
337
+ }
338
+ return uploadId;
339
+ };
340
+
341
+ const publishImageThread = (uploadId, text) => withDelay('publish', async () => {
342
+ const userId = getUserIdFromSession();
343
+ const deviceId = getDeviceId();
344
+
345
+ const payload = {
346
+ publish_mode: 'text_post',
347
+ text_post_app_info: JSON.stringify({ reply_control: 0 }),
348
+ timezone_offset: '32400',
349
+ source_type: '4',
350
+ caption: text || '',
351
+ upload_id: uploadId,
352
+ device_id: deviceId,
353
+ _uid: userId,
354
+ scene_capture_type: '',
355
+ };
356
+
357
+ const body = `signed_body=SIGNATURE.${encodeURIComponent(JSON.stringify(payload))}`;
358
+
359
+ // Single image uses configure_text_only_post (same as text, with upload_id)
360
+ const res = await request(`${BASE_URL}/api/v1/media/configure_text_only_post/`, {
361
+ method: 'POST',
362
+ body,
363
+ allowError: true,
364
+ });
365
+
366
+ const data = await res.json();
367
+ if (data.status !== 'ok') {
368
+ throw new Error(`Image thread publish failed: ${data.message || JSON.stringify(data)}`);
369
+ }
370
+
371
+ return {
372
+ id: data.media?.pk || data.media?.id,
373
+ code: data.media?.code,
374
+ caption: data.media?.caption?.text || text,
375
+ status: data.status,
376
+ };
377
+ });
378
+
379
+ const likeThread = (postId) => withDelay('like', async () => {
380
+ const userId = getUserIdFromSession();
381
+ const res = await request(
382
+ `${BASE_URL}/api/v1/media/${postId}_${userId}/like/`,
383
+ { method: 'POST', body: '', allowError: true },
384
+ );
385
+ const data = await res.json();
386
+ return { status: data.status || 'ok' };
387
+ });
388
+
389
+ const unlikeThread = (postId) => withDelay('like', async () => {
390
+ const userId = getUserIdFromSession();
391
+ const res = await request(
392
+ `${BASE_URL}/api/v1/media/${postId}_${userId}/unlike/`,
393
+ { method: 'POST', body: '', allowError: true },
394
+ );
395
+ const data = await res.json();
396
+ return { status: data.status || 'ok' };
397
+ });
398
+
399
+ const followUser = (userId) => withDelay('follow', async () => {
400
+ const res = await request(
401
+ `${BASE_URL}/api/v1/friendships/create/${userId}/`,
402
+ { method: 'POST', body: '', allowError: true },
403
+ );
404
+ const data = await res.json();
405
+ return {
406
+ status: data.status || (data.friendship_status ? 'ok' : 'fail'),
407
+ following: data.friendship_status?.following || false,
408
+ outgoingRequest: data.friendship_status?.outgoing_request || false,
409
+ };
410
+ });
411
+
412
+ const unfollowUser = (userId) => withDelay('unfollow', async () => {
413
+ const res = await request(
414
+ `${BASE_URL}/api/v1/friendships/destroy/${userId}/`,
415
+ { method: 'POST', body: '', allowError: true },
416
+ );
417
+ const data = await res.json();
418
+ return {
419
+ status: data.status || (data.friendship_status ? 'ok' : 'fail'),
420
+ following: data.friendship_status?.following || false,
421
+ };
422
+ });
423
+
424
+ const searchUsers = async (query, limit = 20) => {
425
+ const res = await request(
426
+ `${BASE_URL}/api/v1/users/search/?q=${encodeURIComponent(query)}`,
427
+ );
428
+ const data = await res.json();
429
+ const users = data?.users || [];
430
+ return users.slice(0, limit).map((u) => ({
431
+ id: u.pk || u.pk_id,
432
+ username: u.username,
433
+ fullName: u.full_name,
434
+ isVerified: u.is_verified,
435
+ profilePicUrl: u.profile_pic_url,
436
+ }));
437
+ };
438
+
439
+ const deleteThread = async (postId) => {
440
+ const res = await request(
441
+ `${BASE_URL}/api/v1/media/${postId}/delete/?media_type=TEXT_POST`,
442
+ { method: 'POST' },
443
+ );
444
+ return res.json();
445
+ };
446
+
447
+ const resetState = () => {
448
+ cachedSession = null;
449
+ countersCache = null;
450
+ };
451
+
452
+ return {
453
+ getSession,
454
+ getToken,
455
+ getUserIdFromSession,
456
+ request,
457
+ getUserId,
458
+ getUserProfile,
459
+ getTimeline,
460
+ getUserThreads,
461
+ getThreadReplies,
462
+ publishTextThread,
463
+ uploadImage,
464
+ publishImageThread,
465
+ likeThread,
466
+ unlikeThread,
467
+ followUser,
468
+ unfollowUser,
469
+ searchUsers,
470
+ deleteThread,
471
+ resetState,
472
+ getRateLimitStatus: () => {
473
+ const status = {};
474
+ for (const type of Object.keys(HOURLY_LIMIT)) {
475
+ const c = getCounter(type);
476
+ status[type] = {
477
+ hourly: `${c.hourly}/${HOURLY_LIMIT[type]}`,
478
+ daily: `${c.daily}/${DAILY_LIMIT[type]}`,
479
+ delay: `${DELAY[type]?.[0]}~${DELAY[type]?.[1]}s`,
480
+ };
481
+ }
482
+ return status;
483
+ },
484
+ };
485
+ };
486
+
487
+ module.exports = createThreadsApiClient;
@@ -0,0 +1,142 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const crypto = require('crypto');
4
+ const { readThreadsCredentials } = require('./utils');
5
+ const { saveThreadsSession } = require('./session');
6
+
7
+ const THREADS_APP_ID = '238260118697367';
8
+ const THREADS_USER_AGENT = 'Barcelona 289.0.0.77.109 Android';
9
+ const BLOKS_VERSION = '00ba6fa565c3c707243ad976fa30a071a625f2a3d158d9412091176fe35027d8';
10
+ const BASE_URL = 'https://i.instagram.com';
11
+
12
+ const generateDeviceId = () => `android-${crypto.randomBytes(8).toString('hex')}`;
13
+
14
+ const createAskForAuthentication = ({ sessionPath }) => async ({
15
+ username,
16
+ password,
17
+ } = {}) => {
18
+ fs.mkdirSync(path.dirname(sessionPath), { recursive: true });
19
+
20
+ const resolvedUsername = username || readThreadsCredentials().username;
21
+ const resolvedPassword = password || readThreadsCredentials().password;
22
+
23
+ if (!resolvedUsername || !resolvedPassword) {
24
+ throw new Error(
25
+ 'Threads login requires username/password. ' +
26
+ 'Please set the THREADS_USERNAME / THREADS_PASSWORD (or INSTA_USERNAME / INSTA_PASSWORD) environment variables.',
27
+ );
28
+ }
29
+
30
+ const deviceId = generateDeviceId();
31
+ const timestamp = Math.floor(Date.now() / 1000);
32
+
33
+ const clientInputParams = JSON.stringify({
34
+ password: `#PWD_INSTAGRAM:0:${timestamp}:${resolvedPassword}`,
35
+ contact_point: resolvedUsername,
36
+ device_id: deviceId,
37
+ });
38
+
39
+ const serverParams = JSON.stringify({
40
+ credential_type: 'password',
41
+ device_id: deviceId,
42
+ });
43
+
44
+ const body = new URLSearchParams({
45
+ params: JSON.stringify({
46
+ client_input_params: JSON.parse(clientInputParams),
47
+ server_params: JSON.parse(serverParams),
48
+ }),
49
+ bk_client_context: JSON.stringify({ bloks_version: BLOKS_VERSION, styles_id: 'instagram' }),
50
+ bloks_versioning_id: BLOKS_VERSION,
51
+ });
52
+
53
+ const res = await fetch(
54
+ `${BASE_URL}/api/v1/bloks/apps/com.bloks.www.bloks.caa.login.async.send_login_request/`,
55
+ {
56
+ method: 'POST',
57
+ headers: {
58
+ 'User-Agent': THREADS_USER_AGENT,
59
+ 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
60
+ 'X-Bloks-Version-Id': BLOKS_VERSION,
61
+ 'X-IG-App-ID': THREADS_APP_ID,
62
+ 'Sec-Fetch-Site': 'same-origin',
63
+ 'Sec-Fetch-Mode': 'cors',
64
+ 'Sec-Fetch-Dest': 'empty',
65
+ },
66
+ body: body.toString(),
67
+ redirect: 'manual',
68
+ },
69
+ );
70
+
71
+ const responseText = await res.text();
72
+
73
+ // Extract Bearer token — check header first, then Bloks response body
74
+ let token = null;
75
+ const authHeader = res.headers.get('ig-set-authorization');
76
+ if (authHeader) {
77
+ const match = authHeader.match(/Bearer IGT:2:(.+)/);
78
+ if (match) token = match[1];
79
+ }
80
+ if (!token) {
81
+ // Token is embedded in Bloks response body (escaped JSON)
82
+ const bodyMatch = responseText.match(/Bearer IGT:2:([a-zA-Z0-9_=+/]+)/);
83
+ if (bodyMatch) token = bodyMatch[1];
84
+ }
85
+
86
+ // Extract userId from token (base64 decode) or response body
87
+ let userId = null;
88
+ if (token) {
89
+ try {
90
+ const decoded = JSON.parse(Buffer.from(token, 'base64').toString());
91
+ userId = decoded.ds_user_id || null;
92
+ } catch { /* ignore decode error */ }
93
+ }
94
+ if (!userId) {
95
+ const userIdMatch = responseText.match(/"pk_string":"(\d+)"/);
96
+ if (userIdMatch) {
97
+ userId = userIdMatch[1];
98
+ } else {
99
+ const altMatch = responseText.match(/"user_id":(\d+)/);
100
+ if (altMatch) userId = altMatch[1];
101
+ }
102
+ }
103
+
104
+ // Check for errors
105
+ if (!token) {
106
+ if (responseText.includes('checkpoint_required') || responseText.includes('challenge_required')) {
107
+ throw new Error(
108
+ 'challenge_required: Identity verification required. Please complete it in the Threads app or Instagram.',
109
+ );
110
+ }
111
+ if (responseText.includes('two_factor_required')) {
112
+ throw new Error(
113
+ 'Two-factor authentication (2FA) is required. Please complete verification in the app first.',
114
+ );
115
+ }
116
+ if (responseText.includes('invalid_user') || responseText.includes('invalid_password')) {
117
+ throw new Error('Threads login failed: Invalid username or password.');
118
+ }
119
+ throw new Error(
120
+ `Threads login failed: Could not extract authorization token. Response status: ${res.status}`,
121
+ );
122
+ }
123
+
124
+ // Save session
125
+ saveThreadsSession(sessionPath, { token, userId, deviceId });
126
+
127
+ return {
128
+ provider: 'threads',
129
+ loggedIn: true,
130
+ userId,
131
+ username: resolvedUsername,
132
+ sessionPath,
133
+ };
134
+ };
135
+
136
+ module.exports = {
137
+ createAskForAuthentication,
138
+ THREADS_APP_ID,
139
+ THREADS_USER_AGENT,
140
+ BLOKS_VERSION,
141
+ BASE_URL,
142
+ };