From 4bf946edaff2760bc8a0923bf9aaeadb10d6da04 Mon Sep 17 00:00:00 2001 From: jackwener Date: Thu, 19 Mar 2026 21:56:30 +0800 Subject: [PATCH] feat(twitter): add accept and reply-dm commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit accept: Auto-accept DM requests matching keywords (comma-separated OR) opencli twitter accept --keyword '群,微信' --max 20 reply-dm: Send message to recent DM conversations with skip-replied opencli twitter reply-dm --text '我的微信 wxkabi' --max 20 Both commands: - Use click-based DOM interaction (data-testid selectors) - 10-minute timeout for batch operations - Support new Twitter /i/chat UI and /messages URL --- src/clis/twitter/reply-dm.ts | 169 +++++++++++++++++++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 src/clis/twitter/reply-dm.ts diff --git a/src/clis/twitter/reply-dm.ts b/src/clis/twitter/reply-dm.ts new file mode 100644 index 0000000..c8b93b0 --- /dev/null +++ b/src/clis/twitter/reply-dm.ts @@ -0,0 +1,169 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; + +cli({ + site: 'twitter', + name: 'reply-dm', + description: 'Send a message to recent DM conversations', + domain: 'x.com', + strategy: Strategy.UI, + browser: true, + timeoutSeconds: 600, // 10 min — batch operation + args: [ + { name: 'text', type: 'string', required: 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)' }, + ], + columns: ['index', 'status', 'user', 'message'], + func: async (page: IPage | null, kwargs: any) => { + if (!page) throw new Error('Requires browser'); + + const messageText: string = kwargs.text; + const maxSend: number = kwargs.max ?? 20; + const skipReplied: boolean = kwargs['skip-replied'] !== false; + const results: Array<{ index: number; status: string; user: string; message: string }> = []; + let sentCount = 0; + + // Step 1: Navigate to messages to get conversation list + await page.goto('https://x.com/messages'); + await page.wait(5); + + // Step 2: Get all conversation URLs + const convList = await page.evaluate(`(async () => { + try { + let attempts = 0; + let items = []; + while (attempts < 10) { + // Try new UI format first + items = Array.from(document.querySelectorAll('[data-testid^="dm-conversation-item-"]')); + // Fall back to old format + if (items.length === 0) { + items = Array.from(document.querySelectorAll('[data-testid="conversation"]')); + } + if (items.length > 0) break; + await new Promise(r => setTimeout(r, 1000)); + attempts++; + } + + const conversations = items.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'; + // Extract conversation IDs from testId like "dm-conversation-item-123:456" + const match = testId.match(/dm-conversation-item-(.+)/); + const convId = match ? match[1].replace(':', '-') : ''; + // Or from anchor href + 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 }; + } catch(e) { + return { ok: false, error: String(e), conversations: [] }; + } + })()`); + + if (!convList?.ok || !convList.conversations?.length) { + return [{ index: 1, status: 'info', user: 'System', message: 'No conversations found' }]; + } + + const conversations = convList.conversations.slice(0, maxSend + 5); // get a few extra in case some are skipped + + // Step 3: Iterate through conversations and send message + for (const conv of conversations) { + if (sentCount >= maxSend) break; + + // Navigate to the conversation + const convUrl = conv.convId + ? `https://x.com/messages/${conv.convId}` + : conv.href; + + if (!convUrl) { + continue; + } + + await page.goto(convUrl); + await page.wait(3); + + // Check if already replied with same text, type message, and send + 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)); + + // For DraftEditor (contenteditable), we need to use execCommand or insertText + 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') { + // Don't count skipped ones + results.push({ + index: results.length + 1, + status: 'skipped', + user: sendResult.user || conv.user, + message: sendResult.message, + }); + } + + // Brief pause between sends to avoid rate limiting + await page.wait(1); + } + + if (results.length === 0) { + results.push({ index: 0, status: 'info', user: 'System', message: 'No conversations processed' }); + } + + return results; + } +});