
X Dm Auto Chat
- 2 installs
- 5.2k repo stars
- Updated July 21, 2026
- browser-act/skills
x-dm-auto-chat is a Claude skill that automates X direct-message inbox scanning, persona-based replies, and outreach through the browser-act CLI.
About
A Claude skill that automates X (Twitter) direct-message chat end-to-end via the browser-act CLI. It scans the DM inbox for pending replies, reads message history, sends persona-based replies generated by the calling agent, and supports searching users to start new outreach conversations. It handles the mechanical steps including E2E passcode unlock, DM-permission filtering, and rate control. Developers use it for automated DM outreach campaigns and batch handling of unread messages.
- Scans the DM inbox, reads history, and sends persona-based replies the calling agent generates
- Handles E2E 4-digit passcode unlock, DM-permission filtering, and rate control
- Supports search-and-outreach to start new DM conversations with target users
X Dm Auto Chat by the numbers
- 2 all-time installs (skills.sh)
- Ranked #730 of 853 Sales & Marketing skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
x-dm-auto-chat capabilities & compatibility
- Capabilities
- x keyword comment · x tweet search · xiaohongshu auto posting
What x-dm-auto-chat says it does
Full X DM automation Skill: inbox scan → conversation read → persona-based reply → send; also supports search-and-outreach.
The 4-digit DM passcode for the current account is available (required for E2E encryption)
For each pending-reply conversation** (strictly serial, **random `sleep 8-15` seconds between each**)
npx skills add https://github.com/browser-act/skills --skill x-dm-auto-chatAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2 |
|---|---|
| repo stars | ★ 5.2k |
| Last updated | July 21, 2026 |
| Repository | browser-act/skills ↗ |
What it does
Automate scanning, replying to, and starting X DM conversations from a brand persona in a logged-in browser.
Who is it for?
Automated X DM outreach campaigns and batch handling of unread, pending-reply conversations.
Skip if: Accounts without the 4-digit DM passcode or without a logged-in X session, which are required.
When should I use this skill?
The user wants to auto-process unread X DMs or run a DM outreach campaign with a persona.
What you get
Unread DM conversations get read and replied to from a persona, with new outreach conversations started as needed.
- Sent persona-based DM replies and new outreach conversations
By the numbers
- 4-digit DM passcode required
- random sleep 8-15 seconds between conversations
Files
X (Twitter) — DM Auto Chat (End-to-End)
Full X DM automation Skill: inbox scan → conversation read → persona-based reply → send; also supports search-and-outreach. The calling Agent generates reply text based on persona; this Skill handles all mechanical operations.
Language
All process output to user (progress updates, process notifications) follows the user's language.
Objective
Encapsulate "refresh DM list → identify pending replies → read context → reply with persona → send" and "search user → enter chat → send first message" into callable end-to-end capabilities.
Prerequisites
- Browser is open at X site, logged into X account (
[aria-label="Account menu"]present) - The 4-digit DM passcode for the current account is available (required for E2E encryption)
- Caller has prepared a "persona description" (used to generate replies), e.g.:
"You are BrowserAct outreach team. Tone: friendly, concise, professional. Goal: invite creators to collaborate."- Optional: list of target user search queries (for outreach scenario)
Pre-execution Checks
1. Tool Readiness
If browser-act has been confirmed available in the current session → skip.
Invoke browser-act via Skill tool to load usage. If installation or configuration issues arise, follow its guidance to resolve then retry.
2. Open DM Entry + Comprehensive State Check
browser-act --session <name> navigate https://x.com/i/chat
browser-act --session <name> wait stable --timeout 15000
browser-act --session <name> eval "$(python scripts/check-page-state.py)"Return format:
{
"url": "https://x.com/i/chat/pin/recovery?from=%2Fi%2Fchat",
"logged_in": true,
"need_passcode": true,
"on_inbox": false,
"on_conversation": false,
"has_panel": false,
"has_composer": false,
"inbox_count": 0
}Decision matrix:
logged_in: false→ inform user to log in first; wait; retry this stepneed_passcode: true→ proceed to step 3 belowon_inbox: trueandinbox_count > 0→ ready, enter business flowon_inbox: truebutinbox_count === 0→ account has no DM conversations; outreach scenario can still proceed, pending-reply scenario has nothing to do
3. DM Passcode Unlock (when need_passcode is true)
1. If caller has provided passcode in advance → use it directly; otherwise ask user for 4-digit DM passcode via AskUserQuestion tool (do not use plain text prompt — must call AskUserQuestion) 2. browser-act --session <name> state — find indexes of 4 <input maxlength=1 pattern=[0-9]*> elements (usually 4 consecutive) 3. Enter each digit: browser-act --session <name> input <idx1> "<d1>", <idx2> "<d2>", <idx3> "<d3>", <idx4> "<d4>"
- Must use `browser-act input` (CDP real keyboard events), cannot use eval to set value — X ignores non-real keyboard input
4. browser-act --session <name> wait stable --timeout 10000 5. Re-run check-page-state.py, confirm need_passcode: false and on_inbox: true 6. 3 consecutive failures still showing need_passcode: true → inform user passcode may be wrong; terminate
Business Flows
Choose Scenario A, Scenario B, or both. Each scenario is an ordered AI Workflow (not a single JS).
Scenario A: Scan unread DMs → Persona-based reply
Flow: Scan inbox → Filter unread & latest peer messages → Per-conversation: read context → Generate reply with persona → Send → Next
Steps:
1. Scan inbox:
browser-act --session <name> eval "$(python scripts/scan-inbox-merged.py)"Returns items[], each containing conversation_id / conversation_url / peer_screen_name / peer_display_name / peer_can_dm / latest_message_preview / latest_message_from_self / unread, etc.
2. Filter pending-reply conversations: from items, select conversations meeting all conditions:
unread === true(has unread) orlatest_message_from_self === false(peer's latest message not yet replied)peer_can_dm === true(recipient allows DM)is_muted !== trueandis_deleted_by_viewer !== true- Optional caller filters: only reply to specific screen_names, exclude already-replied (use external JSONL ledger)
3. For each pending-reply conversation (strictly serial, random `sleep 8-15` seconds between each):
a. Open conversation:
browser-act --session <name> navigate https://x.com<conversation_url>
browser-act --session <name> wait stable --timeout 15000b. If passcode re-triggered → re-unlock (usually won't re-trigger within same session)
c. Read context:
browser-act --session <name> eval "$(python scripts/read-conversation.py)"Returns messages[], each with direction (self/peer), text, timestamp_text, links, images.
d. (Optional) Load full history: If caller needs longer context, loop:
browser-act --session <name> eval "$(python scripts/scroll-load-history.py)"Until reached_top: true, then re-read with read-conversation.py.
e. Generate reply: Calling Agent combines persona, message history to generate reply text. Reply content is entirely the caller's decision; this Skill does not participate in generation. Suggested inputs:
- Persona prompt (provided by caller)
- Recent N messages (typically
messages.slice(-6)) - Peer name (
peer_display_name/peer_screen_name) for address - Return one string
reply_text, length < 10,000 characters
f. Send reply: 1. browser-act --session <name> eval "$(python scripts/check-composer.py)" → record last_message_id 2. browser-act --session <name> state — find <textarea placeholder=Message> index TA_IDX 3. browser-act --session <name> input <TA_IDX> "<reply_text>" (must use CDP real keyboard, cannot use eval) 4. browser-act --session <name> wait --selector '[data-testid="dm-composer-send-button"]' --state attached --timeout 5000 5. browser-act --session <name> eval "document.querySelector('[data-testid=\"dm-composer-send-button\"]').click(); 'clicked'" 6. browser-act --session <name> wait stable --timeout 15000 7. Verify: browser-act --session <name> eval "$(python scripts/verify-sent.py '<reply_text>' --prev-last-id <last_message_id from step f1>)"
sent: trueandcomposer_cleared: true→ success, record resultsent: false→ record failure, do not retry (prevents duplicate sends); proceed to next conversation
g. Random delay: sleep 8-15 seconds (avoid anti-abuse limits)
4. Batch completion: Summarize results (success count / failure count / conversation_id per item); return or write to external log file.
Scenario B: Search users → Start new conversation → Send first message
Flow: Search candidates → Filter sendable → Enter conversation → Generate first message → Send
Steps:
1. Search target users (one search per target, 1-2 second interval between searches):
browser-act --session <name> eval "$(python scripts/search-users.py '<search_query>')"Returns users[], each with user_id / name / screen_name / can_dm / can_dm_reason / verification fields.
2. Filter users who can receive DMs:
can_dm === true and !suspended and !protectedcan_dm_reason === "Allowed"- If
screen_nameis already in send history → skip (deduplication)
3. For each target user (strictly serial, sleep 10-20 seconds between each):
a. Calculate conversation URL:
browser-act --session <name> eval "$(python scripts/open-conversation-by-user.py '<user_id>')"Returns conversation_url (e.g., /i/chat/{smaller_id}-{larger_id}).
b. Navigate to conversation:
browser-act --session <name> navigate https://x.com<conversation_url>
browser-act --session <name> wait stable --timeout 15000c. Handle passcode (may appear on first DM entry) → unlock
d. Verify composer ready:
browser-act --session <name> eval "$(python scripts/check-composer.py)"composer_ready: true → record last_message_id; false → skip this user
e. Generate first message: Calling Agent generates first outreach text first_text based on persona + target user info (screen_name / name / verification type). Suggested content:
- Brief self-introduction (caller identity)
- Personalized reason for reaching out to this specific user
- Clear call-to-action
- Keep length < 500 characters (first messages that are too long are more likely to be flagged as spam)
f. Send: Follow the 7 sub-steps in "Scenario A step 3f", substituting first_text for reply_text.
g. Random delay: sleep 10-20 seconds
4. Batch completion: Summarize results.
Capability Components (callable individually)
In addition to the Scenario A / B end-to-end flows, the following components can also be called directly:
Composite: Inbox scan (API + DOM merged)
browser-act --session <name> eval "$(python scripts/scan-inbox-merged.py)" Returns merged conversation list with peer screen_name + message preview + unread flag.
API: Fetch inbox from API only (with pagination)
browser-act --session <name> eval "$(python scripts/fetch-inbox-api.py --cursor-id {cursor_id} --graph-snapshot-id {snap} --limit {N})"
DOM: Read current conversation messages
browser-act --session <name> eval "$(python scripts/read-conversation.py)"
DOM: Scroll to load message history
browser-act --session <name> eval "$(python scripts/scroll-load-history.py)"
DOM: Check composer state
browser-act --session <name> eval "$(python scripts/check-composer.py)"
DOM: Verify message was sent
browser-act --session <name> eval "$(python scripts/verify-sent.py '<expected_text>' --prev-last-id <last_id>)"
API: Search X users (with DM permission)
browser-act --session <name> eval "$(python scripts/search-users.py '<query>')"
JS: Calculate conversation URL from user_id
browser-act --session <name> eval "$(python scripts/open-conversation-by-user.py '<user_id>')"
JS: Comprehensive page state check
browser-act --session <name> eval "$(python scripts/check-page-state.py)"
Success Criteria
End-to-end Scenario A:
sent: truerate >= 90% for each pending-reply conversation- Failed conversations have clear reason recorded (wrong passcode, composer unavailable, 429, etc.)
End-to-end Scenario B:
- All filtered sendable users enter conversation page (
composer_ready: true) - First message
sent: truerate >= 90%
Atomic components: see success criteria in each atomic Skill (scripts in this directory fully reuse the atomic implementations).
Known Limitations
X Platform DM Limits (verified through exploration)
- E2E encryption passcode required: Must enter 4-digit passcode to unlock DMs; wrong or disconnected passcode loses message history. Passcode input only works via
browser-act input(CDP real keyboard); eval setting value does not work - Message bodies are E2E encrypted: GraphQL API response message events are base64 T-protocol encrypted binary; plaintext is only readable from the browser's already-unlocked DOM. This Skill must run in an already-logged-in and unlocked browser
- Peer DM permissions (
can_dm_reasonenum, observed values):Allowed— can send;InboxClosed— recipient closed DM; other values (possiblyBlocked,NotFollowing, etc.) treat as cannot send - Non-follower DMs go to Message Requests: First message to a user who doesn't follow you goes to their Message Requests; they must accept before it moves to Primary
- Send rate (anti-abuse, no official docs): Empirical max ~5-10 messages per minute; 8-15 second random delay between messages; exceeding threshold triggers HTTP 429 or UI block
- Message length cap: 10,000 characters per message (X official limit)
- Timestamp precision: DOM only gives X display format (
"30m"/"6:25 PM"/"May 8"); no ISO datetime - Attachment messages not covered: Sending images / GIFs / voice / video / quote tweets not implemented; this Skill handles plain text only
Additional Skill Limitations
- Does not participate in reply content generation: Reply text generation (persona application, context understanding, personalization) is entirely the calling Agent's responsibility; this Skill is the operation layer
- Does not maintain cross-session state: Per-run reply history, blocklists, and progress need the caller to record in external files (JSONL)
- Group conversations:
peer_*fields take only the first non-self member; fine-grained replies in group conversations are not supported - Message Requests sub-inbox: Currently only scans Primary inbox; Message Requests are not read; scanning Message Requests requires navigating to a different page — not implemented in this version
Execution Efficiency
- Batch processing: One run processes one batch (N conversations or N target users) then returns; no long-running resident loop — let the caller decide the scheduling cadence
- Strictly serial: All DM operations for the same account must be serial — no parallel; parallel operations accelerate anti-abuse triggering
- No retry on failure: DM send failures are usually permission / rate / network issues; retrying risks duplicate sends — record uniformly and skip
- Resume from breakpoint: Batch tasks use JSONL to record
{target, status, timestamp, error?}per item; resume from breakpoint on interruption - Small-scale validation first: Before bulk runs, validate the full pipeline with 1-2 items, then scale to full batch
- Reuse browser session: Use the same browser-act session (e.g.,
--session x-dm) for the whole batch; passcode unlock and login state persist within the session, no need to re-unlock for each item
Experience Notes
Path: {working-directory}/browser-act-skill-forge-memories/x-dm-automation-x-dm-auto-chat.memory.md (working directory is determined by the Agent running the Skill)
Before execution: If the file exists, read it first — it records unexpected situations encountered during past executions (e.g., a strategy has become ineffective, a selector changed, a rate threshold discovered); adjust strategy order accordingly.
After execution: If an unexpected situation is encountered (strategy became ineffective, page redesigned, anti-scraping upgraded, better path discovered, new can_dm_reason enum values), append a line: {YYYY-MM-DD}: {what happened} → {conclusion}
Normal execution does not write to the file. Do not record what keywords were used, which conversations were replied to, or how many messages were sent — those are task outputs, not experience.
import argparse
import sys
def main():
sys.stdout.reconfigure(encoding='utf-8', newline='\n')
parser = argparse.ArgumentParser(description='Check X DM composer state: confirm textarea available, return current value and current message count')
args = parser.parse_args()
js = """
(() => {
try {
const passcode = document.querySelector('input[pattern="[0-9]*"][maxlength="1"]');
if (passcode) return JSON.stringify({ error: true, message: 'passcode_required' });
const panel = document.querySelector('[data-testid="dm-conversation-panel"]');
if (!panel) return JSON.stringify({ error: true, message: 'no active conversation (open a conversation first)' });
const textarea = document.querySelector('[data-testid="dm-composer-textarea"]');
if (!textarea) return JSON.stringify({ error: true, message: 'dm-composer-textarea not found (composer may be disabled)' });
const sendBtn = document.querySelector('[data-testid="dm-composer-send-button"]');
const voiceBtn = document.querySelector('[data-testid="dm-composer-voice-button"]');
const msgCount = document.querySelectorAll('[data-testid^="message-"]:not([data-testid^="message-text-"])').length;
const lastMsgEl = document.querySelectorAll('[data-testid^="message-"]:not([data-testid^="message-text-"])');
const lastId = lastMsgEl.length ? lastMsgEl[lastMsgEl.length - 1].getAttribute('data-testid').replace('message-', '') : null;
const convIdMatch = location.pathname.match(/\\/i\\/chat\\/(\\d+)-(\\d+)/);
let convId = null;
if (convIdMatch) {
const a = BigInt(convIdMatch[1]);
const b = BigInt(convIdMatch[2]);
convId = (a < b ? `${a}:${b}` : `${b}:${a}`);
}
return JSON.stringify({
conversation_id: convId,
url: location.href,
composer_ready: true,
current_value: textarea.value,
has_send_button: !!sendBtn,
has_voice_button: !!voiceBtn,
message_count: msgCount,
last_message_id: lastId
});
} catch(e) {
return JSON.stringify({ error: true, message: e.message });
}
})()
"""
print(js)
if __name__ == '__main__':
main()
import argparse
import sys
def main():
sys.stdout.reconfigure(encoding='utf-8', newline='\n')
parser = argparse.ArgumentParser(description='Check X DM page state: URL, login status, passcode requirement, conversation panel availability')
args = parser.parse_args()
js = """
(() => {
try {
return JSON.stringify({
url: location.href,
logged_in: !!document.querySelector('[aria-label="Account menu"]') || !!document.cookie.split('; ').find(c => c.startsWith('twid=')),
need_passcode: !!document.querySelector('input[pattern="[0-9]*"][maxlength="1"]'),
on_inbox: /\\/i\\/chat\\/?$/.test(location.pathname),
on_conversation: /\\/i\\/chat\\/\\d+-\\d+/.test(location.pathname),
has_panel: !!document.querySelector('[data-testid="dm-conversation-panel"]'),
has_composer: !!document.querySelector('[data-testid="dm-composer-textarea"]'),
inbox_count: document.querySelectorAll('[data-testid^="dm-conversation-item-"]').length
});
} catch(e) {
return JSON.stringify({ error: true, message: e.message });
}
})()
"""
print(js)
if __name__ == '__main__':
main()
import argparse
import sys
def main():
sys.stdout.reconfigure(encoding='utf-8', newline='\n')
parser = argparse.ArgumentParser(description='Fetch X DM inbox via GraphQL API (metadata only, message bodies are E2E encrypted)')
parser.add_argument('--cursor-id', default='', help='Pagination cursor_id from previous response inboxCursor; empty for first page')
parser.add_argument('--graph-snapshot-id', default='', help='Pagination graph_snapshot_id from previous response; empty for first page')
parser.add_argument('--limit', type=int, default=20, help='Conversations per page')
args = parser.parse_args()
js = f"""
(async () => {{
try {{
const AUTH = 'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
const csrf = document.cookie.split('; ').find(c => c.startsWith('ct0='))?.split('=')[1];
if (!csrf) return JSON.stringify({{ error: true, message: 'ct0 cookie missing - not logged in' }});
const headers = {{
'authorization': AUTH,
'x-csrf-token': csrf,
'accept': 'application/graphql-response+json, application/json',
'apollo-require-preflight': 'true'
}};
const cursorId = {repr(args.cursor_id)};
const graphSnap = {repr(args.graph_snapshot_id)};
const limit = {args.limit};
let url;
if (cursorId && graphSnap) {{
url = 'https://api.x.com/graphql/udvEZwRtFbZht-atludZcw/GetInboxPageRequestQuery?variables=' +
encodeURIComponent(JSON.stringify({{
continue_cursor: {{ cursor_id: cursorId, graph_snapshot_id: graphSnap }},
query_settings: {{ conversation_event_limit: 200, inbox_conversation_event_limit: 5, inbox_conversation_limit: limit, user_event_limit: 500 }}
}}));
}} else {{
url = 'https://api.x.com/graphql/eovtSNDuKOzRLKXV4yWcow/GetInitialXChatPageQuery?variables=' +
encodeURIComponent(JSON.stringify({{
max_local_sequence_id: null,
query_settings: {{ conversation_event_limit: 200, inbox_conversation_event_limit: 5, inbox_conversation_limit: limit, user_event_limit: 500 }},
message_pull_version: null
}}));
}}
const r = await fetch(url, {{ credentials: 'include', headers }});
if (!r.ok) return JSON.stringify({{ error: true, message: 'HTTP ' + r.status }});
const j = await r.json();
const page = j?.data?.get_initial_chat_page || j?.data?.get_inbox_page;
if (!page) return JSON.stringify({{ error: true, message: 'unexpected response shape', sample: JSON.stringify(j).slice(0, 300) }});
const myId = document.cookie.split('; ').find(c => c.startsWith('twid='))?.split('=')[1]?.replace(/^u%3D/, '').replace(/^u=/, '');
const items = (page.items || []).map(it => {{
const d = it.conversation_detail || {{}};
const parts = (d.participants_results || []).map(p => {{
const u = p.result || {{}};
return {{
user_id: u.rest_id,
name: u.core?.name,
screen_name: u.core?.screen_name,
avatar_url: u.avatar?.image_url,
is_blue_verified: u.verification?.is_blue_verified,
is_verified_organization: u.verification?.is_verified_organization,
can_dm: u.chat_permissions?.can_dm,
can_dm_on_xchat: u.chat_permissions?.can_dm_on_xchat,
can_dm_reason: u.chat_permissions?.can_dm_reason,
is_trusted: u.chat_permissions?.is_trusted,
protected: u.privacy?.protected,
suspended: u.privacy?.suspended
}};
}});
const peers = parts.filter(p => p.user_id !== myId);
return {{
conversation_id: d.conversation_id,
is_muted: d.is_muted,
is_deleted_by_viewer: it.is_deleted_by_viewer,
has_more: it.has_more,
participants: parts,
peer_user_id: peers[0]?.user_id,
peer_screen_name: peers[0]?.screen_name,
peer_name: peers[0]?.name
}};
}});
const cursor = page.inboxCursor || {{}};
const isEnd = cursor.__typename === 'XChatGetInboxPageEndCursor';
return JSON.stringify({{
my_user_id: myId,
count: items.length,
items,
next_cursor: isEnd ? null : {{ cursor_id: cursor.cursor_id, graph_snapshot_id: cursor.graph_snapshot_id }},
message_requests_count: page.message_requests_count
}});
}} catch(e) {{
return JSON.stringify({{ error: true, message: e.message }});
}}
}})()
"""
print(js)
if __name__ == '__main__':
main()
import argparse
import sys
def main():
sys.stdout.reconfigure(encoding='utf-8', newline='\n')
parser = argparse.ArgumentParser(description='Navigate to (or create) a 1-on-1 DM conversation with a given user by user_id')
parser.add_argument('user_id', help='Target user rest_id (numeric string)')
args = parser.parse_args()
js = f"""
(async () => {{
try {{
const myId = document.cookie.split('; ').find(c => c.startsWith('twid='))?.split('=')[1]?.replace(/^u%3D/, '').replace(/^u=/, '');
if (!myId) return JSON.stringify({{ error: true, message: 'twid cookie missing - not logged in' }});
const peerId = {repr(args.user_id)};
const a = BigInt(myId);
const b = BigInt(peerId);
const urlPath = a < b ? `/i/chat/${{a}}-${{b}}` : `/i/chat/${{b}}-${{a}}`;
return JSON.stringify({{
my_user_id: myId,
peer_user_id: peerId,
conversation_url: urlPath,
conversation_id: a < b ? `${{a}}:${{b}}` : `${{b}}:${{a}}`,
next_step: 'navigate to conversation_url then verify composer_ready'
}});
}} catch(e) {{
return JSON.stringify({{ error: true, message: e.message }});
}}
}})()
"""
print(js)
if __name__ == '__main__':
main()
import argparse
import sys
def main():
sys.stdout.reconfigure(encoding='utf-8', newline='\n')
parser = argparse.ArgumentParser(description='Read all currently-loaded messages from the active X DM conversation DOM')
args = parser.parse_args()
js = """
(() => {
try {
const passcode = document.querySelector('input[pattern="[0-9]*"][maxlength="1"]');
if (passcode) return JSON.stringify({ error: true, message: 'passcode_required' });
const header = document.querySelector('[data-testid="dm-conversation-header"]');
const peerName = header?.innerText?.trim();
const panel = document.querySelector('[data-testid="dm-conversation-panel"]');
if (!panel) return JSON.stringify({ error: true, message: 'no active conversation (dm-conversation-panel not found); navigate to /i/chat/{id1}-{id2} or click an inbox item' });
const convIdMatch = location.pathname.match(/\\/i\\/chat\\/(\\d+)-(\\d+)/);
let convId = null;
if (convIdMatch) {
const a = BigInt(convIdMatch[1]);
const b = BigInt(convIdMatch[2]);
convId = (a < b ? `${a}:${b}` : `${b}:${a}`);
}
const myId = document.cookie.split('; ').find(c => c.startsWith('twid='))?.split('=')[1]?.replace(/^u%3D/, '').replace(/^u=/, '');
const msgEls = document.querySelectorAll('[data-testid^="message-"]:not([data-testid^="message-text-"])');
const messages = [];
msgEls.forEach(el => {
const testid = el.getAttribute('data-testid');
const msgId = testid.replace('message-', '');
const textEl = document.querySelector(`[data-testid="message-text-${msgId}"]`);
let text = textEl?.textContent || '';
// Strip trailing timestamp duplicates like "6:25 PM6:25 PM" that get picked up
// by textContent when the time element is a sibling inside message-text
text = text.replace(/(\\d{1,2}:\\d{2}\\s?(?:AM|PM)?)+\\s*$/, '').trimEnd();
const cls = el.className || '';
const fromSelf = cls.includes('justify-end');
const fromPeer = cls.includes('justify-start');
const direction = fromSelf ? 'self' : (fromPeer ? 'peer' : 'unknown');
// Time — X renders as HH:MM or similar; collect all text in element minus main text
let timestamp = null;
const allText = el.innerText || '';
const timeMatch = allText.match(/\\b(\\d{1,2}:\\d{2}\\s?(?:AM|PM)?)\\b/);
if (timeMatch) timestamp = timeMatch[1];
// Extract attached URLs (rich links)
const links = [...el.querySelectorAll('a[href^="http"]')].map(a => a.getAttribute('href')).filter((v, i, arr) => arr.indexOf(v) === i);
// Attached images (avatars excluded)
const images = [...el.querySelectorAll('img[src]')]
.map(im => im.getAttribute('src'))
.filter(s => !/profile_images/.test(s))
.filter((v, i, arr) => arr.indexOf(v) === i);
messages.push({
message_id: msgId,
direction,
text,
timestamp_text: timestamp,
links,
images
});
});
return JSON.stringify({
conversation_id: convId,
url: location.href,
peer_display_name: peerName,
my_user_id: myId,
message_count: messages.length,
messages
});
} catch(e) {
return JSON.stringify({ error: true, message: e.message });
}
})()
"""
print(js)
if __name__ == '__main__':
main()
import argparse
import sys
def main():
sys.stdout.reconfigure(encoding='utf-8', newline='\n')
parser = argparse.ArgumentParser(description='Combined X DM inbox scan: merge API (metadata, screen_name) + DOM (preview, unread, timestamp)')
args = parser.parse_args()
js = """
(async () => {
try {
const passcode = document.querySelector('input[pattern="[0-9]*"][maxlength="1"]');
if (passcode) return JSON.stringify({ error: true, message: 'passcode_required', hint: 'Page is on DM passcode screen; unlock first' });
const myId = document.cookie.split('; ').find(c => c.startsWith('twid='))?.split('=')[1]?.replace(/^u%3D/, '').replace(/^u=/, '');
if (!myId) return JSON.stringify({ error: true, message: 'twid cookie missing - not logged in' });
const AUTH = 'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
const csrf = document.cookie.split('; ').find(c => c.startsWith('ct0='))?.split('=')[1];
const headers = {
'authorization': AUTH,
'x-csrf-token': csrf,
'accept': 'application/graphql-response+json, application/json',
'apollo-require-preflight': 'true'
};
const apiUrl = 'https://api.x.com/graphql/eovtSNDuKOzRLKXV4yWcow/GetInitialXChatPageQuery?variables=' +
encodeURIComponent(JSON.stringify({
max_local_sequence_id: null,
query_settings: { conversation_event_limit: 200, inbox_conversation_event_limit: 5, inbox_conversation_limit: 20, user_event_limit: 500 },
message_pull_version: null
}));
const r = await fetch(apiUrl, { credentials: 'include', headers });
if (!r.ok) return JSON.stringify({ error: true, message: 'inbox API HTTP ' + r.status });
const j = await r.json();
const page = j?.data?.get_initial_chat_page;
if (!page) return JSON.stringify({ error: true, message: 'unexpected API response shape' });
const apiByConvId = {};
(page.items || []).forEach(it => {
const d = it.conversation_detail || {};
const parts = (d.participants_results || []).map(p => {
const u = p.result || {};
return {
user_id: u.rest_id,
name: u.core?.name,
screen_name: u.core?.screen_name,
avatar_url: u.avatar?.image_url,
is_blue_verified: u.verification?.is_blue_verified,
is_verified_organization: u.verification?.is_verified_organization,
can_dm: u.chat_permissions?.can_dm,
can_dm_reason: u.chat_permissions?.can_dm_reason
};
});
const peers = parts.filter(p => p.user_id !== myId);
apiByConvId[d.conversation_id] = {
is_muted: d.is_muted,
is_deleted_by_viewer: it.is_deleted_by_viewer,
peer: peers[0] || null,
participants: parts
};
});
const items = document.querySelectorAll('[data-testid^="dm-conversation-item-"]');
const merged = [];
items.forEach(el => {
const testid = el.getAttribute('data-testid');
const convId = testid.replace('dm-conversation-item-', '');
const apiData = apiByConvId[convId] || {};
const anchor = el.querySelector('a[href*="/chat/"]');
const href = anchor?.getAttribute('href');
const texts = [];
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
let n;
while (n = walker.nextNode()) {
const t = n.textContent.trim();
if (t) texts.push(t);
}
const peerName = texts[0];
const timestamp = texts[1];
let youPrefix = false;
let preview = texts[2] || '';
if (preview === 'You:' && texts.length >= 4) {
youPrefix = true;
preview = texts[3];
}
const unread = !!el.querySelector('svg[data-icon="icon-circle-fill"].text-primary');
merged.push({
conversation_id: convId,
conversation_url: href,
peer_user_id: apiData.peer?.user_id,
peer_screen_name: apiData.peer?.screen_name,
peer_display_name: apiData.peer?.name || peerName,
peer_avatar_url: apiData.peer?.avatar_url,
peer_is_blue_verified: apiData.peer?.is_blue_verified,
peer_can_dm: apiData.peer?.can_dm,
peer_can_dm_reason: apiData.peer?.can_dm_reason,
is_muted: apiData.is_muted,
is_deleted_by_viewer: apiData.is_deleted_by_viewer,
latest_message_timestamp: timestamp,
latest_message_preview: preview,
latest_message_from_self: youPrefix,
unread
});
});
const cursor = page.inboxCursor || {};
const isEnd = cursor.__typename === 'XChatGetInboxPageEndCursor';
return JSON.stringify({
my_user_id: myId,
count: merged.length,
unread_count: merged.filter(m => m.unread).length,
items: merged,
next_cursor: isEnd ? null : { cursor_id: cursor.cursor_id, graph_snapshot_id: cursor.graph_snapshot_id },
message_requests_count: page.message_requests_count
});
} catch(e) {
return JSON.stringify({ error: true, message: e.message, stack: (e.stack||'').slice(0, 300) });
}
})()
"""
print(js)
if __name__ == '__main__':
main()
import argparse
import sys
def main():
sys.stdout.reconfigure(encoding='utf-8', newline='\n')
parser = argparse.ArgumentParser(description='Scroll message list to top to trigger loading older messages; returns whether new messages were loaded')
args = parser.parse_args()
js = """
(async () => {
try {
const list = document.querySelector('[data-testid="dm-message-list-container"]') || document.querySelector('[data-testid="dm-message-list"]');
if (!list) return JSON.stringify({ error: true, message: 'message list container not found' });
const beforeCount = document.querySelectorAll('[data-testid^="message-"]:not([data-testid^="message-text-"])').length;
list.scrollTop = 0;
// Wait up to 5s for new messages to load
let afterCount = beforeCount;
for (let i = 0; i < 50; i++) {
await new Promise(r => setTimeout(r, 100));
afterCount = document.querySelectorAll('[data-testid^="message-"]:not([data-testid^="message-text-"])').length;
if (afterCount > beforeCount) break;
}
return JSON.stringify({
before_count: beforeCount,
after_count: afterCount,
loaded_more: afterCount > beforeCount,
reached_top: afterCount === beforeCount // No new messages loaded → likely at top
});
} catch(e) {
return JSON.stringify({ error: true, message: e.message });
}
})()
"""
print(js)
if __name__ == '__main__':
main()
import argparse
import sys
def main():
sys.stdout.reconfigure(encoding='utf-8', newline='\n')
parser = argparse.ArgumentParser(description='Search X users via GraphQL TypeaheadXChatQuery; returns candidate users with DM permission info')
parser.add_argument('query', help='Search query (name, screen_name or partial)')
args = parser.parse_args()
js = f"""
(async () => {{
try {{
const passcode = document.querySelector('input[pattern="[0-9]*"][maxlength="1"]');
if (passcode) return JSON.stringify({{ error: true, message: 'passcode_required' }});
const AUTH = 'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
const csrf = document.cookie.split('; ').find(c => c.startsWith('ct0='))?.split('=')[1];
if (!csrf) return JSON.stringify({{ error: true, message: 'ct0 cookie missing - not logged in' }});
const url = 'https://api.x.com/graphql/mYbJ7Qq9UAtG-68VAiBpYA/TypeaheadXChatQuery?variables=' +
encodeURIComponent(JSON.stringify({{
query: {repr(args.query)},
include_group_check: true,
conv_id: null,
surface: 'NewDm'
}}));
const r = await fetch(url, {{
credentials: 'include',
headers: {{
'authorization': AUTH,
'x-csrf-token': csrf,
'accept': 'application/graphql-response+json, application/json',
'apollo-require-preflight': 'true'
}}
}});
if (!r.ok) return JSON.stringify({{ error: true, message: 'HTTP ' + r.status }});
const j = await r.json();
const hits = j?.data?.search_by_raw_query?.x_chat_users_typeahead || [];
const users = hits.map(h => {{
const u = h.typeahead_user?.user_results?.result || {{}};
return {{
user_id: u.rest_id,
name: u.core?.name,
screen_name: u.core?.screen_name,
avatar_url: u.avatar?.image_url,
is_blue_verified: u.verification?.is_blue_verified,
is_verified_organization: u.verification?.is_verified_organization,
verified_type: u.verification?.verified_type,
can_dm: u.chat_permissions?.can_dm,
can_dm_on_xchat: u.chat_permissions?.can_dm_on_xchat,
can_dm_reason: u.chat_permissions?.can_dm_reason,
protected: u.privacy?.protected,
suspended: u.privacy?.suspended
}};
}});
return JSON.stringify({{
query: {repr(args.query)},
count: users.length,
users
}});
}} catch(e) {{
return JSON.stringify({{ error: true, message: e.message }});
}}
}})()
"""
print(js)
if __name__ == '__main__':
main()
import argparse
import sys
def main():
sys.stdout.reconfigure(encoding='utf-8', newline='\n')
parser = argparse.ArgumentParser(description='Verify a message was sent by checking if a new self-direction message matching the given text appeared after the given previous last_message_id')
parser.add_argument('expected_text', help='The message text that was supposed to be sent (exact match)')
parser.add_argument('--prev-last-id', default='', help='The last_message_id before sending, used to detect the newly appeared message')
args = parser.parse_args()
# Escape backticks and ${ for JS template literals
expected = args.expected_text.replace('\\', '\\\\').replace('`', '\\`').replace('${', '\\${')
js = f"""
(() => {{
try {{
const expected = `{expected}`;
const prevLastId = {repr(args.prev_last_id)};
const msgEls = document.querySelectorAll('[data-testid^="message-"]:not([data-testid^="message-text-"])');
if (msgEls.length === 0) return JSON.stringify({{ sent: false, reason: 'no messages in conversation' }});
let foundAfterPrev = false;
let sawPrev = prevLastId === '';
for (const el of msgEls) {{
const id = el.getAttribute('data-testid').replace('message-', '');
const cls = el.className || '';
const isSelf = cls.includes('justify-end');
const textEl = document.querySelector('[data-testid="message-text-' + id + '"]');
let text = textEl?.textContent || '';
text = text.replace(/(\\d{{1,2}}:\\d{{2}}\\s?(?:AM|PM)?)+\\s*$/, '').trimEnd();
if (!sawPrev) {{
if (id === prevLastId) sawPrev = true;
continue;
}}
if (isSelf && text.includes(expected.trim())) {{
foundAfterPrev = true;
break;
}}
}}
// Check for delivery failure: X renders a separate LI with "Failed, Try Again"
// in dm-message-list when delivery fails (sibling of the message LI, no data-testid)
const msgList = document.querySelector('[data-testid="dm-message-list"]');
const hasDeliveryFailed = msgList
? [...msgList.querySelectorAll('li')].some(li => li.innerText?.trim() === 'Failed, Try Again')
: false;
if (foundAfterPrev && hasDeliveryFailed) {{
return JSON.stringify({{ sent: false, reason: 'delivery_failed', composer_cleared: document.querySelector('[data-testid="dm-composer-textarea"]')?.value === '', current_message_count: msgEls.length }});
}}
const textarea = document.querySelector('[data-testid="dm-composer-textarea"]');
const composerEmpty = textarea?.value === '';
return JSON.stringify({{
sent: foundAfterPrev,
composer_cleared: composerEmpty,
current_message_count: msgEls.length
}});
}} catch(e) {{
return JSON.stringify({{ error: true, message: e.message }});
}}
}})()
"""
print(js)
if __name__ == '__main__':
main()
Related skills
FAQ
What credentials does it need?
A logged-in X account in the browser and the 4-digit DM passcode required for E2E encryption.
Who writes the reply text?
The calling agent generates replies from the persona and history; the skill handles the mechanical send.