viruagent-cli 0.5.0 → 0.5.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.
package/bin/index.js CHANGED
@@ -204,6 +204,32 @@ unlikeCommentCmd
204
204
  .option('--comment-id <id>', 'Comment ID')
205
205
  .action((opts) => execute('unlike-comment', opts));
206
206
 
207
+ const sendDmCmd = program
208
+ .command('send-dm')
209
+ .description('Send a direct message to a user');
210
+ addProviderOption(sendDmCmd);
211
+ sendDmCmd
212
+ .option('--username <username>', 'Recipient username')
213
+ .option('--thread-id <threadId>', 'Existing thread ID')
214
+ .option('--text <message>', 'Message text')
215
+ .action((opts) => execute('send-dm', opts));
216
+
217
+ const listMessagesCmd = program
218
+ .command('list-messages')
219
+ .description('List messages in a DM thread (via browser)');
220
+ addProviderOption(listMessagesCmd);
221
+ listMessagesCmd
222
+ .option('--thread-id <threadId>', 'Thread ID')
223
+ .action((opts) => execute('list-messages', opts));
224
+
225
+ const listCommentsCmd = program
226
+ .command('list-comments')
227
+ .description('List comments on a post');
228
+ addProviderOption(listCommentsCmd);
229
+ listCommentsCmd
230
+ .option('--post-id <shortcode>', 'Post shortcode')
231
+ .action((opts) => execute('list-comments', opts));
232
+
207
233
  const analyzePostCmd = program
208
234
  .command('analyze-post')
209
235
  .description('Analyze a post (thumbnail + caption + profile)');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viruagent-cli",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "AI-agent-optimized CLI for blog publishing (Tistory, Naver) and Instagram automation",
5
5
  "private": false,
6
6
  "type": "commonjs",
@@ -545,6 +545,43 @@ const createInstaApiClient = ({ sessionPath }) => {
545
545
  return res.json();
546
546
  });
547
547
 
548
+ const sendDm = (recipientUserId, text) => withDelay('dm', async () => {
549
+ const cookies = getCookies();
550
+ const csrf = getCsrfToken();
551
+ const body = new URLSearchParams({
552
+ action: 'send_item',
553
+ recipient_users: `[[${recipientUserId}]]`,
554
+ client_context: `6${Date.now()}_${Math.floor(Math.random() * 1000000000)}`,
555
+ offline_threading_id: Date.now().toString(),
556
+ text,
557
+ });
558
+ const res = await fetch('https://i.instagram.com/api/v1/direct_v2/threads/broadcast/text/', {
559
+ method: 'POST',
560
+ headers: {
561
+ 'User-Agent': 'Instagram 317.0.0.34.109 Android (30/11; 420dpi; 1080x2220; samsung; SM-A515F; a51; exynos9611; en_US; 562940699)',
562
+ 'X-IG-App-ID': '567067343352427',
563
+ 'X-CSRFToken': csrf,
564
+ 'Content-Type': 'application/x-www-form-urlencoded',
565
+ Cookie: cookiesToHeader(cookies),
566
+ },
567
+ body: body.toString(),
568
+ redirect: 'manual',
569
+ });
570
+ const data = await res.json();
571
+ if (data.status !== 'ok') {
572
+ const errCode = data.content?.error_code;
573
+ if (errCode === 4415001) {
574
+ throw new Error('DM restricted: Account DM feature is limited. Please open Instagram in browser, go to Direct Messages, and resolve any pending prompts or challenges.');
575
+ }
576
+ throw new Error(`DM failed: ${JSON.stringify(data)}`);
577
+ }
578
+ return {
579
+ status: data.status,
580
+ threadId: data.payload?.thread_id || null,
581
+ itemId: data.payload?.item_id || null,
582
+ };
583
+ });
584
+
548
585
  const getMediaIdFromShortcode = async (shortcode) => {
549
586
  const detail = await getPostDetail(shortcode);
550
587
  return detail.id;
@@ -666,6 +703,7 @@ const createInstaApiClient = ({ sessionPath }) => {
666
703
  uploadPhoto,
667
704
  configurePost,
668
705
  publishPost,
706
+ sendDm,
669
707
  deletePost,
670
708
  resolveChallenge,
671
709
  resetState,
@@ -239,6 +239,204 @@ const createInstaProvider = ({ sessionPath }) => {
239
239
  });
240
240
  },
241
241
 
