tap
Back

twitter/chat

twitterRead-only

获取 Twitter DM 对话消息

x.com
Last 7 days
0
Last 30 days
0
All time
0
twitter/chat.js
/* @meta
{
  "name": "twitter/chat",
  "description": "获取 Twitter DM 对话消息",
  "domain": "x.com",
  "args": {
    "conversation_id": {"required": true, "description": "Conversation ID (from URL, e.g. 115897222-2741527221)"},
    "count": {"required": false, "description": "Number of messages to return (default 50)"}
  },
  "capabilities": ["network"],
  "readOnly": true,
  "example": "bb-browser site twitter/chat 115897222-2741527221"
}
*/

async function(args) {
  if (!args.conversation_id) return {error: 'Missing argument: conversation_id', hint: 'Provide conversation ID from URL, e.g. 115897222-2741527221'};
  const ct0 = document.cookie.split(';').map(c=>c.trim()).find(c=>c.startsWith('ct0='))?.split('=')[1];
  if (!ct0) return {error: 'No ct0 cookie', hint: 'Not logged into x.com. Open x.com and log in first.'};

  let convId = args.conversation_id;
  const urlMatch = convId.match(/\/chat\/([^\/?]+)/);
  if (urlMatch) convId = urlMatch[1];

  // ── Strategy: DOM first (handles E2E encrypted), API fallback ──

  const onConvPage = window.location.pathname === '/i/chat/' + convId;

  if (onConvPage) {
    // Wait for message list to render (E2E decryption may take a moment)
    let msgList = document.querySelector('[data-testid="dm-message-list"]');
    if (!msgList) {
      for (let i = 0; i < 20; i++) {
        await new Promise(r => setTimeout(r, 300));
        msgList = document.querySelector('[data-testid="dm-message-list"]');
        if (msgList) break;
      }
    }
    if (!msgList) return {error: 'Message list not found', hint: 'Page may still be loading. Try again.'};

    // Wait a bit more for E2E decryption
    await new Promise(r => setTimeout(r, 500));

    // Extract participant info from header
    const header = document.querySelector('[data-testid="dm-conversation-header"]');
    const headerName = header?.textContent?.trim() || '';

    // Extract messages from DOM
    const textEls = document.querySelectorAll('[data-testid^="message-text-"]');
    const messages = [];
    const timeStampRegex = /(\d{2}:\d{2})\1$/;  // duplicated timestamps like "04:4904:49"

    for (const el of textEls) {
      const reqId = el.dataset.testid.replace('message-text-', '');
      let text = el.innerText || el.textContent || '';
      // Clean trailing duplicate timestamps
      text = text.replace(/(\d{2}:\d{2})\1$/, '').trim();

      // Determine sender: check if this message bubble has an avatar (= other person)
      // Twitter DM: messages from the other person have avatar images, yours don't
      const msgContainer = el.closest('[data-testid^="message-"]') || el.parentElement?.parentElement?.parentElement;
      // Walk up to find the message row - other person's messages typically appear on the left
      // We check for avatar image in nearby siblings
      let isOtherPerson = false;
      if (msgContainer) {
        const row = msgContainer.closest('[class]')?.parentElement;
        if (row) {
          const avatar = row.querySelector('img[src*="profile_images"]');
          if (avatar) isOtherPerson = true;
        }
      }

      // Try to extract time from a <time> element nearby
      let time = null;
      const parent = el.closest('[data-testid^="message-"]')?.parentElement;
      if (parent) {
        const timeEl = parent.querySelector('time');
        if (timeEl) time = timeEl.getAttribute('datetime');
      }

      messages.push({
        request_id: reqId,
        text,
        sender: isOtherPerson ? 'other' : 'self',
        time: time || undefined
      });
    }

    // Try to get participant names from the profile section at top of chat
    const profileSection = msgList.querySelector('[data-testid="UserCell"]') || msgList.firstElementChild;
    let otherName = headerName;
    let otherScreenName = '';
    const profileLink = msgList.querySelector('a[href^="/"]');
    if (profileLink) {
      const href = profileLink.getAttribute('href');
      if (href && !href.includes('/i/')) {
        otherScreenName = href.replace(/^\//, '').replace(/\/$/, '');
      }
    }
    // Also try from header text which usually has the name
    const nameHeading = header?.querySelector('span');
    if (nameHeading) otherName = nameHeading.textContent?.trim() || otherName;

    return {
      conversation_id: convId,
      participant: otherScreenName ? {name: otherName, screen_name: otherScreenName} : {name: otherName},
      encrypted: true,
      count: messages.length,
      messages
    };
  }

  // ── Fallback: API approach (works for non-encrypted conversations) ──

  const bearer = decodeURIComponent('AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA');
  const _h = {'Authorization':'Bearer '+bearer, 'X-Csrf-Token':ct0, 'X-Twitter-Auth-Type':'OAuth2Session', 'X-Twitter-Active-User':'yes'};

  const count = Math.min(parseInt(args.count) || 50, 100);
  const params = new URLSearchParams({
    include_profile_interstitial_type: '1', include_blocking: '1', include_blocked_by: '1',
    include_followed_by: '1', include_want_retweets: '1', include_mute_edge: '1',
    include_can_dm: '1', include_can_media_tag: '1', include_ext_is_blue_verified: '1',
    include_ext_verified_type: '1', include_ext_profile_image_shape: '1', skip_status: '1',
    dm_secret_conversations_enabled: 'false', krs_registration_enabled: 'true',
    cards_platform: 'Web-12', include_cards: '1', include_ext_alt_text: 'true',
    include_quote_count: 'true', include_reply_count: '1', tweet_mode: 'extended',
    include_ext_views: 'true', include_groups: 'true', include_inbox_timelines: 'true',
    count: String(count)
  });

  const url = '/i/api/1.1/dm/conversation/' + encodeURIComponent(convId) + '.json?' + params.toString();
  const resp = await fetch(url, {headers: _h, credentials: 'include'});
  if (!resp.ok) {
    if (resp.status === 404) return {error: 'Conversation not found', hint: 'Check the conversation ID: ' + convId};
    return {error: 'HTTP ' + resp.status, hint: 'Make sure you are logged in and have access to this conversation'};
  }
  const d = await resp.json();

  const users = {};
  for (const [uid, u] of Object.entries(d.conversation_timeline?.users || {})) {
    users[uid] = {id: uid, name: u.name, screen_name: u.screen_name};
  }

  const entries = d.conversation_timeline?.entries || [];
  const hasE2E = entries.some(e => e.trust_conversation);
  const messages = [];

  for (const entry of entries) {
    const msg = entry.message;
    if (!msg) continue;
    const md = msg.message_data;
    if (!md) continue;
    const senderId = md.sender_id;
    const sender = users[senderId];
    const mediaUrls = (md.attachment?.media || md.entities?.media || []).map(m => m.media_url_https || m.media_url).filter(Boolean);
    const urls = (md.entities?.urls || []).map(u => ({url: u.expanded_url || u.url, display: u.display_url}));

    messages.push({
      id: msg.id, sender_id: senderId,
      sender_name: sender?.name, sender_screen_name: sender?.screen_name,
      text: md.text || '',
      media: mediaUrls.length > 0 ? mediaUrls : undefined,
      urls: urls.length > 0 ? urls : undefined,
      created_at: md.time ? new Date(parseInt(md.time)).toISOString() : undefined
    });
  }

  messages.sort((a, b) => (a.created_at || '').localeCompare(b.created_at || ''));
  const participants = Object.values(users).map(u => ({id: u.id, name: u.name, screen_name: u.screen_name}));

  const result = {
    conversation_id: convId, participants,
    count: messages.length, messages
  };

  if (hasE2E && messages.length <= 1) {
    result.encrypted = true;
    result.hint = 'This conversation is end-to-end encrypted. Only pre-encryption messages are shown via API. To get all messages, open the conversation in your browser first: https://x.com/i/chat/' + convId + ' , then run this command with --tab <tabId>';
  }

  return result;
}
Updated Jul 11, 2026Created Jul 11, 2026SHA-256: 4c7eebed38d5