Files
wehub-resource-sync 9b395f5cc3
Build Chrome Extension / build (push) Waiting to run
Trigger Website Rebuild (Docs Updated) / dispatch (push) Waiting to run
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
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

213 lines
9.4 KiB
JavaScript

import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import {
THREAD_DECORATION,
THREADS_BASE,
encodeRestliDecoration,
fetchInboxRows,
fetchSalesnavJson,
getCsrf,
normalizeWhitespace,
parseLimit,
} from './salesnav-inbox.js';
const LINKEDIN_DOMAIN = 'www.linkedin.com';
const SALES_INBOX_URL = 'https://www.linkedin.com/sales/inbox/';
const DEFAULT_MESSAGE_LIMIT = 200;
const THREAD_PAGE_SIZE = 20;
function isLinkedInHost(hostname) {
const host = String(hostname || '').toLowerCase();
return host === 'linkedin.com' || host.endsWith('.linkedin.com');
}
function parseSalesProfileUrn(value) {
const raw = normalizeWhitespace(value);
const match = raw.match(/^urn:li:fs_salesProfile:\(([^,()]+),([^,()]+),([^,()]+)\)$/);
if (!match) return '';
const parts = [match[1], match[2], match[3]].map((part) => normalizeWhitespace(part).toLowerCase());
if (parts.some((part) => !part || part === 'undefined' || part === 'null' || part === 'not_available')) return '';
return raw;
}
function parseThreadInput(value) {
const raw = normalizeWhitespace(value);
if (!raw) return ['empty', ''];
if (/^2-[A-Za-z0-9+/=_-]+$/.test(raw)) return ['thread_id', raw];
if (/^urn:li:fs_salesProfile:\(/.test(raw)) {
const urn = parseSalesProfileUrn(raw);
if (!urn) throw new ArgumentError('Sales Navigator recipient urn must be urn:li:fs_salesProfile:(profileId,authType,authToken)');
return ['recipient_urn', urn];
}
try {
const url = new URL(raw);
if (url.protocol !== 'https:' || url.username || url.password || url.port || !isLinkedInHost(url.hostname)) return ['name', raw.toLowerCase()];
const inboxMatch = url.pathname.match(/^\/sales\/inbox\/([^/]+)\/?$/i);
if (inboxMatch) return ['thread_id', decodeURIComponent(inboxMatch[1])];
const leadMatch = url.pathname.match(/^\/sales\/lead\/([^,/]+),([^,/]+),([^/]+)\/?$/i);
if (leadMatch) {
const urn = `urn:li:fs_salesProfile:(${decodeURIComponent(leadMatch[1])},${decodeURIComponent(leadMatch[2])},${decodeURIComponent(leadMatch[3])})`;
if (!parseSalesProfileUrn(urn)) {
throw new ArgumentError('Sales Navigator lead URL must contain resolved profileId, authType, and authToken');
}
return ['recipient_urn', urn];
}
} catch (err) {
if (err instanceof ArgumentError) throw err;
// Fall through to name matching for non-URL text.
}
return ['name', raw.toLowerCase()];
}
function salesnavThreadUrl(threadId) {
return threadId ? `https://www.linkedin.com/sales/inbox/${encodeURIComponent(threadId)}` : '';
}
function threadApiUrl(threadId, messageCount) {
return `${THREADS_BASE}/${encodeURIComponent(threadId)}?decoration=${encodeRestliDecoration(THREAD_DECORATION)}&count=1&messageCount=${messageCount}`;
}
function participantName(profile) {
return normalizeWhitespace(profile?.fullName || [profile?.firstName, profile?.lastName].filter(Boolean).join(' '));
}
function participantIndex(thread) {
const resolution = thread?.participantsResolutionResults || {};
const participants = Array.isArray(thread?.participants) ? thread.participants : Object.keys(resolution);
const byUrn = new Map();
for (const urn of participants) {
const profile = resolution[urn] || { entityUrn: urn };
byUrn.set(urn, profile);
}
return byUrn;
}
function parseSalesnavThreadMessages(thread) {
if (!thread || typeof thread !== 'object') {
throw new CommandExecutionError('Sales Navigator messaging thread API returned malformed payload');
}
const threadId = normalizeWhitespace(thread?.id || '');
if (!threadId) {
throw new CommandExecutionError('Sales Navigator messaging thread API returned a thread without id');
}
if (!Array.isArray(thread?.messages)) {
throw new CommandExecutionError('Sales Navigator messaging thread API returned malformed messages');
}
const byUrn = participantIndex(thread);
const messages = thread.messages;
const rows = messages.map((message) => {
if (!message || typeof message !== 'object') {
throw new CommandExecutionError('Sales Navigator messaging thread API returned malformed message row');
}
const deliveredAt = Number(message?.deliveredAt || 0);
const senderProfile = byUrn.get(message?.author);
return {
message_id: normalizeWhitespace(message?.id || ''),
thread_id: threadId,
sender: participantName(senderProfile) || normalizeWhitespace(message?.author || ''),
sender_urn: normalizeWhitespace(message?.author || ''),
text: normalizeWhitespace(message?.body || message?.systemMessageContent || ''),
subject: normalizeWhitespace(message?.subject || ''),
timestamp: deliveredAt ? new Date(deliveredAt).toISOString() : '',
delivered_at: deliveredAt || '',
type: normalizeWhitespace(message?.type || ''),
};
}).filter((row) => row.text || row.subject || row.message_id);
rows.sort((a, b) => Number(a.delivered_at || 0) - Number(b.delivered_at || 0));
return rows.map((row, index) => ({ index, ...row }));
}
function threadMatchesInput(row, parsed) {
if (!row || !parsed) return false;
const [kind, criterion] = parsed;
if (kind === 'thread_id') return row.thread_id === criterion;
if (kind === 'recipient_urn') {
return (row.participants || []).some((p) => normalizeWhitespace(p.entity_urn) === criterion);
}
if (kind === 'name') {
const needle = normalizeWhitespace(criterion).toLowerCase();
if (!needle) return false;
if (normalizeWhitespace(row.person_name).toLowerCase() === needle) return true;
return (row.participants || []).some((p) => normalizeWhitespace(p.name).toLowerCase() === needle);
}
return false;
}
async function resolveThreadId(page, input, { maxPages = 30 } = {}) {
const parsed = parseThreadInput(input);
if (parsed[0] === 'empty') throw new ArgumentError('thread or recipient is required');
if (parsed[0] === 'thread_id') return parsed[1];
const inboxRows = await fetchInboxRows(page, { limit: 500, maxPages });
const match = inboxRows.find((row) => threadMatchesInput(row, parsed));
if (!match) {
throw new EmptyResultError('linkedin salesnav-thread', `No Sales Navigator thread matched ${input}`);
}
return match.thread_id;
}
async function fetchThreadWithPagination(page, csrf, threadId, limit = DEFAULT_MESSAGE_LIMIT) {
let requested = THREAD_PAGE_SIZE;
if (limit < requested) requested = limit;
let thread = null;
for (let attempts = 0; attempts < 30; attempts += 1) {
thread = await fetchSalesnavJson(page, csrf, threadApiUrl(threadId, requested), 'Sales Navigator messaging thread API');
const total = Number(thread?.totalMessageCount || 0);
const have = Array.isArray(thread?.messages) ? thread.messages.length : 0;
if (have >= limit || (total && have >= total) || requested >= limit) break;
let nextRequested = requested + THREAD_PAGE_SIZE;
if (total && total > nextRequested) nextRequested = total;
if (nextRequested > limit) nextRequested = limit;
requested = nextRequested;
}
const total = Number(thread?.totalMessageCount || 0);
const have = Array.isArray(thread?.messages) ? thread.messages.length : 0;
if (total && have < total && have < limit) {
throw new CommandExecutionError(`Sales Navigator messaging thread API returned partial history (${have}/${total})`);
}
return thread;
}
cli({
site: 'linkedin',
name: 'salesnav-thread',
access: 'read',
description: 'Return full Sales Navigator message history for a thread id, Sales Navigator inbox URL, lead URL, recipient urn, or exact recipient name',
domain: LINKEDIN_DOMAIN,
strategy: Strategy.UI,
browser: true,
args: [
{ name: 'thread-or-recipient', type: 'string', required: true, positional: true, help: 'Sales Navigator inbox URL/thread id, Sales Navigator lead URL, recipient urn, or exact participant name' },
{ name: 'limit', type: 'number', default: DEFAULT_MESSAGE_LIMIT, help: 'Maximum messages to return (1-500)' },
{ name: 'max-pages', type: 'number', default: 30, help: 'Maximum inbox pages to scan when resolving a recipient' },
],
columns: ['index', 'thread_id', 'thread_url', 'sender', 'text', 'timestamp', 'subject', 'message_id', 'sender_urn', 'delivered_at', 'type', 'total_message_count'],
func: async (page, args) => {
if (!page) throw new CommandExecutionError('Browser session required for linkedin salesnav-thread');
const input = normalizeWhitespace(args['thread-or-recipient']);
if (!input) throw new ArgumentError('thread-or-recipient is required');
const limit = parseLimit(args.limit, DEFAULT_MESSAGE_LIMIT);
const maxPages = parseLimit(args['max-pages'], 30);
await page.goto(SALES_INBOX_URL);
await page.wait(4);
const threadId = await resolveThreadId(page, input, { maxPages });
const csrf = await getCsrf(page);
const thread = await fetchThreadWithPagination(page, csrf, threadId, limit);
const messages = parseSalesnavThreadMessages(thread).slice(0, limit);
if (messages.length === 0) throw new EmptyResultError('linkedin salesnav-thread', `No messages found for ${threadId}`);
return messages.map((message) => ({
...message,
thread_url: salesnavThreadUrl(threadId),
total_message_count: Number(thread?.totalMessageCount || messages.length),
}));
},
});
export const __test__ = {
parseThreadInput,
threadApiUrl,
participantIndex,
parseSalesnavThreadMessages,
threadMatchesInput,
salesnavThreadUrl,
};