9b395f5cc3
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
184 lines
8.1 KiB
JavaScript
184 lines
8.1 KiB
JavaScript
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
cli({
|
|
site: 'twitter',
|
|
name: 'reply-dm',
|
|
access: 'write',
|
|
description: 'Send a message to recent DM conversations',
|
|
domain: 'x.com',
|
|
strategy: Strategy.UI,
|
|
browser: true,
|
|
args: [
|
|
{ name: 'text', type: 'string', required: true, positional: true, help: 'Message text to send (e.g. "我的微信 wxkabi")' },
|
|
{ name: 'max', type: 'int', required: false, default: 20, help: 'Maximum number of conversations to reply to (default: 20)' },
|
|
{ name: 'skip-replied', type: 'boolean', required: false, default: true, help: 'Skip conversations where you already sent the same text (default: true)' },
|
|
{ name: 'timeout', type: 'int', required: false, default: 600, help: 'Max seconds for the overall command (default: 600 — batch op)' },
|
|
],
|
|
columns: ['index', 'status', 'user', 'message'],
|
|
func: async (page, kwargs) => {
|
|
if (!page)
|
|
throw new CommandExecutionError('Browser session required for twitter reply-dm');
|
|
const messageText = kwargs.text;
|
|
const maxSend = kwargs.max ?? 20;
|
|
const skipReplied = kwargs['skip-replied'] !== false;
|
|
const results = [];
|
|
let sentCount = 0;
|
|
// Step 1: Navigate to messages to get conversation list
|
|
await page.goto('https://x.com/messages');
|
|
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
|
// Step 2: Collect conversations with scroll-to-load
|
|
const needed = maxSend + 10; // extra buffer for skips
|
|
const convList = await page.evaluate(`(async () => {
|
|
try {
|
|
// Wait for initial items
|
|
let attempts = 0;
|
|
while (attempts < 10) {
|
|
const items = document.querySelectorAll('[data-testid^="dm-conversation-item-"], [data-testid="conversation"]');
|
|
if (items.length > 0) break;
|
|
await new Promise(r => setTimeout(r, 1000));
|
|
attempts++;
|
|
}
|
|
|
|
// Scroll to load more conversations
|
|
const needed = ${needed};
|
|
const seenIds = new Set();
|
|
let noNewCount = 0;
|
|
|
|
for (let scroll = 0; scroll < 30; scroll++) {
|
|
const items = Array.from(document.querySelectorAll('[data-testid^="dm-conversation-item-"], [data-testid="conversation"]'));
|
|
items.forEach(el => seenIds.add(el.getAttribute('data-testid')));
|
|
|
|
if (seenIds.size >= needed) break;
|
|
|
|
// Find the scrollable container and scroll it
|
|
const scrollContainer = document.querySelector('[data-testid="dm-inbox-panel"]') ||
|
|
items[items.length - 1]?.closest('[class*="scroll"]') ||
|
|
items[items.length - 1]?.parentElement;
|
|
if (scrollContainer) {
|
|
scrollContainer.scrollTop = scrollContainer.scrollHeight;
|
|
}
|
|
// Also try scrolling the last item into view
|
|
if (items.length > 0) {
|
|
items[items.length - 1].scrollIntoView({ behavior: 'instant', block: 'end' });
|
|
}
|
|
|
|
await new Promise(r => setTimeout(r, 1500));
|
|
|
|
// Check if new items appeared
|
|
const newItems = Array.from(document.querySelectorAll('[data-testid^="dm-conversation-item-"], [data-testid="conversation"]'));
|
|
const newIds = new Set(newItems.map(el => el.getAttribute('data-testid')));
|
|
if (newIds.size <= seenIds.size) {
|
|
noNewCount++;
|
|
if (noNewCount >= 3) break; // No more loading after 3 tries
|
|
} else {
|
|
noNewCount = 0;
|
|
}
|
|
}
|
|
|
|
// Collect all visible conversations
|
|
const finalItems = Array.from(document.querySelectorAll('[data-testid^="dm-conversation-item-"], [data-testid="conversation"]'));
|
|
const conversations = finalItems.map((item, idx) => {
|
|
const testId = item.getAttribute('data-testid') || '';
|
|
const text = item.innerText || '';
|
|
const lines = text.split('\\n').filter(l => l.trim());
|
|
const user = lines[0] || 'Unknown';
|
|
const match = testId.match(/dm-conversation-item-(.+)/);
|
|
const convId = match ? match[1].replace(':', '-') : '';
|
|
const link = item.querySelector('a[href*="/messages/"]');
|
|
const href = link ? link.href : '';
|
|
return { idx, user, convId, href, preview: text.substring(0, 100) };
|
|
});
|
|
|
|
return { ok: true, conversations, total: conversations.length };
|
|
} catch(e) {
|
|
return { ok: false, error: String(e), conversations: [], total: 0 };
|
|
}
|
|
})()`);
|
|
if (!convList?.ok || !convList.conversations?.length) {
|
|
return [{ index: 1, status: 'info', user: 'System', message: 'No conversations found' }];
|
|
}
|
|
const conversations = convList.conversations;
|
|
// Step 3: Iterate through conversations and send message
|
|
for (const conv of conversations) {
|
|
if (sentCount >= maxSend)
|
|
break;
|
|
const convUrl = conv.convId
|
|
? `https://x.com/messages/${conv.convId}`
|
|
: conv.href;
|
|
if (!convUrl)
|
|
continue;
|
|
await page.goto(convUrl);
|
|
await page.wait(3);
|
|
const sendResult = await page.evaluate(`(async () => {
|
|
try {
|
|
const messageText = ${JSON.stringify(messageText)};
|
|
const skipReplied = ${skipReplied};
|
|
|
|
// Get username from conversation
|
|
const dmHeader = document.querySelector('[data-testid="DmActivityContainer"] [dir="ltr"] span') ||
|
|
document.querySelector('[data-testid="conversation-header"]') ||
|
|
document.querySelector('[data-testid="DmActivityContainer"] h2');
|
|
const username = dmHeader ? dmHeader.innerText.trim().split('\\\\n')[0] : '${conv.user}';
|
|
|
|
// Check if we already sent this message
|
|
if (skipReplied) {
|
|
const chatArea = document.querySelector('[data-testid="DmScrollerContainer"]') ||
|
|
document.querySelector('main');
|
|
const chatText = chatArea ? chatArea.innerText : '';
|
|
if (chatText.includes(messageText)) {
|
|
return { status: 'skipped', user: username, message: 'Already sent this message' };
|
|
}
|
|
}
|
|
|
|
// Find the text input
|
|
const input = document.querySelector('[data-testid="dmComposerTextInput"]');
|
|
if (!input) {
|
|
return { status: 'error', user: username, message: 'No message input found' };
|
|
}
|
|
|
|
// Focus and type into the DraftEditor
|
|
input.focus();
|
|
await new Promise(r => setTimeout(r, 300));
|
|
document.execCommand('insertText', false, messageText);
|
|
await new Promise(r => setTimeout(r, 500));
|
|
|
|
// Click send button
|
|
const sendBtn = document.querySelector('[data-testid="dmComposerSendButton"]');
|
|
if (!sendBtn) {
|
|
return { status: 'error', user: username, message: 'No send button found' };
|
|
}
|
|
|
|
sendBtn.click();
|
|
await new Promise(r => setTimeout(r, 1500));
|
|
|
|
return { status: 'sent', user: username, message: 'Message sent: ' + messageText };
|
|
} catch(e) {
|
|
return { status: 'error', user: 'system', message: String(e) };
|
|
}
|
|
})()`);
|
|
if (sendResult?.status === 'sent') {
|
|
sentCount++;
|
|
results.push({
|
|
index: sentCount,
|
|
status: 'sent',
|
|
user: sendResult.user || conv.user,
|
|
message: sendResult.message,
|
|
});
|
|
}
|
|
else if (sendResult?.status === 'skipped') {
|
|
results.push({
|
|
index: results.length + 1,
|
|
status: 'skipped',
|
|
user: sendResult.user || conv.user,
|
|
message: sendResult.message,
|
|
});
|
|
}
|
|
await page.wait(1);
|
|
}
|
|
if (results.length === 0) {
|
|
results.push({ index: 0, status: 'info', user: 'System', message: 'No conversations processed' });
|
|
}
|
|
return results;
|
|
}
|
|
});
|