/ clis / twitter / accept.js
accept.js
  1  import { CommandExecutionError } from '@jackwener/opencli/errors';
  2  import { cli, Strategy } from '@jackwener/opencli/registry';
  3  cli({
  4      site: 'twitter',
  5      name: 'accept',
  6      description: 'Auto-accept DM requests containing specific keywords',
  7      domain: 'x.com',
  8      strategy: Strategy.UI,
  9      browser: true,
 10      timeoutSeconds: 600, // 10 min — batch operation iterating many conversations
 11      args: [
 12          { name: 'query', type: 'string', required: true, positional: true, help: 'Keywords to match (comma-separated for OR, e.g. "群,微信")' },
 13          { name: 'max', type: 'int', required: false, default: 20, help: 'Maximum number of requests to accept (default: 20)' },
 14      ],
 15      columns: ['index', 'status', 'user', 'message'],
 16      func: async (page, kwargs) => {
 17          if (!page)
 18              throw new CommandExecutionError('Browser session required for twitter accept');
 19          const keywords = kwargs.query.split(',').map((k) => k.trim()).filter(Boolean);
 20          const maxAccepts = kwargs.max ?? 20;
 21          const results = [];
 22          let acceptCount = 0;
 23          // Track already-visited conversations to avoid infinite loops
 24          const visited = new Set();
 25          for (let round = 0; round < maxAccepts + 50; round++) {
 26              if (acceptCount >= maxAccepts)
 27                  break;
 28              // Step 1: Navigate to DM requests page
 29              await page.goto('https://x.com/messages/requests');
 30              await page.wait(4);
 31              // Step 2: Get conversations with scroll-to-load
 32              const convInfo = await page.evaluate(`(async () => {
 33          try {
 34            // Wait for initial items
 35            let attempts = 0;
 36            while (attempts < 10) {
 37              const convs = document.querySelectorAll('[data-testid="conversation"]');
 38              if (convs.length > 0) break;
 39              await new Promise(r => setTimeout(r, 1000));
 40              attempts++;
 41            }
 42  
 43            // Scroll to load more
 44            const seenCount = new Set();
 45            let noNewCount = 0;
 46            for (let scroll = 0; scroll < 20; scroll++) {
 47              const convs = Array.from(document.querySelectorAll('[data-testid="conversation"]'));
 48              const prevSize = seenCount.size;
 49              convs.forEach((_, i) => seenCount.add(i));
 50              if (convs.length >= ${maxAccepts + 10}) break;
 51  
 52              // Scroll last item into view
 53              if (convs.length > 0) {
 54                convs[convs.length - 1].scrollIntoView({ behavior: 'instant', block: 'end' });
 55              }
 56              await new Promise(r => setTimeout(r, 1500));
 57  
 58              if (seenCount.size <= prevSize) {
 59                noNewCount++;
 60                if (noNewCount >= 3) break;
 61              } else {
 62                noNewCount = 0;
 63              }
 64            }
 65  
 66            const convs = Array.from(document.querySelectorAll('[data-testid="conversation"]'));
 67            if (convs.length === 0) return { ok: false, count: 0, items: [] };
 68  
 69            const items = convs.map((conv, idx) => {
 70              const text = conv.innerText || '';
 71              const link = conv.querySelector('a[href]');
 72              const href = link ? link.href : '';
 73              const lines = text.split('\\n').filter(l => l.trim());
 74              const user = lines[0] || 'Unknown';
 75              return { idx, text, href, user };
 76            });
 77            return { ok: true, count: convs.length, items };
 78          } catch(e) {
 79            return { ok: false, error: String(e), count: 0, items: [] };
 80          }
 81        })()`);
 82              if (!convInfo?.ok || convInfo.count === 0) {
 83                  if (results.length === 0) {
 84                      results.push({ index: 1, status: 'info', user: 'System', message: 'No message requests found' });
 85                  }
 86                  break;
 87              }
 88              let foundInThisRound = false;
 89              // Step 3: Find first unvisited conversation with keyword match in preview
 90              for (const item of convInfo.items) {
 91                  if (acceptCount >= maxAccepts)
 92                      break;
 93                  const convKey = item.href || `conv-${item.idx}`;
 94                  if (visited.has(convKey))
 95                      continue;
 96                  visited.add(convKey);
 97                  // Check if preview text contains any keyword
 98                  const previewMatch = keywords.some((k) => item.text.includes(k));
 99                  if (!previewMatch)
100                      continue;
101                  // Step 4: Click this conversation to open it
102                  const clickResult = await page.evaluate(`(async () => {
103            try {
104              const convs = Array.from(document.querySelectorAll('[data-testid="conversation"]'));
105              const conv = convs[${item.idx}];
106              if (!conv) return { ok: false, error: 'Conversation element not found' };
107              conv.click();
108              await new Promise(r => setTimeout(r, 2000));
109              return { ok: true };
110            } catch(e) {
111              return { ok: false, error: String(e) };
112            }
113          })()`);
114                  if (!clickResult?.ok)
115                      continue;
116                  // Wait for conversation to load
117                  await page.wait(2);
118                  // Step 5: Read full chat content and find Accept button
119                  const res = await page.evaluate(`(async () => {
120            try {
121              const keywords = ${JSON.stringify(keywords)};
122  
123              // Get username from conversation header
124              const heading = document.querySelector('[data-testid="conversation-header"]') ||
125                              document.querySelector('[data-testid="DM-conversation-header"]');
126              let username = 'Unknown';
127              if (heading) {
128                username = heading.innerText.trim().split('\\n')[0];
129              }
130  
131              // Read full chat area text
132              const chatArea = document.querySelector('[data-testid="DmScrollerContainer"]') ||
133                               document.querySelector('[data-testid="DMConversationBody"]') ||
134                               document.querySelector('main [data-testid="cellInnerDiv"]')?.closest('section') ||
135                               document.querySelector('main');
136              const text = chatArea ? chatArea.innerText : '';
137  
138              // Verify keyword match in full chat content
139              const matchedKw = keywords.filter(k => text.includes(k));
140              if (matchedKw.length === 0) {
141                return { status: 'skipped', user: username, message: 'No keyword match in full content' };
142              }
143  
144              // Find the Accept button
145              const allBtns = Array.from(document.querySelectorAll('[role="button"]'));
146              const acceptBtn = allBtns.find(btn => {
147                const t = btn.innerText.trim().toLowerCase();
148                return t === 'accept' || t === '接受';
149              });
150  
151              if (!acceptBtn) {
152                return { status: 'no_button', user: username, message: 'Keyword matched but no Accept button (already accepted?)' };
153              }
154  
155              // Click Accept
156              acceptBtn.click();
157              await new Promise(r => setTimeout(r, 2000));
158  
159              // Check for confirmation dialog
160              const btnsAfter = Array.from(document.querySelectorAll('[role="button"]'));
161              const confirmBtn = btnsAfter.find(btn => {
162                const t = btn.innerText.trim().toLowerCase();
163                return (t === 'accept' || t === '接受') && btn !== acceptBtn;
164              });
165              if (confirmBtn) {
166                confirmBtn.click();
167                await new Promise(r => setTimeout(r, 1000));
168              }
169  
170              return { status: 'accepted', user: username, message: 'Accepted! Matched: ' + matchedKw.join(', ') };
171            } catch(e) {
172              return { status: 'error', user: 'system', message: String(e) };
173            }
174          })()`);
175                  if (res?.status === 'accepted') {
176                      acceptCount++;
177                      foundInThisRound = true;
178                      results.push({
179                          index: acceptCount,
180                          status: 'accepted',
181                          user: res.user || 'Unknown',
182                          message: res.message || 'Accepted',
183                      });
184                      // After accept, Twitter redirects to /messages — loop back to /messages/requests
185                      await page.wait(2);
186                      break; // break inner loop, outer loop will re-navigate to requests
187                  }
188                  else if (res?.status === 'no_button') {
189                      // Already accepted, skip
190                      continue;
191                  }
192              }
193              // If no match found in this round, we've exhausted all visible requests
194              if (!foundInThisRound) {
195                  break;
196              }
197          }
198          if (results.length === 0) {
199              results.push({ index: 0, status: 'info', user: 'System', message: `No requests matched keywords "${keywords.join(', ')}"` });
200          }
201          return results;
202      }
203  });