From dc07756cb0274f7226033dfe3fbe45521bb933a3 Mon Sep 17 00:00:00 2001 From: Dandiggas Date: Sat, 4 Jul 2026 17:48:43 +0100 Subject: [PATCH 1/2] fix(messaging): percent-encode parentheses in msg_conversation URNs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit encodeURIComponent leaves ( ) unescaped, but msg_conversation URNs embed parentheses — urn:li:msg_conversation:(urn:li:fsd_profile:,). The raw parens break LinkedIn's REST-li variables=(...) parser, so get_conversation returned HTTP 400 for every real conversation URN. Encode ( and ) explicitly; add endpoint tests covering round-trip decoding and the absence of raw parens in the built path. --- src/browser/endpoints.ts | 6 +++++- tests/endpoints.test.ts | 30 ++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/browser/endpoints.ts b/src/browser/endpoints.ts index 88e4d5e..c990f48 100644 --- a/src/browser/endpoints.ts +++ b/src/browser/endpoints.ts @@ -398,7 +398,11 @@ export function conversationMessages( conversationUrn: string, queryId: string = KNOWN_QUERY_IDS.messagingMessages, ): string { - const urn = encodeURIComponent(conversationUrn); + // encodeURIComponent leaves `(` and `)` unescaped, but msg_conversation URNs + // contain parentheses: urn:li:msg_conversation:(urn:li:fsd_profile:,). + // Raw parens break the REST-li `variables=(...)` parser, so this endpoint + // returned HTTP 400 for every real conversation URN. Encode them explicitly. + const urn = encodeURIComponent(conversationUrn).replace(/\(/g, '%28').replace(/\)/g, '%29'); return `/voyagerMessagingGraphQL/graphql?queryId=${encodeURIComponent(queryId)}&variables=(conversationUrn:${urn})`; } diff --git a/tests/endpoints.test.ts b/tests/endpoints.test.ts index 2457236..2e4bf3b 100644 --- a/tests/endpoints.test.ts +++ b/tests/endpoints.test.ts @@ -13,6 +13,8 @@ import { companyById, messagingConversations, messagingConversationEvents, + inboxConversations, + conversationMessages, messagingConversationsGraphql, invitationsReceived, invitationsSent, @@ -243,3 +245,31 @@ describe('endpoints — determinism', () => { ); }); }); + +describe('endpoints — messaging GraphQL builders', () => { + it('inboxConversations() encodes the mailbox urn', () => { + expect(inboxConversations('ACoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA')).toBe( + `/voyagerMessagingGraphQL/graphql?queryId=${encodeURIComponent( + KNOWN_QUERY_IDS.messagingConversations, + )}&variables=(mailboxUrn:urn%3Ali%3Afsd_profile%3AACoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA)`, + ); + }); + + it('conversationMessages() percent-encodes the parentheses inside msg_conversation urns', () => { + // msg_conversation URNs contain ( ) and a comma; raw parens break the + // REST-li variables=(...) parser server-side (HTTP 400). + const urn = + 'urn:li:msg_conversation:(urn:li:fsd_profile:ACoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA,2-THREADID==)'; + const path = conversationMessages(urn); + const variables = path.split('&variables=')[1]; + expect(variables.startsWith('(conversationUrn:')).toBe(true); + const encodedUrn = variables.slice('(conversationUrn:'.length, -1); + // No raw parens may survive inside the urn value… + expect(encodedUrn).not.toContain('('); + expect(encodedUrn).not.toContain(')'); + expect(encodedUrn).toContain('%28'); + expect(encodedUrn).toContain('%29'); + // …and decoding restores the original urn exactly. + expect(decodeURIComponent(encodedUrn)).toBe(urn); + }); +}); From ab0a1ed95de0faec813e3a780b6163b4dd698b00 Mon Sep 17 00:00:00 2001 From: Dandiggas Date: Sat, 4 Jul 2026 17:48:43 +0100 Subject: [PATCH 2/2] feat(messaging): inbox participant identity + sorted, attributed messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_inbox: resolve the response's MessagingParticipant entities via *conversationParticipants and emit participants[] (name, headline, profileUrn, profileUrl) with the mailbox owner excluded; fall back to participant names for the title (1:1 threads have title: null) and expose the groupChat flag. Previously threads were unidentifiable without fetching each conversation. get_conversation: messages arrive unordered and unattributed — sort ascending by deliveredAt and resolve *sender/*actor to sender name + senderProfileUrn, plus a fromSelf flag derived from the mailbox-owner id embedded in the conversation URN (no extra /me round-trip). Covered by tests/normalize-messaging.test.ts with synthetic fixtures. --- src/browser/normalize.ts | 126 +++++++++++++++++++++++++++-- src/tools/discovery.ts | 4 +- tests/normalize-messaging.test.ts | 130 ++++++++++++++++++++++++++++++ 3 files changed, 253 insertions(+), 7 deletions(-) create mode 100644 tests/normalize-messaging.test.ts diff --git a/src/browser/normalize.ts b/src/browser/normalize.ts index d9b1521..9ed004d 100644 --- a/src/browser/normalize.ts +++ b/src/browser/normalize.ts @@ -124,26 +124,111 @@ export function ownFsdId(me: NormalizedResponse): string | undefined { return undefined; } +export interface ShapedParticipant { + name?: string; + headline?: string; + /** urn:li:fsd_profile: */ + profileUrn?: string; + profileUrl?: string; +} + export interface ShapedConversation { title?: string; + /** The other member(s) in the thread — the mailbox owner is excluded. */ + participants?: ShapedParticipant[]; + groupChat?: boolean; lastActivityAt?: number; unreadCount?: number; read?: boolean; conversationUrn?: string; } -/** Shape an inbox conversations response into a compact list. */ +/** + * Index the `com.linkedin.messenger.MessagingParticipant` entities of a + * messaging response by entityUrn + * (urn:li:msg_messagingParticipant:urn:li:fsd_profile:). + */ +function indexMessagingParticipants(resp: NormalizedResponse): Map { + const map = new Map(); + for (const e of resp.included ?? []) { + if ( + typeof e.$type === 'string' && + e.$type.endsWith('.MessagingParticipant') && + typeof e.entityUrn === 'string' + ) { + map.set(e.entityUrn, e); + } + } + return map; +} + +/** Shape a MessagingParticipant entity into a compact identity. */ +function shapeMessagingParticipant(p?: VoyagerEntity): ShapedParticipant | undefined { + if (!p) return undefined; + const member = (p['participantType'] as Record | undefined)?.['member'] as + | Record + | undefined; + const name = + [asText(member?.['firstName']), asText(member?.['lastName'])].filter(Boolean).join(' ') || + undefined; + return { + name, + headline: asText(member?.['headline']), + profileUrn: + typeof p['hostIdentityUrn'] === 'string' ? (p['hostIdentityUrn'] as string) : undefined, + profileUrl: + typeof member?.['profileUrl'] === 'string' ? (member['profileUrl'] as string) : undefined, + }; +} + +/** + * The mailbox owner's fsd_profile id is embedded in every msg_conversation URN: + * urn:li:msg_conversation:(urn:li:fsd_profile:,). + */ +function ownerIdFromConversationUrn(urn: unknown): string | undefined { + return typeof urn === 'string' ? urn.match(/urn:li:fsd_profile:([^,)]+)/)?.[1] : undefined; +} + +/** + * Shape an inbox conversations response into a compact list. + * + * Conversations reference their members via `*conversationParticipants` + * (MessagingParticipant URNs); resolving those against `included[]` yields the + * counterpart's name/headline/profile — without it, 1:1 threads (whose `title` + * is null) are unidentifiable without fetching each conversation. + */ export function shapeInbox(resp: NormalizedResponse): ShapedConversation[] { const out: ShapedConversation[] = []; + const participants = indexMessagingParticipants(resp); for (const e of resp.included ?? []) { if (typeof e.$type !== 'string' || !e.$type.endsWith('.Conversation')) continue; const c = e as Record; + const conversationUrn = + typeof c['entityUrn'] === 'string' ? (c['entityUrn'] as string) : undefined; + const ownerId = ownerIdFromConversationUrn(conversationUrn); + const partUrns = Array.isArray(c['*conversationParticipants']) + ? (c['*conversationParticipants'] as unknown[]) + : []; + const others = partUrns + .filter((u): u is string => typeof u === 'string' && (!ownerId || !u.includes(ownerId))) + .map((u) => shapeMessagingParticipant(participants.get(u))) + .filter((p): p is ShapedParticipant => !!p); out.push({ - title: asText(c['title']) ?? asText(c['headlineText']) ?? asText(c['shortHeadlineText']), - lastActivityAt: typeof c['lastActivityAt'] === 'number' ? (c['lastActivityAt'] as number) : undefined, + title: + asText(c['title']) ?? + asText(c['headlineText']) ?? + asText(c['shortHeadlineText']) ?? + (others + .map((p) => p.name) + .filter(Boolean) + .join(', ') || undefined), + participants: others.length ? others : undefined, + groupChat: typeof c['groupChat'] === 'boolean' ? (c['groupChat'] as boolean) : undefined, + lastActivityAt: + typeof c['lastActivityAt'] === 'number' ? (c['lastActivityAt'] as number) : undefined, unreadCount: typeof c['unreadCount'] === 'number' ? (c['unreadCount'] as number) : undefined, read: typeof c['read'] === 'boolean' ? (c['read'] as boolean) : undefined, - conversationUrn: typeof c['entityUrn'] === 'string' ? (c['entityUrn'] as string) : undefined, + conversationUrn, }); } return out; @@ -198,19 +283,50 @@ export function shapeJobDetails(resp: NormalizedResponse): ShapedJobDetails { export interface ShapedMessage { text?: string; deliveredAt?: number; + /** Sender display name, resolved from the response's MessagingParticipant entities. */ + sender?: string; + /** urn:li:fsd_profile: of the sender. */ + senderProfileUrn?: string; + /** True when the mailbox owner (authenticated user) sent the message. */ + fromSelf?: boolean; } -/** Shape a conversation's messages (tolerant; field names may vary by deploy). */ +/** + * Shape a conversation's messages (tolerant; field names may vary by deploy). + * + * Messages carry `*sender` / `*actor` (a MessagingParticipant URN) resolvable + * against the same response, and arrive in no guaranteed order — so attribute + * each message and sort ascending by deliveredAt. `fromSelf` is derived from + * the mailbox-owner id embedded in the message's `*conversation` URN, which + * avoids an extra `/me` round-trip. + */ export function shapeConversationMessages(resp: NormalizedResponse): ShapedMessage[] { const out: ShapedMessage[] = []; + const participants = indexMessagingParticipants(resp); for (const e of resp.included ?? []) { if (typeof e.$type !== 'string' || !e.$type.endsWith('.Message')) continue; const m = e as Record; + const senderUrn = + typeof m['*sender'] === 'string' + ? (m['*sender'] as string) + : typeof m['*actor'] === 'string' + ? (m['*actor'] as string) + : undefined; + const sender = shapeMessagingParticipant(senderUrn ? participants.get(senderUrn) : undefined); + const ownerId = + ownerIdFromConversationUrn(m['*conversation']) ?? + ownerIdFromConversationUrn(m['backendConversationUrn']); + const fromSelf = + ownerId && sender?.profileUrn ? sender.profileUrn.endsWith(ownerId) : undefined; out.push({ text: asText(m['body']) ?? asText(m['previewText']), deliveredAt: typeof m['deliveredAt'] === 'number' ? (m['deliveredAt'] as number) : undefined, + sender: sender?.name, + senderProfileUrn: sender?.profileUrn, + ...(typeof fromSelf === 'boolean' ? { fromSelf } : {}), }); } + out.sort((a, b) => (a.deliveredAt ?? 0) - (b.deliveredAt ?? 0)); return out; } diff --git a/src/tools/discovery.ts b/src/tools/discovery.ts index 525f93f..bf768c2 100644 --- a/src/tools/discovery.ts +++ b/src/tools/discovery.ts @@ -75,7 +75,7 @@ export function registerDiscoveryTools( server.tool( 'get_inbox', - 'List your recent LinkedIn messaging conversations (title, last activity, unread count).', + 'List your recent LinkedIn messaging conversations (participants with name/headline/profile, title, last activity, unread count).', {}, async () => run(logger, 'get_inbox', async () => { @@ -208,7 +208,7 @@ export function registerDiscoveryTools( server.tool( 'get_conversation', - 'Read messages in a LinkedIn conversation by its URN (get the URN from get_inbox).', + 'Read messages in a LinkedIn conversation by its URN (get the URN from get_inbox). Messages are sorted oldest-first with sender attribution (name + fromSelf flag).', { conversation_urn: z .string() diff --git a/tests/normalize-messaging.test.ts b/tests/normalize-messaging.test.ts new file mode 100644 index 0000000..ff33cde --- /dev/null +++ b/tests/normalize-messaging.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect } from 'vitest'; +import { + shapeInbox, + shapeConversationMessages, + type NormalizedResponse, +} from '../src/browser/normalize.js'; + +/** + * Synthetic fixtures mirroring the live messenger GraphQL shape + * (captured 2026-07-04): + * - Conversation.`*conversationParticipants` → MessagingParticipant URNs + * - MessagingParticipant.hostIdentityUrn + participantType.member.{firstName,lastName,headline}.text + * - Message.`*sender` / `*actor` → MessagingParticipant URN + * - the mailbox owner's fsd_profile id is embedded in the msg_conversation URN + */ + +const OWNER_ID = 'ACoAAOWNEROWNEROWNEROWNEROWNEROWNEROWNER'; +const OTHER_ID = 'ACoAAOTHEROTHEROTHEROTHEROTHEROTHEROTHER'; +const CONV_URN = `urn:li:msg_conversation:(urn:li:fsd_profile:${OWNER_ID},2-THREADID==)`; + +const participant = (id: string, first: string, last: string, headline: string) => ({ + $type: 'com.linkedin.messenger.MessagingParticipant', + entityUrn: `urn:li:msg_messagingParticipant:urn:li:fsd_profile:${id}`, + hostIdentityUrn: `urn:li:fsd_profile:${id}`, + participantType: { + member: { + profileUrl: `https://www.linkedin.com/in/${id}`, + firstName: { text: first }, + lastName: { text: last }, + headline: { text: headline }, + }, + }, +}); + +const inboxResp: NormalizedResponse = { + included: [ + participant(OWNER_ID, 'Owner', 'Member', 'Mailbox owner'), + participant(OTHER_ID, 'Ada', 'Lovelace', 'Engineer at Example'), + { + $type: 'com.linkedin.messenger.Conversation', + entityUrn: CONV_URN, + title: null, + groupChat: false, + lastActivityAt: 1700000300000, + unreadCount: 1, + read: false, + '*conversationParticipants': [ + `urn:li:msg_messagingParticipant:urn:li:fsd_profile:${OTHER_ID}`, + `urn:li:msg_messagingParticipant:urn:li:fsd_profile:${OWNER_ID}`, + ], + }, + ], +}; + +const message = (id: string, senderId: string, deliveredAt: number, text: string) => ({ + $type: 'com.linkedin.messenger.Message', + entityUrn: `urn:li:msg_message:(urn:li:fsd_profile:${OWNER_ID},${id})`, + '*conversation': CONV_URN, + '*sender': `urn:li:msg_messagingParticipant:urn:li:fsd_profile:${senderId}`, + body: { text }, + deliveredAt, +}); + +const conversationResp: NormalizedResponse = { + included: [ + participant(OWNER_ID, 'Owner', 'Member', 'Mailbox owner'), + participant(OTHER_ID, 'Ada', 'Lovelace', 'Engineer at Example'), + // Deliberately out of order — the live API gives no ordering guarantee. + message('m2', OWNER_ID, 1700000200000, 'Sounds good, thanks!'), + message('m1', OTHER_ID, 1700000100000, 'Hi — are you free next week?'), + ], +}; + +describe('shapeInbox — participant identity', () => { + it('resolves the counterpart (name, headline, profileUrn/Url) and excludes the mailbox owner', () => { + const [conv] = shapeInbox(inboxResp); + expect(conv.participants).toHaveLength(1); + expect(conv.participants?.[0]).toMatchObject({ + name: 'Ada Lovelace', + headline: 'Engineer at Example', + profileUrn: `urn:li:fsd_profile:${OTHER_ID}`, + }); + }); + + it('falls back to participant names when the conversation has no title (1:1 threads)', () => { + const [conv] = shapeInbox(inboxResp); + expect(conv.title).toBe('Ada Lovelace'); + }); + + it('keeps the existing compact fields', () => { + const [conv] = shapeInbox(inboxResp); + expect(conv).toMatchObject({ + groupChat: false, + lastActivityAt: 1700000300000, + unreadCount: 1, + read: false, + conversationUrn: CONV_URN, + }); + }); +}); + +describe('shapeConversationMessages — ordering and attribution', () => { + it('sorts messages ascending by deliveredAt', () => { + const msgs = shapeConversationMessages(conversationResp); + expect(msgs.map((m) => m.deliveredAt)).toEqual([1700000100000, 1700000200000]); + }); + + it('attributes each message with sender name and profile urn', () => { + const [first, second] = shapeConversationMessages(conversationResp); + expect(first.sender).toBe('Ada Lovelace'); + expect(first.senderProfileUrn).toBe(`urn:li:fsd_profile:${OTHER_ID}`); + expect(second.sender).toBe('Owner Member'); + }); + + it('flags the mailbox owner via fromSelf (derived from the conversation urn, no /me call)', () => { + const [fromOther, fromOwner] = shapeConversationMessages(conversationResp); + expect(fromOther.fromSelf).toBe(false); + expect(fromOwner.fromSelf).toBe(true); + }); + + it('omits attribution gracefully when participants are missing', () => { + const bare: NormalizedResponse = { + included: [message('m1', OTHER_ID, 1700000100000, 'Hello')], + }; + const [msg] = shapeConversationMessages(bare); + expect(msg.text).toBe('Hello'); + expect(msg.sender).toBeUndefined(); + expect(msg.fromSelf).toBeUndefined(); + }); +});