// ============================================================================ // LINKEDIN PLATFORM ADAPTER // ============================================================================ // // LinkedIn migrated from REST to GraphQL (Voyager MessagingGraphQL) in 2025-2026. // The old REST endpoint (/voyager/api/messaging/conversations) now returns 500. // // Auth: CSRF token from JSESSIONID cookie + browser cookies for session. // All requests go through /voyager/api/ — the accept header distinguishes // REST vs GraphQL responses. // // ⚠️ VOLATILE: LinkedIn's GraphQL query IDs change periodically. // When conversations stop loading, check for new queryId values // in browser Network tab on linkedin.com/messaging. // // Anti-detection: LinkedIn actively scans for 6,200+ Chrome extensions. // Do NOT inject DOM elements or expose web-accessible resources. // // Verified: 2026-06-19 via HAR capture on linkedin.com/messaging // ============================================================================ import type { PlatformAdapter, ApiEndpoint, Conversation, Message, Participant, } from "../index.js"; // ── Raw API Types ───────────────────────────────────────────────────────── /** LinkedIn's text wrapper — everything is wrapped in this */ export interface LinkedInAttributedText { text: string; } /** GraphQL conversation response */ export interface LinkedInConversationsResponse { data: { messengerConversationsBySyncToken: { elements: LinkedInRawConversation[]; }; }; } export interface LinkedInRawConversation { /** Compound URN: urn:li:msg_conversation:(urn:li:fsd_profile:...,threadId) */ entityUrn: string; /** Backend URN (different from entityUrn) */ backendUrn: string; /** Last message preview */ descriptionText: LinkedInAttributedText; /** Epoch ms */ lastActivityAt: number; /** Unread count */ unreadCount: number; /** Participants */ conversationParticipants: LinkedInRawParticipant[]; } export interface LinkedInRawParticipant { entityUrn: string; participantType: { member?: { firstName: LinkedInAttributedText; lastName: LinkedInAttributedText; profileUrl?: string; profilePicture?: { rootUrl: string; artifacts: Array<{ fileIdentifyingUrlPathSegment: string; width: number; height: number; }>; }; }; }; } /** GraphQL messages response */ export interface LinkedInMessagesResponse { data: { messengerMessagesBySyncToken: { elements: LinkedInRawMessage[]; }; }; } export interface LinkedInRawMessage { backendUrn: string; body: LinkedInAttributedText; deliveredAt: number; actor: LinkedInRawParticipant; } // ── Normalization ───────────────────────────────────────────────────────── /** Extract text from LinkedIn's AttributedText wrapper */ export function attrText(obj: unknown): string { if (!obj || typeof obj !== "object") return ""; return (obj as Record).text as string ?? ""; } /** * Extract the web-friendly thread ID from a compound conversation URN. * * Input: urn:li:msg_conversation:(urn:li:fsd_profile:ACoAAA...,2-NDE3Zj...) * Output: 2-NDE3Zj... * * The thread ID is used in the web URL: * https://www.linkedin.com/messaging/thread/{threadId}/ */ export function extractThreadId(entityUrn: string): string { const match = entityUrn.match(/,([^)]+)\)?$/); return match ? match[1] : entityUrn; } /** Parse a participant from the GraphQL response */ export function normalizeParticipant( p: LinkedInRawParticipant, ): Participant | null { const member = p.participantType?.member; if (!member) return null; const firstName = attrText(member.firstName); const lastName = attrText(member.lastName); const name = `${firstName} ${lastName}`.trim(); const pic = member.profilePicture; const artifacts = pic?.artifacts ?? []; const rootUrl = pic?.rootUrl ?? ""; const smallArtifact = artifacts.find((a) => a.width === 200) ?? artifacts[0]; const avatarUrl = smallArtifact && rootUrl ? `${rootUrl}${smallArtifact.fileIdentifyingUrlPathSegment}` : undefined; const username = member.profileUrl ?.split("/in/") .pop() ?.replace(/\/$/, ""); return { id: p.entityUrn, name: name || "Unknown", username, avatarUrl, profileUrl: member.profileUrl, }; } /** Normalize a LinkedIn conversation */ export function normalizeConversation( conv: LinkedInRawConversation, ): Conversation { const participants = conv.conversationParticipants .map(normalizeParticipant) .filter((p): p is Participant => p !== null); const descText = attrText(conv.descriptionText); const lastMessage: Message | null = descText ? { id: conv.backendUrn ?? "", senderId: "", text: descText, timestamp: new Date(conv.lastActivityAt ?? 0).toISOString(), type: "text", } : null; return { platform: "linkedin", conversationId: conv.entityUrn, conversationType: participants.length <= 1 ? "dm" : "group_dm", name: participants.map((p) => p.name).join(", ") || "Unknown", participants, lastMessage, lastActivityAt: new Date(conv.lastActivityAt ?? 0).toISOString(), unreadCount: conv.unreadCount ?? 0, }; } /** Normalize a LinkedIn message */ export function normalizeMessage(msg: LinkedInRawMessage): Message { const actor = normalizeParticipant(msg.actor); return { id: msg.backendUrn ?? "", senderId: msg.actor?.entityUrn ?? "", senderName: actor?.name, text: attrText(msg.body), timestamp: new Date(msg.deliveredAt ?? 0).toISOString(), type: "text", }; } // ── Adapter Definition ──────────────────────────────────────────────────── /** * ⚠️ VOLATILE — these query IDs change when LinkedIn deploys new code. * When the adapter stops working, capture new IDs from the Network tab * on linkedin.com/messaging and update here. */ export const QUERY_IDS = { conversations: "messengerConversations.0d5e6781bbee71c3e51c8843c6519f48", messages: "messengerMessages.5846eeb71c981f11e0134cb6626cc314", } as const; const BASE_URL = "https://www.linkedin.com/voyager/api"; const listConversations: ApiEndpoint = { name: "messengerConversations (GraphQL)", method: "GET", urlTemplate: `${BASE_URL}/voyagerMessagingGraphQL/graphql?queryId=${QUERY_IDS.conversations}&variables=(mailboxUrn:{encodedProfileUrn})`, headers: { "csrf-token": "{csrfToken}", "x-restli-protocol-version": "2.0.0", accept: "application/graphql", }, description: "GraphQL endpoint for listing conversations. Requires the user's profile URN " + "as the mailboxUrn variable. Returns conversations with participant info and " + "last message preview.", rateLimiting: "Medium risk. LinkedIn monitors request patterns. Do not call more than " + "once per minute. Human-paced delays between subsequent message fetches.", verification: { status: "live_verified", lastChecked: "2026-06-25", source: "hydra_runtime", notes: "Used by Hydra extension; queryId is volatile and should be recaptured when conversations stop loading.", }, }; const fetchMessages: ApiEndpoint = { name: "messengerMessages (GraphQL)", method: "GET", urlTemplate: `${BASE_URL}/voyagerMessagingGraphQL/graphql?queryId=${QUERY_IDS.messages}&variables=(conversationUrn:{encodedConversationUrn})`, headers: { "csrf-token": "{csrfToken}", "x-restli-protocol-version": "2.0.0", accept: "application/graphql", }, description: "GraphQL endpoint for fetching messages in a specific conversation. " + "The conversationUrn is the full entityUrn from the conversations response.", rateLimiting: "High risk if fetching many threads. Space requests 1.5-4 seconds apart. " + "Cap at 10 threads per sync.", verification: { status: "live_verified", lastChecked: "2026-06-25", source: "hydra_runtime", notes: "Used by Hydra extension for active thread fetches; page-context fetch may be required for same-origin behavior.", }, }; const resolveProfileUrn: ApiEndpoint = { name: "memberHandles (profile URN resolution)", method: "GET", urlTemplate: `${BASE_URL}/voyagerOnboardingDashMemberHandles?primary=true&q=criteria&type=EMAIL`, headers: { "csrf-token": "{csrfToken}", "x-restli-protocol-version": "2.0.0", accept: "application/vnd.linkedin.normalized+json+2.1", }, description: "Resolves the current user's profile URN. Needed as a parameter for " + "the conversations endpoint. Returns fsd_profile URN in the response.", rateLimiting: "Low risk — called once per session.", verification: { status: "live_verified", lastChecked: "2026-06-25", source: "hydra_runtime", notes: "Used by Hydra to persist the canonical self fsd_profile URN for account identity and isFromMe mapping.", }, }; const sendMessage: ApiEndpoint = { name: "voyagerMessagingDashMessengerMessages (createMessage)", method: "POST", urlTemplate: `${BASE_URL}/voyagerMessagingDashMessengerMessages?action=createMessage`, headers: { "csrf-token": "{csrfToken}", "content-type": "text/plain;charset=UTF-8", "x-restli-protocol-version": "2.0.0", accept: "application/json", }, description: "Send a DM. Body (JSON, sent as text/plain to skip the CORS preflight): " + '{ message: { body: { attributes: [], text: "" }, renderContentUnions: [], ' + "conversationUrn: '', originToken: '' }, " + "mailboxUrn: '', trackingId: '<16 random bytes>', " + "dedupeByClientGeneratedToken: false }. conversationUrn is the conversation's full " + "entityUrn — urn:li:msg_conversation:(urn:li:fsd_profile:,) — and mailboxUrn " + "is the fsd_profile inside it. Must run same-origin (inject into a linkedin.com tab).", rateLimiting: "Yellow risk — mirror the web client exactly, one message at a time, low frequency, " + "always behind draft → approve → send.", verification: { status: "experimental", lastChecked: "2026-06-25", source: "external_reference", notes: "Endpoint family cross-checked against public Beeper/mautrix/Texts references and wired into Hydra's action ledger, but still needs a controlled live-send test.", }, }; export const linkedinAdapter: PlatformAdapter = { platform: "linkedin", name: "LinkedIn", color: "#0A66C2", emoji: "💼", auth: { requiredTokens: ["csrfToken"], extractionMethod: "Content script on *.linkedin.com extracts CSRF token from the " + "JSESSIONID cookie (strip quotes) or from meta tag csrf-token.", sources: [ { type: "cookie", name: "csrfToken", selector: "JSESSIONID", description: 'The JSESSIONID cookie value with quotes stripped. ' + "This is used as the csrf-token header value.", }, { type: "meta_tag", name: "csrfToken", selector: 'document.cookie.match(/JSESSIONID="?([^";]+)/)?.[1]', description: "Alternative extraction from document.cookie.", }, ], }, endpoints: { listConversations, fetchMessages, resolveProfileUrn, sendMessage, }, antiDetection: { minDelayMs: 1500, maxDelayMs: 4000, maxMessageThreads: 10, notes: "LinkedIn actively scans for extensions (6,200+ known). " + "CRITICAL: Zero DOM injection, zero web-accessible resources. " + "All API calls must use credentials:'include' (browser cookies). " + "Never extract cookies and send from a different context. " + "The accept:'application/graphql' header is required for the new endpoints.", }, buildDeepLink: (conv) => { const threadId = extractThreadId(conv.conversationId); return `https://www.linkedin.com/messaging/thread/${threadId}/`; }, lastVerified: "2026-06-19", volatileIds: QUERY_IDS, changelog: [ { date: "2026-06-19", description: "Migrated from REST to GraphQL. Old endpoint (/messaging/conversations?keyVersion=LEGACY_INBOX) " + "returns 500. New endpoints use voyagerMessagingGraphQL/graphql with queryId parameters.", }, { date: "2026-06-19", description: "Profile URN resolution via memberHandles endpoint. Needed for conversations mailboxUrn parameter.", }, { date: "2026-06-25", description: "Documented experimental createMessage write endpoint. Endpoint family cross-checked against " + "public Beeper/mautrix/Texts references; product use should still remain approval-gated and low volume.", }, ], };