242
+ async sendDm({ username, threadId, text } = {}) {
243
+ const target = String(username || '').trim();
244
+ const tid = String(threadId || '').trim();
245
+ const msg = String(text || '').trim();
246
+ if (!target && !tid) throw new Error('username or threadId is required.');
247
+ if (!msg) throw new Error('text is required.');
248
+
249
+ const { chromium } = require('playwright');
250
+ const path = require('path');
251
+ const userDataDir = path.join(path.dirname(sessionPath), '..', 'browser-data', 'insta');
252
+ const fs = require('fs');
253
+ if (!fs.existsSync(userDataDir)) fs.mkdirSync(userDataDir, { recursive: true });
254
+
255
+ // Determine DM URL
256
+ let dmUrl;
257
+ if (tid) {
258
+ dmUrl = `https://www.instagram.com/direct/t/${tid}/`;
259
+ } else {
260
+ dmUrl = `https://www.instagram.com/direct/new/`;
261
+ }
262
+
263
+ const context = await chromium.launchPersistentContext(userDataDir, {
264
+ headless: true,
265
+ viewport: { width: 1280, height: 800 },
266
+ });
267
+
268
+ try {
269
+ const page = context.pages()[0] || await context.newPage();
270
+
271
+ if (!tid && target) {
272
+ // New DM: go to new message, search for user
273
+ await page.goto('https://www.instagram.com/direct/new/', { waitUntil: 'domcontentloaded' });
274
+ await page.waitForTimeout(3000);
275
+
276
+ // Search for recipient
277
+ const searchInput = page.locator('input[name="queryBox"]').or(page.getByPlaceholder(/검색|Search/i));
278
+ await searchInput.first().waitFor({ timeout: 10000 });
279
+ await searchInput.first().fill(target);
280
+ await page.waitForTimeout(2000);
281
+
282
+ // Click the user result
283
+ const userResult = page.locator(`text=${target}`).first();
284
+ await userResult.click();
285
+ await page.waitForTimeout(1000);
286
+
287
+ // Click chat/next button
288
+ const chatBtn = page.getByRole('button', { name: /채팅|Chat|다음|Next/i });
289
+ await chatBtn.first().click();
290
+ await page.waitForTimeout(2000);
291
+ } else {
292
+ await page.goto(dmUrl, { waitUntil: 'domcontentloaded' });
293
+ await page.waitForTimeout(3000);
294
+ }
295
+
296
+ // Dismiss popups
297
+ try {
298
+ const btn = page.getByRole('button', { name: /나중에|Not Now/i });
299
+ if (await btn.first().isVisible({ timeout: 2000 })) await btn.first().click();
300
+ } catch {}
301
+
302
+ // Send message
303
+ const input = page.locator('[role="textbox"]').first();
304
+ await input.waitFor({ timeout: 10000 });
305
+ await input.click();
306
+ await page.keyboard.type(msg);
307
+ await page.waitForTimeout(500);
308
+ await page.keyboard.press('Enter');
309
+ await page.waitForTimeout(3000);
310
+
311
+ return {
312
+ provider: 'insta',
313
+ mode: 'dm',
314
+ to: target || tid,
315
+ text: msg,
316
+ status: 'ok',
317
+ };
318
+ } finally {
319
+ await context.close();
320
+ }
321
+ },
322
+
323
+ async listMessages({ threadId } = {}) {
324
+ const tid = String(threadId || '').trim();
325
+ if (!tid) throw new Error('threadId is required.');
326
+
327
+ const { chromium } = require('playwright');
328
+ const path = require('path');
329
+ const fs = require('fs');
330
+ const userDataDir = path.join(path.dirname(sessionPath), '..', 'browser-data', 'insta');
331
+ if (!fs.existsSync(userDataDir)) fs.mkdirSync(userDataDir, { recursive: true });
332
+
333
+ const context = await chromium.launchPersistentContext(userDataDir, {
334
+ headless: true,
335
+ viewport: { width: 1280, height: 800 },
336
+ });
337
+
338
+ try {
339
+ const page = context.pages()[0] || await context.newPage();
340
+ await page.goto(`https://www.instagram.com/direct/t/${tid}/`, { waitUntil: 'domcontentloaded' });
341
+ await page.waitForTimeout(5000);
342
+
343
+ // Dismiss popups
344
+ try {
345
+ const btn = page.getByRole('button', { name: /나중에|Not Now/i });
346
+ if (await btn.first().isVisible({ timeout: 2000 })) await btn.first().click();
347
+ } catch {}
348
+ await page.waitForTimeout(1000);
349
+
350
+ // Extract messages from DOM
351
+ const messages = await page.evaluate(() => {
352
+ const result = [];
353
+ // Find message containers - Instagram uses div with role="row" or specific data attributes
354
+ const rows = document.querySelectorAll('div[role="row"]');
355
+ rows.forEach((row) => {
356
+ const textEl = row.querySelector('div[dir="auto"]');
357
+ if (!textEl) return;
358
+ const text = textEl.innerText?.trim();
359
+ if (!text) return;
360
+
361
+ // Determine if sent or received by checking position/style
362
+ const wrapper = row.closest('[class]');
363
+ const style = wrapper ? window.getComputedStyle(wrapper) : null;
364
+ const isSent = row.innerHTML.includes('rgb(99, 91, 255)') ||
365
+ row.innerHTML.includes('#635BFF') ||
366
+ row.querySelector('[style*="flex-end"]') !== null;
367
+
368
+ result.push({ text, isSent });
369
+ });
370
+ return result;
371
+ });
372
+
373
+ // If role="row" didn't work, try alternative extraction
374
+ if (messages.length === 0) {
375
+ const altMessages = await page.evaluate(() => {
376
+ const result = [];
377
+ const allDivs = document.querySelectorAll('div[dir="auto"]');
378
+ const seen = new Set();
379
+ allDivs.forEach((el) => {
380
+ const text = el.innerText?.trim();
381
+ if (!text || text.length > 500 || seen.has(text)) return;
382
+ // Skip UI elements
383
+ if (['메시지 입력...', '검색', 'Message...'].includes(text)) return;
384
+ if (el.closest('nav') || el.closest('header')) return;
385
+ seen.add(text);
386
+
387
+ // Check if element is in right-aligned (sent) bubble
388
+ const rect = el.getBoundingClientRect();
389
+ const isSent = rect.left > window.innerWidth / 2;
390
+
391
+ result.push({ text, isSent });
392
+ });
393
+ return result;
394
+ });
395
+ if (altMessages.length > 0) messages.push(...altMessages);
396
+ }
397
+
398
+ // Get thread participant name
399
+ const participant = await page.evaluate(() => {
400
+ const header = document.querySelector('header');
401
+ if (!header) return null;
402
+ const spans = header.querySelectorAll('span');
403
+ for (const s of spans) {
404
+ const t = s.innerText?.trim();
405
+ if (t && !['메시지', 'Direct', '뒤로'].includes(t) && t.length < 30) return t;
406
+ }
407
+ return null;
408
+ });
409
+
410
+ return {
411
+ provider: 'insta',
412
+ mode: 'messages',
413
+ threadId: tid,
414
+ participant,
415
+ totalCount: messages.length,
416
+ messages,
417
+ };
418
+ } finally {
419
+ await context.close();
420
+ }
421
+ },
422
+
423
+ async listComments({ postId } = {}) {
424
+ return withProviderSession(async () => {
425
+ const shortcode = String(postId || '').trim();
426
+ if (!shortcode) {
427
+ throw new Error('postId (shortcode) is required.');
428
+ }
429
+ const comments = await instaApi.getComments(shortcode);
430
+ return {
431
+ provider: 'insta',
432
+ mode: 'comments',
433
+ postId: shortcode,
434
+ totalCount: comments.length,
435
+ comments,
436
+ };
437
+ });
438
+ },
439
+
242
440
  async analyzePost({ postId } = {}) {
243
441
  return withProviderSession(async () => {
244
442
  const shortcode = String(postId || '').trim();
package/src/runner.js CHANGED
@@ -231,6 +231,27 @@ const runCommand = async (command, opts = {}) => {
231
231
  }
232
232
  return withProvider(() => provider.unlikeComment({ commentId: opts.commentId }))();
233
233
 
234
+ case 'send-dm':
235
+ if (!opts.username && !opts.threadId) {
236
+ throw createError('MISSING_PARAM', 'send-dm requires --username or --thread-id');
237
+ }
238
+ if (!opts.text) {
239
+ throw createError('MISSING_PARAM', 'send-dm requires --text');
240
+ }
241
+ return withProvider(() => provider.sendDm({ username: opts.username, threadId: opts.threadId, text: opts.text }))();
242
+
243
+ case 'list-messages':
244
+ if (!opts.threadId) {
245
+ throw createError('MISSING_PARAM', 'list-messages requires --thread-id');
246
+ }
247
+ return withProvider(() => provider.listMessages({ threadId: opts.threadId }))();
248
+
249
+ case 'list-comments':
250
+ if (!opts.postId) {
251
+ throw createError('MISSING_PARAM', 'list-comments requires --post-id');
252
+ }
253
+ return withProvider(() => provider.listComments({ postId: opts.postId }))();
254
+
234
255
  case 'analyze-post':
235
256
  if (!opts.postId) {
236
257
  throw createError('MISSING_PARAM', 'analyze-post requires --post-id');