Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/browser/endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:<id>,<threadId>).
// 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})`;
}

Expand Down
126 changes: 121 additions & 5 deletions src/browser/normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,26 +124,111 @@ export function ownFsdId(me: NormalizedResponse): string | undefined {
return undefined;
}

export interface ShapedParticipant {
name?: string;
headline?: string;
/** urn:li:fsd_profile:<id> */
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:<id>).
*/
function indexMessagingParticipants(resp: NormalizedResponse): Map<string, VoyagerEntity> {
const map = new Map<string, VoyagerEntity>();
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<string, unknown> | undefined)?.['member'] as
| Record<string, unknown>
| 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:<OWNER>,<threadId>).
*/
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<string, unknown>;
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;
Expand Down Expand Up @@ -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:<id> 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<string, unknown>;
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;
}

Expand Down
4 changes: 2 additions & 2 deletions src/tools/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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()
Expand Down
30 changes: 30 additions & 0 deletions tests/endpoints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {
companyById,
messagingConversations,
messagingConversationEvents,
inboxConversations,
conversationMessages,
messagingConversationsGraphql,
invitationsReceived,
invitationsSent,
Expand Down Expand Up @@ -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);
});
});
130 changes: 130 additions & 0 deletions tests/normalize-messaging.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});